@camstack/system 1.2.308 → 1.2.309
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/autotrack/index.js +1 -1
- package/dist/builtins/autotrack/index.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +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/composed-device.d.ts +26 -0
- package/dist/builtins/composer/composer.addon.d.ts +12 -0
- package/dist/builtins/composer/composer.addon.js +1682 -0
- package/dist/builtins/composer/composer.addon.mjs +1677 -0
- package/dist/builtins/composer/composer.d.ts +103 -0
- package/dist/builtins/composer/composition-declarations.d.ts +53 -0
- package/dist/builtins/composer/composition-runtime.d.ts +88 -0
- package/dist/builtins/composer/deadline-timer.d.ts +18 -0
- package/dist/builtins/composer/index.d.ts +1 -0
- package/dist/builtins/composer/index.js +8 -0
- package/dist/builtins/composer/index.mjs +2 -0
- package/dist/builtins/composer/source-tracker.d.ts +78 -0
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/block-supervisor.d.ts +32 -1
- package/dist/builtins/core-blocks/blocks-integration.d.ts +5 -0
- package/dist/builtins/core-blocks/composition-api.d.ts +25 -0
- package/dist/builtins/core-blocks/composition-sources.d.ts +10 -0
- package/dist/builtins/core-blocks/core-block-store.d.ts +34 -2
- package/dist/builtins/core-blocks/core-blocks.addon.d.ts +6 -0
- package/dist/builtins/core-blocks/core-blocks.addon.js +504 -11
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +504 -11
- package/dist/builtins/device-manager/device-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +10 -31
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +9 -30
- 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/shared/device-state-changed.d.ts +17 -0
- 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/device-state-changed-Bk7cvTnz.js +31 -0
- package/dist/device-state-changed-DoBbkyEI.mjs +20 -0
- package/dist/{dist-DygWCI8k.mjs → dist-BEuQPDk_.mjs} +1286 -79
- package/dist/{dist-DGhk7Tay.js → dist-BhO9eYKO.js} +1375 -78
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -1
- package/dist/index.mjs +4 -2
- package/dist/{retired-settings-keys-z02aqTy7.mjs → retired-settings-keys-CQvG6B-Q.mjs} +1 -1
- package/dist/{retired-settings-keys-IZ9MH0GO.js → retired-settings-keys-DHgnJqS3.js} +1 -1
- package/package.json +20 -1
|
@@ -0,0 +1,1677 @@
|
|
|
1
|
+
import { $ as compositionSliceKey, K as buildCapabilityLookup, Ln as DeviceType, Q as composerCapability, Sn as toExpressionValue, Z as composedDeviceStableId, c as COMPOSER_ADDON_ID, et as compositionSourceKey, g as DeclaredDevices, i as BaseDevice, ir as EventCategory, jn as BaseAddon, kn as errMsg, t as ALL_CAPABILITY_DEFINITIONS, tn as planComposition, xt as evaluateCompositionField } from "../../dist-BEuQPDk_.mjs";
|
|
2
|
+
import { n as parseDeviceStateChanged, t as isRecord } from "../../device-state-changed-DoBbkyEI.mjs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/builtins/composer/composed-device.ts
|
|
5
|
+
/**
|
|
6
|
+
* A composed device: an ordinary `BaseDevice` whose caps are served by the
|
|
7
|
+
* composer (D659). Every consumer (exporters, NC, UI, HomeKit) reads it
|
|
8
|
+
* through its bindings, unchanged.
|
|
9
|
+
*
|
|
10
|
+
* It serves NO command method: slice 1 refuses any cap with one
|
|
11
|
+
* (`has-commands`), and commands arrive with `forward` in slice 3. So each
|
|
12
|
+
* composed cap is registered with an EMPTY native provider, and `getStatus`
|
|
13
|
+
* (R4) is the kernel's: `registerNativeCap` installs a default that reads the
|
|
14
|
+
* device's runtime slice for any cap declaring both `status` and
|
|
15
|
+
* `runtimeState` (`device-cap-proxy.ts`, "Default `getStatus`"), on the
|
|
16
|
+
* registry path and the forked-runner (UDS) path alike. Every composable cap
|
|
17
|
+
* qualifies: the validator refuses a cap with no `runtimeState`
|
|
18
|
+
* (`validate-composition.ts`, `not-composable`). The default is correct only
|
|
19
|
+
* because, for every composable cap, `status.schema` and `runtimeState` have
|
|
20
|
+
* one shape — `composed-device.spec.ts` enumerates the real cap registry and
|
|
21
|
+
* fails the day one breaks it (slice 3 lifting `has-commands` makes
|
|
22
|
+
* `recording-signal` such a cap). `room-device.e2e.spec.ts` proves R4 through
|
|
23
|
+
* the real registry. Its type comes from the declaration, not from
|
|
24
|
+
* `deviceMeta`: a failed meta read builds a device as `generic`, and the
|
|
25
|
+
* declaration is the authority for what this device is.
|
|
26
|
+
*/
|
|
27
|
+
var COMPOSED_DEVICE_CONFIG_SCHEMA = z.object({});
|
|
28
|
+
var ComposedDevice = class extends BaseDevice {
|
|
29
|
+
features = [];
|
|
30
|
+
constructor(ctx, type, caps) {
|
|
31
|
+
super(ctx, COMPOSED_DEVICE_CONFIG_SCHEMA, { type });
|
|
32
|
+
for (const cap of caps) ctx.registerNativeCap(cap, {});
|
|
33
|
+
this.markOnline(false);
|
|
34
|
+
}
|
|
35
|
+
seed(capName, slice) {
|
|
36
|
+
try {
|
|
37
|
+
this.runtimeState.setCapState(capName, { ...slice });
|
|
38
|
+
return { ok: true };
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
error: errMsg(err)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
patch(capName, patch) {
|
|
47
|
+
try {
|
|
48
|
+
this.runtimeState.patchCapState(capName, { ...patch });
|
|
49
|
+
return { ok: true };
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
error: errMsg(err)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
setAvailable(available) {
|
|
58
|
+
this.markOnline(available);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
function composedDeviceClass(type, caps) {
|
|
62
|
+
return class extends ComposedDevice {
|
|
63
|
+
constructor(ctx) {
|
|
64
|
+
super(ctx, type, caps);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A composed device kept alive by the withdrawal gate for a persisted row whose
|
|
70
|
+
* block this process has not planned (yet): no caps, offline. Its type is the
|
|
71
|
+
* ROW's (`deviceMeta.type`) — only ever adopted from an existing row, where the
|
|
72
|
+
* row is canonical — because there is no plan to take one from.
|
|
73
|
+
*/
|
|
74
|
+
var ComposedPlaceholderDevice = class extends ComposedDevice {
|
|
75
|
+
constructor(ctx) {
|
|
76
|
+
super(ctx, ctx.deviceMeta.type, []);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
function isComposedDevice(device) {
|
|
80
|
+
return device instanceof ComposedDevice;
|
|
81
|
+
}
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/builtins/composer/composition-declarations.ts
|
|
84
|
+
/**
|
|
85
|
+
* Which composed devices exist (D659): one declaration per composition block,
|
|
86
|
+
* owned by `composer` and hung off the shared Blocks integration.
|
|
87
|
+
*
|
|
88
|
+
* `DeclaredDevices` sweeps any row whose declaration is absent. A block missing
|
|
89
|
+
* from ONE apply (a transient store read, a race) would delete the device, and
|
|
90
|
+
* with it its id, exports and rules. The `WithdrawalGate` is D49 for that path:
|
|
91
|
+
* a withdrawal needs a second consecutive apply to agree. The other half of the
|
|
92
|
+
* rule — nothing is declared or withdrawn while the `core_blocks` store has not
|
|
93
|
+
* loaded — is `Composer.apply`'s, which never reaches this gate then (R2).
|
|
94
|
+
*/
|
|
95
|
+
function declarationFor(input) {
|
|
96
|
+
return {
|
|
97
|
+
stableId: composedDeviceStableId(input.blockId),
|
|
98
|
+
name: input.name,
|
|
99
|
+
type: input.target.type,
|
|
100
|
+
DeviceClass: composedDeviceClass(input.target.type, input.caps),
|
|
101
|
+
...input.target.role === void 0 ? {} : { role: input.target.role }
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** What forces a rebuild of the live device: type, role, cap set. Never the name. */
|
|
105
|
+
function shapeOf(input) {
|
|
106
|
+
return JSON.stringify([
|
|
107
|
+
input.target.type,
|
|
108
|
+
input.target.role ?? null,
|
|
109
|
+
input.caps.map((c) => c.name).toSorted()
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The declaration that holds a persisted composed row whose block has no plan
|
|
114
|
+
* in this apply. `name` and `type` are used only if the row has to be CREATED,
|
|
115
|
+
* which `DeclaredDevices` does not do for a row it finds: it adopts it, and the
|
|
116
|
+
* row's own name and type stay canonical (`ComposedPlaceholderDevice`).
|
|
117
|
+
*/
|
|
118
|
+
function placeholderDeclaration(stableId) {
|
|
119
|
+
return {
|
|
120
|
+
stableId,
|
|
121
|
+
name: stableId,
|
|
122
|
+
type: DeviceType.Generic,
|
|
123
|
+
DeviceClass: ComposedPlaceholderDevice
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
var WITHDRAWAL_CONFIRMATIONS = 2;
|
|
127
|
+
var WithdrawalGate = class {
|
|
128
|
+
logger;
|
|
129
|
+
deviceIdOf;
|
|
130
|
+
known = /* @__PURE__ */ new Map();
|
|
131
|
+
misses = /* @__PURE__ */ new Map();
|
|
132
|
+
persistedIds = /* @__PURE__ */ new Map();
|
|
133
|
+
constructor(logger, deviceIdOf) {
|
|
134
|
+
this.logger = logger;
|
|
135
|
+
this.deviceIdOf = deviceIdOf;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The rows that exist before this process applied anything. Called once at
|
|
139
|
+
* boot, before the first `filter`: without it the mirror starts empty and a
|
|
140
|
+
* row missing from the first apply after a restart (a release, a block that
|
|
141
|
+
* no longer plans) is swept at once — one apply, not two. A seeded row
|
|
142
|
+
* missing from that apply counts as its FIRST miss and is held by
|
|
143
|
+
* `placeholderDeclaration`. A row the gate already knows keeps its entry and
|
|
144
|
+
* its miss count.
|
|
145
|
+
*/
|
|
146
|
+
seed(rows) {
|
|
147
|
+
const fresh = rows.filter((row) => !this.known.has(row.stableId));
|
|
148
|
+
this.known = new Map([...this.known, ...fresh.map((row) => [row.stableId, placeholderDeclaration(row.stableId)])]);
|
|
149
|
+
this.persistedIds = new Map([...this.persistedIds, ...rows.map((row) => [row.stableId, row.id])]);
|
|
150
|
+
}
|
|
151
|
+
filter(current) {
|
|
152
|
+
const present = new Map(current.map((d) => [d.stableId, d]));
|
|
153
|
+
const kept = [];
|
|
154
|
+
const misses = /* @__PURE__ */ new Map();
|
|
155
|
+
for (const [stableId, declaration] of this.known) {
|
|
156
|
+
if (present.has(stableId)) continue;
|
|
157
|
+
const count = (this.misses.get(stableId) ?? 0) + 1;
|
|
158
|
+
if (count < WITHDRAWAL_CONFIRMATIONS) {
|
|
159
|
+
this.logger.warn("a composition vanished from one apply — its device is kept until a second apply agrees", {
|
|
160
|
+
tags: this.tagsOf(stableId),
|
|
161
|
+
meta: {
|
|
162
|
+
stableId,
|
|
163
|
+
misses: count
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
kept.push(declaration);
|
|
167
|
+
misses.set(stableId, count);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
this.logger.info("a composition is gone on two consecutive applies — its device is withdrawn", {
|
|
171
|
+
tags: this.tagsOf(stableId),
|
|
172
|
+
meta: { stableId }
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
this.known = new Map([...kept.map((d) => [d.stableId, d]), ...present]);
|
|
176
|
+
this.misses = misses;
|
|
177
|
+
return [...current, ...kept];
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The live device's id, else the persisted row's. A declaration whose device
|
|
181
|
+
* was never built and never persisted has no numeric id to carry: the
|
|
182
|
+
* stableId in `meta` is then all there is.
|
|
183
|
+
*/
|
|
184
|
+
tagsOf(stableId) {
|
|
185
|
+
const deviceId = this.deviceIdOf(stableId) ?? this.persistedIds.get(stableId) ?? null;
|
|
186
|
+
return deviceId === null ? {} : { deviceId };
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
//#endregion
|
|
190
|
+
//#region src/builtins/composer/composition-runtime.ts
|
|
191
|
+
/**
|
|
192
|
+
* One running composition: per-field state, writes to the composed device, and
|
|
193
|
+
* one deadline timer (D659).
|
|
194
|
+
*
|
|
195
|
+
* - Only the fields a change affects are recomputed.
|
|
196
|
+
* - A cap is SEEDED once (`setCapState`, every required field present), then
|
|
197
|
+
* PATCHED (`patchCapState` merges; the runtime-state twin of "`setAll`
|
|
198
|
+
* already merges").
|
|
199
|
+
* - Unavailable is per field. A nullable field is written `null`; a
|
|
200
|
+
* non-nullable one is not written and the device goes offline (D393).
|
|
201
|
+
* - A `clock` field (`now` only) is recomputed when a sibling of its cap
|
|
202
|
+
* changes value, so `binary.lastChangedAt` is the last transition. A
|
|
203
|
+
* `constant` is computed once.
|
|
204
|
+
* - A windowed expression reports when it flips; ONE timer at the earliest
|
|
205
|
+
* such instant. It waits for no source (D3). Every evaluation is STORED, so
|
|
206
|
+
* a deadline that moved reaches the timer even when the value did not: an
|
|
207
|
+
* unstored deadline fires early, re-evaluates to "same", and re-arms
|
|
208
|
+
* `setTimeout(0)` until the real window closes — a poll (R1).
|
|
209
|
+
* - A fatal (source gone) outcome is the block's failure, and it is sticky:
|
|
210
|
+
* the device stays offline until a new runtime, created on an operator edit
|
|
211
|
+
* or restart, clears it.
|
|
212
|
+
* - A write the target REFUSES or THROWS on leaves the evaluated outcomes
|
|
213
|
+
* alone and marks its cap write-pending: the next pass that evaluates any
|
|
214
|
+
* field of that cap, and every deadline pass, re-writes the WHOLE current
|
|
215
|
+
* slice (a seed if it was never seeded). No retry timer — the next source
|
|
216
|
+
* event or deadline is the retry (D3). One log line per failure transition.
|
|
217
|
+
* - Nothing escapes: a pass that throws is logged, and the timer is re-armed
|
|
218
|
+
* from the stored deadlines regardless.
|
|
219
|
+
* - `stop()` is terminal: every entry point is a no-op afterwards.
|
|
220
|
+
*/
|
|
221
|
+
function errorText(error) {
|
|
222
|
+
return error instanceof Error ? error.message : String(error);
|
|
223
|
+
}
|
|
224
|
+
function pendingReason(cap, pending) {
|
|
225
|
+
return pending.kind === "refused" ? `the \`${cap}\` capability refused the value: ${pending.error}` : `writing the \`${cap}\` capability threw: ${pending.error}`;
|
|
226
|
+
}
|
|
227
|
+
function fieldKey(plan) {
|
|
228
|
+
return `${plan.cap}.${plan.path}`;
|
|
229
|
+
}
|
|
230
|
+
function valueOf(outcome) {
|
|
231
|
+
return outcome === null || outcome.state === "unavailable" ? void 0 : outcome.value;
|
|
232
|
+
}
|
|
233
|
+
function reasonOf(outcome) {
|
|
234
|
+
if (outcome === null) return "not evaluated yet";
|
|
235
|
+
return outcome.reason;
|
|
236
|
+
}
|
|
237
|
+
/** State, value and reason — what a write or a log line is about. NEVER the deadline (R1). */
|
|
238
|
+
function sameOutcome(a, b) {
|
|
239
|
+
return a !== null && a.state === b.state && valueOf(a) === valueOf(b) && reasonOf(a) === reasonOf(b);
|
|
240
|
+
}
|
|
241
|
+
/** A source is GONE: the outcome that must never be masked by a non-fatal one. */
|
|
242
|
+
function isFatal(outcome) {
|
|
243
|
+
return outcome !== null && outcome.state === "unavailable" && outcome.fatal;
|
|
244
|
+
}
|
|
245
|
+
function writable(record) {
|
|
246
|
+
const outcome = record.outcome;
|
|
247
|
+
if (outcome === null) return void 0;
|
|
248
|
+
if (outcome.state !== "unavailable") return outcome.value;
|
|
249
|
+
return record.plan.unavailable === "null" ? null : void 0;
|
|
250
|
+
}
|
|
251
|
+
var CompositionRuntime = class {
|
|
252
|
+
deps;
|
|
253
|
+
records;
|
|
254
|
+
seeded = /* @__PURE__ */ new Set();
|
|
255
|
+
available = null;
|
|
256
|
+
failure = null;
|
|
257
|
+
disabledReason = null;
|
|
258
|
+
stopped = false;
|
|
259
|
+
/** The last `pass threw` logged (`during: message`); cleared by a pass that completes. */
|
|
260
|
+
lastPassError = null;
|
|
261
|
+
pending = /* @__PURE__ */ new Map();
|
|
262
|
+
heldBack = /* @__PURE__ */ new Set();
|
|
263
|
+
constructor(deps) {
|
|
264
|
+
this.deps = deps;
|
|
265
|
+
this.records = new Map(deps.fields.map((plan) => [fieldKey(plan), {
|
|
266
|
+
plan,
|
|
267
|
+
outcome: null,
|
|
268
|
+
written: void 0
|
|
269
|
+
}]));
|
|
270
|
+
}
|
|
271
|
+
start() {
|
|
272
|
+
if (this.disabledReason !== null) return;
|
|
273
|
+
this.guarded("start", true, () => this.pass([...this.records.keys()], "start"));
|
|
274
|
+
}
|
|
275
|
+
onSourcesChanged(sliceKeys) {
|
|
276
|
+
if (this.disabledReason !== null) return;
|
|
277
|
+
this.guarded("source", true, () => {
|
|
278
|
+
const affected = [...this.records.values()].filter((r) => r.plan.reads.some((read) => sliceKeys.has(compositionSliceKey(read.source, read.cap)))).map((r) => fieldKey(r.plan));
|
|
279
|
+
if (affected.length > 0) this.pass(affected, "source");
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
disable(reason) {
|
|
283
|
+
if (this.stopped) return;
|
|
284
|
+
this.disabledReason = reason;
|
|
285
|
+
this.deps.timer.disarm();
|
|
286
|
+
this.guarded("disable", false, () => {
|
|
287
|
+
const outcome = {
|
|
288
|
+
state: "unavailable",
|
|
289
|
+
value: null,
|
|
290
|
+
reason,
|
|
291
|
+
fatal: false
|
|
292
|
+
};
|
|
293
|
+
const touched = [...this.records.values()].filter((r) => !isFatal(r.outcome) && !sameOutcome(r.outcome, outcome));
|
|
294
|
+
for (const r of touched) this.logTransition(r, outcome);
|
|
295
|
+
this.records = new Map([...this.records].map(([k, r]) => [k, isFatal(r.outcome) ? r : {
|
|
296
|
+
...r,
|
|
297
|
+
outcome
|
|
298
|
+
}]));
|
|
299
|
+
this.writeCaps(new Set(touched.map((r) => fieldKey(r.plan))), /* @__PURE__ */ new Set());
|
|
300
|
+
this.updateAvailability();
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/** Terminal: no write, no evaluation and no timer after this. */
|
|
304
|
+
stop() {
|
|
305
|
+
this.stopped = true;
|
|
306
|
+
this.deps.timer.disarm();
|
|
307
|
+
}
|
|
308
|
+
state() {
|
|
309
|
+
return {
|
|
310
|
+
failure: this.failure,
|
|
311
|
+
available: this.available === true,
|
|
312
|
+
fields: [...this.records.values()].map((r) => {
|
|
313
|
+
const pending = this.pending.get(r.plan.cap);
|
|
314
|
+
if (pending !== void 0 && !isFatal(r.outcome)) return {
|
|
315
|
+
cap: r.plan.cap,
|
|
316
|
+
path: r.plan.path,
|
|
317
|
+
state: "unavailable",
|
|
318
|
+
value: null,
|
|
319
|
+
reason: pendingReason(r.plan.cap, pending)
|
|
320
|
+
};
|
|
321
|
+
const value = valueOf(r.outcome);
|
|
322
|
+
const reason = reasonOf(r.outcome);
|
|
323
|
+
return {
|
|
324
|
+
cap: r.plan.cap,
|
|
325
|
+
path: r.plan.path,
|
|
326
|
+
state: r.outcome?.state ?? "unavailable",
|
|
327
|
+
value: value === void 0 ? null : value,
|
|
328
|
+
...reason === null ? {} : { reason }
|
|
329
|
+
};
|
|
330
|
+
})
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Every entry point runs here: a no-op once stopped, nothing thrown escapes,
|
|
335
|
+
* and the one timer is re-armed from the stored deadlines whatever happened.
|
|
336
|
+
*/
|
|
337
|
+
guarded(during, rearm, body) {
|
|
338
|
+
if (this.stopped) return;
|
|
339
|
+
let threw = false;
|
|
340
|
+
try {
|
|
341
|
+
body();
|
|
342
|
+
this.lastPassError = null;
|
|
343
|
+
} catch (error) {
|
|
344
|
+
threw = true;
|
|
345
|
+
this.logPassError(during, errorText(error));
|
|
346
|
+
} finally {
|
|
347
|
+
if (rearm && !this.stopped && this.disabledReason === null) this.armDeadline(threw ? this.deps.now() : null);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/** Once per distinct failure, not once per pass: a reader that keeps throwing logs one line. */
|
|
351
|
+
logPassError(during, error) {
|
|
352
|
+
const key = `${during}: ${error}`;
|
|
353
|
+
if (key === this.lastPassError) return;
|
|
354
|
+
this.lastPassError = key;
|
|
355
|
+
this.deps.logger.error("composition pass threw — the runtime keeps running; the next source event or deadline re-runs it", {
|
|
356
|
+
tags: { deviceId: this.deps.target.id },
|
|
357
|
+
meta: {
|
|
358
|
+
blockId: this.deps.blockId,
|
|
359
|
+
during,
|
|
360
|
+
error
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
pass(keys, trigger) {
|
|
365
|
+
const initial = trigger === "start";
|
|
366
|
+
const now = this.deps.now();
|
|
367
|
+
const derived = this.evaluate(keys.filter((k) => this.records.get(k)?.plan.role === "derived"), now);
|
|
368
|
+
const followers = [...this.records.values()].filter((r) => r.plan.role === "clock" ? initial || derived.valueCaps.has(r.plan.cap) : r.plan.role === "constant" && initial).map((r) => fieldKey(r.plan));
|
|
369
|
+
const extra = this.evaluate(followers, now);
|
|
370
|
+
const evaluatedCaps = new Set([...keys, ...followers].flatMap((k) => {
|
|
371
|
+
const cap = this.records.get(k)?.plan.cap;
|
|
372
|
+
return cap === void 0 ? [] : [cap];
|
|
373
|
+
}));
|
|
374
|
+
const retry = new Set([...this.pending.keys()].filter((cap) => trigger === "deadline" || evaluatedCaps.has(cap)));
|
|
375
|
+
this.writeCaps(new Set([...derived.keys, ...extra.keys]), retry);
|
|
376
|
+
this.updateFailure();
|
|
377
|
+
this.updateAvailability();
|
|
378
|
+
}
|
|
379
|
+
evaluate(keys, now) {
|
|
380
|
+
const changed = /* @__PURE__ */ new Set();
|
|
381
|
+
const valueCaps = /* @__PURE__ */ new Set();
|
|
382
|
+
const next = new Map(this.records);
|
|
383
|
+
try {
|
|
384
|
+
for (const key of keys) {
|
|
385
|
+
const record = next.get(key);
|
|
386
|
+
if (record === void 0) continue;
|
|
387
|
+
const outcome = evaluateCompositionField({
|
|
388
|
+
source: record.plan.source,
|
|
389
|
+
read: this.deps.read,
|
|
390
|
+
now
|
|
391
|
+
});
|
|
392
|
+
next.set(key, {
|
|
393
|
+
...record,
|
|
394
|
+
outcome
|
|
395
|
+
});
|
|
396
|
+
if (sameOutcome(record.outcome, outcome)) continue;
|
|
397
|
+
if (record.outcome?.state !== outcome.state) this.logTransition(record, outcome);
|
|
398
|
+
if (valueOf(record.outcome) !== valueOf(outcome)) valueCaps.add(record.plan.cap);
|
|
399
|
+
changed.add(key);
|
|
400
|
+
}
|
|
401
|
+
} finally {
|
|
402
|
+
this.records = next;
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
keys: changed,
|
|
406
|
+
valueCaps
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
recordsOf(cap) {
|
|
410
|
+
return [...this.records.values()].filter((r) => r.plan.cap === cap);
|
|
411
|
+
}
|
|
412
|
+
/** `retry`: write-pending caps this pass re-writes WHOLE, whether or not a value changed. */
|
|
413
|
+
writeCaps(touched, retry) {
|
|
414
|
+
const caps = new Set([...[...touched].flatMap((k) => {
|
|
415
|
+
const cap = this.records.get(k)?.plan.cap;
|
|
416
|
+
return cap === void 0 ? [] : [cap];
|
|
417
|
+
}), ...retry]);
|
|
418
|
+
for (const cap of caps) if (!this.seeded.has(cap)) this.seedCap(cap);
|
|
419
|
+
else if (this.pending.has(cap)) this.patchWholeCap(cap);
|
|
420
|
+
else this.patchCap(cap, touched);
|
|
421
|
+
}
|
|
422
|
+
seedCap(cap) {
|
|
423
|
+
const records = this.recordsOf(cap);
|
|
424
|
+
const missing = records.filter((r) => r.plan.required && writable(r) === void 0).map((r) => r.plan.path);
|
|
425
|
+
if (missing.length > 0) return this.holdBackSeed(cap, missing);
|
|
426
|
+
this.heldBack = new Set([...this.heldBack].filter((c) => c !== cap));
|
|
427
|
+
const entries = this.writableEntries(records);
|
|
428
|
+
if (!this.write(cap, "seed", entries)) return;
|
|
429
|
+
this.seeded = new Set([...this.seeded, cap]);
|
|
430
|
+
this.markWritten(cap, entries);
|
|
431
|
+
}
|
|
432
|
+
patchCap(cap, touched) {
|
|
433
|
+
const entries = this.writableEntries(this.recordsOf(cap).filter((r) => touched.has(fieldKey(r.plan)) && writable(r) !== r.written));
|
|
434
|
+
if (entries.length === 0) return;
|
|
435
|
+
if (this.write(cap, "patch", entries)) this.markWritten(cap, entries);
|
|
436
|
+
}
|
|
437
|
+
/** After a failed write nothing is known about what landed: send every current value. */
|
|
438
|
+
patchWholeCap(cap) {
|
|
439
|
+
const entries = this.writableEntries(this.recordsOf(cap));
|
|
440
|
+
if (entries.length === 0) return;
|
|
441
|
+
if (this.write(cap, "patch", entries)) this.markWritten(cap, entries);
|
|
442
|
+
}
|
|
443
|
+
writableEntries(records) {
|
|
444
|
+
return records.flatMap((r) => {
|
|
445
|
+
const v = writable(r);
|
|
446
|
+
return v === void 0 ? [] : [[r.plan.path, v]];
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
holdBackSeed(cap, missing) {
|
|
450
|
+
if (this.heldBack.has(cap)) return;
|
|
451
|
+
this.heldBack = new Set([...this.heldBack, cap]);
|
|
452
|
+
this.deps.logger.warn("composed capability not seeded yet — a required field has no value", {
|
|
453
|
+
tags: { deviceId: this.deps.target.id },
|
|
454
|
+
meta: {
|
|
455
|
+
blockId: this.deps.blockId,
|
|
456
|
+
cap,
|
|
457
|
+
missing
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
/** One write. A refusal or a throw marks the cap write-pending; true when it landed. */
|
|
462
|
+
write(cap, op, entries) {
|
|
463
|
+
const values = Object.fromEntries(entries);
|
|
464
|
+
let failed;
|
|
465
|
+
try {
|
|
466
|
+
const outcome = op === "seed" ? this.deps.target.seed(cap, values) : this.deps.target.patch(cap, values);
|
|
467
|
+
failed = outcome.ok ? null : {
|
|
468
|
+
kind: "refused",
|
|
469
|
+
error: outcome.error
|
|
470
|
+
};
|
|
471
|
+
} catch (error) {
|
|
472
|
+
failed = {
|
|
473
|
+
kind: "threw",
|
|
474
|
+
error: errorText(error)
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
if (failed === null) {
|
|
478
|
+
this.clearPending(cap);
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
this.markPending(cap, op, failed);
|
|
482
|
+
return false;
|
|
483
|
+
}
|
|
484
|
+
clearPending(cap) {
|
|
485
|
+
if (!this.pending.has(cap)) return;
|
|
486
|
+
this.pending = new Map([...this.pending].filter(([c]) => c !== cap));
|
|
487
|
+
this.deps.logger.info("composed capability written again after a failed write", {
|
|
488
|
+
tags: { deviceId: this.deps.target.id },
|
|
489
|
+
meta: {
|
|
490
|
+
blockId: this.deps.blockId,
|
|
491
|
+
cap
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
markPending(cap, op, failed) {
|
|
496
|
+
const wasPending = this.pending.has(cap);
|
|
497
|
+
this.pending = new Map([...this.pending, [cap, failed]]);
|
|
498
|
+
if (wasPending) return;
|
|
499
|
+
const message = failed.kind === "refused" ? "composed capability refused a write — its whole slice is re-written on the next pass that evaluates it" : "composed capability write threw — its whole slice is re-written on the next pass that evaluates it";
|
|
500
|
+
this.deps.logger.warn(message, {
|
|
501
|
+
tags: { deviceId: this.deps.target.id },
|
|
502
|
+
meta: {
|
|
503
|
+
blockId: this.deps.blockId,
|
|
504
|
+
cap,
|
|
505
|
+
op,
|
|
506
|
+
error: failed.error
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
markWritten(cap, entries) {
|
|
511
|
+
const written = new Map(entries);
|
|
512
|
+
this.records = new Map([...this.records].map(([k, r]) => r.plan.cap === cap && written.has(r.plan.path) ? [k, {
|
|
513
|
+
...r,
|
|
514
|
+
written: written.get(r.plan.path)
|
|
515
|
+
}] : [k, r]));
|
|
516
|
+
}
|
|
517
|
+
updateAvailability() {
|
|
518
|
+
const blocking = [...this.records.values()].filter((r) => r.plan.unavailable === "offline" && r.outcome?.state === "unavailable").map((r) => fieldKey(r.plan));
|
|
519
|
+
const unseeded = [...new Set(this.deps.fields.map((f) => f.cap))].filter((c) => !this.seeded.has(c));
|
|
520
|
+
const writePending = [...this.pending.keys()];
|
|
521
|
+
const available = this.disabledReason === null && this.failure === null && blocking.length === 0 && unseeded.length === 0 && writePending.length === 0;
|
|
522
|
+
if (available === this.available) return;
|
|
523
|
+
this.deps.target.setAvailable(available);
|
|
524
|
+
this.available = available;
|
|
525
|
+
const extras = {
|
|
526
|
+
tags: { deviceId: this.deps.target.id },
|
|
527
|
+
meta: {
|
|
528
|
+
blockId: this.deps.blockId,
|
|
529
|
+
blocking,
|
|
530
|
+
unseeded,
|
|
531
|
+
writePending,
|
|
532
|
+
failure: this.failure,
|
|
533
|
+
disabled: this.disabledReason
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
if (available) this.deps.logger.info("composed device available", extras);
|
|
537
|
+
else this.deps.logger.warn("composed device unavailable — a field has no value, a write is pending, or the block failed", extras);
|
|
538
|
+
}
|
|
539
|
+
updateFailure() {
|
|
540
|
+
if (this.failure !== null) return;
|
|
541
|
+
const fatal = [...this.records.values()].find((r) => r.outcome?.state === "unavailable" && r.outcome.fatal);
|
|
542
|
+
if (fatal === void 0 || fatal.outcome === null || fatal.outcome.state !== "unavailable") return;
|
|
543
|
+
this.failure = `source missing — ${fatal.outcome.reason}`;
|
|
544
|
+
this.deps.logger.error("composition failed: a source is gone — only the operator ends this (repair the source, edit or delete the block)", {
|
|
545
|
+
tags: { deviceId: this.deps.target.id },
|
|
546
|
+
meta: {
|
|
547
|
+
blockId: this.deps.blockId,
|
|
548
|
+
field: fieldKey(fatal.plan),
|
|
549
|
+
reason: fatal.outcome.reason
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
/** `after`: arm only deadlines later than this instant; null arms the earliest stored one. */
|
|
554
|
+
armDeadline(after) {
|
|
555
|
+
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] : []);
|
|
556
|
+
if (deadlines.length === 0) {
|
|
557
|
+
this.deps.timer.disarm();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
this.deps.timer.arm(Math.min(...deadlines), () => this.onDeadline());
|
|
561
|
+
}
|
|
562
|
+
onDeadline() {
|
|
563
|
+
this.guarded("deadline", true, () => {
|
|
564
|
+
const now = this.deps.now();
|
|
565
|
+
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));
|
|
566
|
+
if (due.length > 0) this.pass(due, "deadline");
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
logTransition(record, outcome) {
|
|
570
|
+
const from = record.outcome?.state ?? "pending";
|
|
571
|
+
if (from === "pending" && outcome.state === "fresh") return;
|
|
572
|
+
const extras = {
|
|
573
|
+
tags: { deviceId: this.deps.target.id },
|
|
574
|
+
meta: {
|
|
575
|
+
blockId: this.deps.blockId,
|
|
576
|
+
cap: record.plan.cap,
|
|
577
|
+
field: record.plan.path,
|
|
578
|
+
from,
|
|
579
|
+
to: outcome.state,
|
|
580
|
+
reason: reasonOf(outcome)
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
const message = `composed field ${fieldKey(record.plan)}: ${from} → ${outcome.state}`;
|
|
584
|
+
if (outcome.state === "fresh") this.deps.logger.info(message, extras);
|
|
585
|
+
else this.deps.logger.warn(message, extras);
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
//#endregion
|
|
589
|
+
//#region src/builtins/composer/composer.ts
|
|
590
|
+
/**
|
|
591
|
+
* The composer: every declarative composition on this hub, in one runner
|
|
592
|
+
* (D659).
|
|
593
|
+
*
|
|
594
|
+
* `apply` is called by `core-blocks`' supervisor on its existing converge with
|
|
595
|
+
* the WHOLE set of composition blocks. It is idempotent: a block whose
|
|
596
|
+
* `updatedAt`, device and plan are unchanged keeps its runtime; the declared set
|
|
597
|
+
* is reconciled only when it changed. Every verdict names its reason.
|
|
598
|
+
*
|
|
599
|
+
* Nothing is declared or withdrawn until two things hold (D49): the
|
|
600
|
+
* `core_blocks` store has loaded (R2), and the composer has read the rows it
|
|
601
|
+
* already owns, so a restart is not the first miss of every block
|
|
602
|
+
* (`WithdrawalGate.seed`). A block whose row exists but whose composition no
|
|
603
|
+
* longer plans (a cap renamed by a release) keeps its device as an offline
|
|
604
|
+
* placeholder with no capabilities, and reports `failed` naming why.
|
|
605
|
+
*/
|
|
606
|
+
var INTEGRATION_WAIT = "waiting for the shared Blocks integration to reconcile";
|
|
607
|
+
var STORE_NOT_LOADED = "the core_blocks store has not loaded — nothing is declared or withdrawn until it does (D49)";
|
|
608
|
+
var ROWS_UNREAD = "the composer's own device rows could not be read — nothing is declared or withdrawn until they are (D49)";
|
|
609
|
+
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)";
|
|
610
|
+
/** The shape of a placeholder: no caps, so no plan to take a shape from. */
|
|
611
|
+
var PLACEHOLDER_SHAPE = "placeholder";
|
|
612
|
+
var Composer = class {
|
|
613
|
+
deps;
|
|
614
|
+
live = /* @__PURE__ */ new Map();
|
|
615
|
+
devices = /* @__PURE__ */ new Map();
|
|
616
|
+
/** StableIds whose row exists: the seeded persisted rows, then what every declare kept. */
|
|
617
|
+
rows = /* @__PURE__ */ new Set();
|
|
618
|
+
seeded = false;
|
|
619
|
+
/** The previous seed read answered `[]`: one more `[]` confirms it (D49). */
|
|
620
|
+
emptySeedSeen = false;
|
|
621
|
+
/** Consecutive applies refused because `core_blocks` had not loaded: warned once per run. */
|
|
622
|
+
storeWaitApplies = 0;
|
|
623
|
+
/** Consecutive applies refused because the Blocks integration had not reconciled: warned once per run. */
|
|
624
|
+
integrationWaitApplies = 0;
|
|
625
|
+
declaredFingerprint = null;
|
|
626
|
+
gate;
|
|
627
|
+
/** Applies never interleave: each one reads and replaces the whole live set. */
|
|
628
|
+
applyChain = Promise.resolve();
|
|
629
|
+
constructor(deps) {
|
|
630
|
+
this.deps = deps;
|
|
631
|
+
this.gate = new WithdrawalGate(deps.logger.child("withdrawal"), (stableId) => this.devices.get(stableId)?.id ?? null);
|
|
632
|
+
}
|
|
633
|
+
apply(input) {
|
|
634
|
+
const run = this.applyChain.then(() => this.applyNow(input), () => this.applyNow(input));
|
|
635
|
+
this.applyChain = run.then(() => void 0, () => void 0);
|
|
636
|
+
return run;
|
|
637
|
+
}
|
|
638
|
+
getStates(blockIds) {
|
|
639
|
+
return blockIds.flatMap((blockId) => {
|
|
640
|
+
const entry = this.live.get(blockId);
|
|
641
|
+
if (entry === void 0) return [];
|
|
642
|
+
const verdict = this.verdict(blockId);
|
|
643
|
+
return [{
|
|
644
|
+
blockId,
|
|
645
|
+
deviceId: entry.device?.id ?? null,
|
|
646
|
+
status: verdict.status,
|
|
647
|
+
...verdict.reason === void 0 ? {} : { reason: verdict.reason },
|
|
648
|
+
fields: entry.runtime?.state().fields.map((f) => ({ ...f })) ?? []
|
|
649
|
+
}];
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
/** Runs on the tracker's one chain (R3): resolves once the change reached every runtime. */
|
|
653
|
+
async onStateChanged(data) {
|
|
654
|
+
this.dispatch(await this.deps.tracker.onStateChanged(data.deviceId, data.capName, data.slice));
|
|
655
|
+
}
|
|
656
|
+
async onDeviceUnregistered(deviceId) {
|
|
657
|
+
this.dispatch(await this.deps.tracker.onDeviceUnregistered(deviceId));
|
|
658
|
+
}
|
|
659
|
+
async onBindingsChanged(data) {
|
|
660
|
+
this.dispatch(await this.deps.tracker.onBindingsChanged(data.deviceId, data.capName, data.reason));
|
|
661
|
+
}
|
|
662
|
+
shutdown() {
|
|
663
|
+
for (const entry of this.live.values()) entry.runtime?.stop();
|
|
664
|
+
}
|
|
665
|
+
async applyNow(input) {
|
|
666
|
+
const ids = [...input.blocks.map((b) => b.id), ...input.unreadableBlocks.map((u) => u.id)];
|
|
667
|
+
if (!input.storeLoaded) {
|
|
668
|
+
this.storeWaitApplies += 1;
|
|
669
|
+
if (this.storeWaitApplies === 1) this.deps.logger.warn("composer: the block store has not loaded — no device is declared, withdrawn or renamed", { meta: {
|
|
670
|
+
blocks: input.blocks.length,
|
|
671
|
+
live: this.live.size
|
|
672
|
+
} });
|
|
673
|
+
return this.waiting(ids, STORE_NOT_LOADED);
|
|
674
|
+
}
|
|
675
|
+
if (this.storeWaitApplies > 0) {
|
|
676
|
+
this.deps.logger.info("composer: the block store has loaded — applying again", { meta: { appliesRefused: this.storeWaitApplies } });
|
|
677
|
+
this.storeWaitApplies = 0;
|
|
678
|
+
}
|
|
679
|
+
if (input.integrationId === null) {
|
|
680
|
+
this.integrationWaitApplies += 1;
|
|
681
|
+
if (this.integrationWaitApplies === 1) this.deps.logger.warn("composer: the shared Blocks integration has not reconciled — no composed device can be declared", { meta: { blocks: input.blocks.length } });
|
|
682
|
+
return this.waiting(ids, INTEGRATION_WAIT);
|
|
683
|
+
}
|
|
684
|
+
if (this.integrationWaitApplies > 0) {
|
|
685
|
+
this.deps.logger.info("composer: the shared Blocks integration has reconciled — applying again", { meta: { appliesRefused: this.integrationWaitApplies } });
|
|
686
|
+
this.integrationWaitApplies = 0;
|
|
687
|
+
}
|
|
688
|
+
const unseeded = await this.ensureSeeded();
|
|
689
|
+
if (unseeded !== null) return this.waiting(ids, unseeded);
|
|
690
|
+
const planned = [...input.blocks.map((block) => this.plan(block)), ...input.unreadableBlocks.map((block) => this.planUnreadable(block))];
|
|
691
|
+
this.logPlanTransitions(planned);
|
|
692
|
+
const declarations = this.gate.filter(planned.flatMap((p) => p.declaration === null ? [] : [p.declaration]));
|
|
693
|
+
const rebuildErrors = await this.rebuildShapes(planned);
|
|
694
|
+
await this.reconcileDevices(input.integrationId, declarations, planned);
|
|
695
|
+
const rebuilt = await this.rebuildRuntimes(planned, declarations, new Set(input.restartBlockIds), rebuildErrors);
|
|
696
|
+
await this.syncSources(rebuilt);
|
|
697
|
+
return {
|
|
698
|
+
blocks: planned.map((p) => this.verdict(p.block.id)),
|
|
699
|
+
acted: true
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
waiting(blockIds, reason) {
|
|
703
|
+
return {
|
|
704
|
+
blocks: blockIds.map((blockId) => ({
|
|
705
|
+
blockId,
|
|
706
|
+
status: "starting",
|
|
707
|
+
reason
|
|
708
|
+
})),
|
|
709
|
+
acted: false
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Seed the withdrawal gate with the rows that exist, ONCE, before its first
|
|
714
|
+
* `filter`; answers null once seeded, else why this apply must wait.
|
|
715
|
+
*
|
|
716
|
+
* An EMPTY answer is the destroying direction: a cold registry answers `[]`
|
|
717
|
+
* too, and `DeclaredDevices.reconcile` sweeps from its OWN later read — so a
|
|
718
|
+
* cold seed followed by a warm sweep would delete the row of a block that no
|
|
719
|
+
* longer plans, with no placeholder to hold it. `[]` is therefore trusted
|
|
720
|
+
* only when the next apply's read agrees (D49); that apply comes from the
|
|
721
|
+
* supervisor's existing converge, never a timer here (D3). A failed read
|
|
722
|
+
* changes nothing and breaks the agreement.
|
|
723
|
+
*/
|
|
724
|
+
async ensureSeeded() {
|
|
725
|
+
if (this.seeded) return null;
|
|
726
|
+
let rows;
|
|
727
|
+
try {
|
|
728
|
+
rows = await this.deps.readPersisted();
|
|
729
|
+
} catch (err) {
|
|
730
|
+
this.emptySeedSeen = false;
|
|
731
|
+
this.deps.logger.warn("composer: reading its own device rows failed — nothing is declared or withdrawn; retried on the next apply", { meta: { error: errMsg(err) } });
|
|
732
|
+
return ROWS_UNREAD;
|
|
733
|
+
}
|
|
734
|
+
if (rows.length === 0 && !this.emptySeedSeen) {
|
|
735
|
+
this.emptySeedSeen = true;
|
|
736
|
+
this.deps.logger.warn("composer: its own device rows read empty — waiting for the next apply to confirm before declaring or withdrawing anything");
|
|
737
|
+
return ROWS_EMPTY_UNCONFIRMED;
|
|
738
|
+
}
|
|
739
|
+
this.gate.seed(rows);
|
|
740
|
+
this.rows = new Set(rows.map((r) => r.stableId));
|
|
741
|
+
this.seeded = true;
|
|
742
|
+
this.deps.logger.info("composer: seeded the withdrawal gate from the persisted rows", { meta: {
|
|
743
|
+
rows: rows.length,
|
|
744
|
+
confirmedEmpty: rows.length === 0
|
|
745
|
+
} });
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* A row that exists but does not parse is held exactly like a composition
|
|
750
|
+
* that stopped planning: its device (if it has a row) stays as an offline
|
|
751
|
+
* placeholder, and the block fails naming the parse error. Absent, it would
|
|
752
|
+
* be withdrawn on the second apply that omitted it (D49).
|
|
753
|
+
*/
|
|
754
|
+
planUnreadable(block) {
|
|
755
|
+
const stableId = composedDeviceStableId(block.id);
|
|
756
|
+
const hasRow = this.rows.has(stableId);
|
|
757
|
+
return {
|
|
758
|
+
block: {
|
|
759
|
+
id: block.id,
|
|
760
|
+
name: block.name,
|
|
761
|
+
enabled: false,
|
|
762
|
+
updatedAt: 0
|
|
763
|
+
},
|
|
764
|
+
fields: [],
|
|
765
|
+
problems: [`the stored block does not parse — ${block.error}`],
|
|
766
|
+
declaration: hasRow ? placeholderDeclaration(stableId) : null,
|
|
767
|
+
shape: hasRow ? PLACEHOLDER_SHAPE : null,
|
|
768
|
+
spec: null
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
plan(block) {
|
|
772
|
+
const plan = planComposition(block.composition, this.deps.lookupCap);
|
|
773
|
+
const target = block.composition.target;
|
|
774
|
+
if (plan.problems.length > 0 || target.kind !== "new") {
|
|
775
|
+
const problems = plan.problems.length > 0 ? plan.problems.map((p) => `${p.path}: ${p.message}`) : [`target: a \`${target.kind}\` target is not composable in this release`];
|
|
776
|
+
const stableId = composedDeviceStableId(block.id);
|
|
777
|
+
const hasRow = this.rows.has(stableId);
|
|
778
|
+
return {
|
|
779
|
+
block,
|
|
780
|
+
fields: [],
|
|
781
|
+
problems,
|
|
782
|
+
declaration: hasRow ? placeholderDeclaration(stableId) : null,
|
|
783
|
+
shape: hasRow ? PLACEHOLDER_SHAPE : null,
|
|
784
|
+
spec: null
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
const caps = block.composition.features.flatMap((f) => {
|
|
788
|
+
const def = this.deps.lookupCap(f.cap);
|
|
789
|
+
return def === null ? [] : [def];
|
|
790
|
+
});
|
|
791
|
+
const declarationInput = {
|
|
792
|
+
blockId: block.id,
|
|
793
|
+
name: block.name,
|
|
794
|
+
target,
|
|
795
|
+
caps
|
|
796
|
+
};
|
|
797
|
+
return {
|
|
798
|
+
block,
|
|
799
|
+
fields: plan.fields,
|
|
800
|
+
problems: [],
|
|
801
|
+
declaration: declarationFor(declarationInput),
|
|
802
|
+
shape: shapeOf(declarationInput),
|
|
803
|
+
spec: {
|
|
804
|
+
type: target.type,
|
|
805
|
+
...target.role === void 0 ? {} : { role: target.role }
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
/** One line when a block starts or stops planning — never one per apply. */
|
|
810
|
+
logPlanTransitions(planned) {
|
|
811
|
+
for (const p of planned) {
|
|
812
|
+
const before = this.live.get(p.block.id)?.planned.problems.join("; ") ?? "";
|
|
813
|
+
const now = p.problems.join("; ");
|
|
814
|
+
if (before === now) continue;
|
|
815
|
+
const extras = {
|
|
816
|
+
tags: this.tagsOf(p.block.id),
|
|
817
|
+
meta: {
|
|
818
|
+
blockId: p.block.id,
|
|
819
|
+
problems: p.problems,
|
|
820
|
+
placeholder: p.declaration !== null
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
if (now === "") this.deps.logger.info("composition plans again", extras);
|
|
824
|
+
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);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* A changed type/role/cap set (or a switch to or from a placeholder) needs a
|
|
829
|
+
* NEW device object: decommission, and let the reconcile adopt it with the
|
|
830
|
+
* new class. A failure keeps the old device and is retried next apply.
|
|
831
|
+
*/
|
|
832
|
+
async rebuildShapes(planned) {
|
|
833
|
+
const errors = /* @__PURE__ */ new Map();
|
|
834
|
+
for (const p of planned) {
|
|
835
|
+
const previous = this.live.get(p.block.id);
|
|
836
|
+
const device = previous?.device ?? null;
|
|
837
|
+
if (previous === void 0 || device === null || p.shape === null) continue;
|
|
838
|
+
if (previous.planned.shape === p.shape) continue;
|
|
839
|
+
try {
|
|
840
|
+
this.deps.logger.info("composition shape changed — rebuilding its device", {
|
|
841
|
+
tags: { deviceId: device.id },
|
|
842
|
+
meta: {
|
|
843
|
+
blockId: p.block.id,
|
|
844
|
+
from: previous.planned.shape,
|
|
845
|
+
to: p.shape
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
if (p.spec !== null) await this.deps.devices.applyDerivedSpec(device.id, p.spec);
|
|
849
|
+
await this.deps.devices.decommission(device.id);
|
|
850
|
+
const stableId = composedDeviceStableId(p.block.id);
|
|
851
|
+
this.devices = new Map([...this.devices].filter(([s]) => s !== stableId));
|
|
852
|
+
this.declaredFingerprint = null;
|
|
853
|
+
} catch (err) {
|
|
854
|
+
const error = errMsg(err);
|
|
855
|
+
errors.set(p.block.id, error);
|
|
856
|
+
this.deps.logger.warn("rebuilding a composed device failed — the old one is kept", {
|
|
857
|
+
tags: { deviceId: device.id },
|
|
858
|
+
meta: {
|
|
859
|
+
blockId: p.block.id,
|
|
860
|
+
error
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return errors;
|
|
866
|
+
}
|
|
867
|
+
async reconcileDevices(integrationId, declarations, planned) {
|
|
868
|
+
const shapes = new Map(planned.flatMap((p) => p.declaration === null ? [] : [[p.declaration.stableId, p.shape]]));
|
|
869
|
+
const fingerprint = JSON.stringify(declarations.map((d) => [d.stableId, shapes.get(d.stableId) ?? null]).toSorted());
|
|
870
|
+
const allLive = declarations.every((d) => this.devices.has(d.stableId));
|
|
871
|
+
if (fingerprint === this.declaredFingerprint && allLive) return;
|
|
872
|
+
try {
|
|
873
|
+
const result = await this.deps.declare(integrationId, declarations);
|
|
874
|
+
this.devices = result.devices;
|
|
875
|
+
this.rows = new Set(declarations.map((d) => d.stableId).filter((s) => this.rows.has(s) || result.devices.has(s)));
|
|
876
|
+
this.declaredFingerprint = fingerprint;
|
|
877
|
+
} catch (err) {
|
|
878
|
+
this.deps.logger.warn("composer: the device declaration failed — keeping the devices already live", { meta: {
|
|
879
|
+
error: errMsg(err),
|
|
880
|
+
declarations: declarations.length
|
|
881
|
+
} });
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
async rebuildRuntimes(planned, declarations, restart, rebuildErrors) {
|
|
885
|
+
const reset = /* @__PURE__ */ new Set();
|
|
886
|
+
const fresh = /* @__PURE__ */ new Set();
|
|
887
|
+
const next = /* @__PURE__ */ new Map();
|
|
888
|
+
for (const p of planned) {
|
|
889
|
+
const previous = this.live.get(p.block.id);
|
|
890
|
+
const rebuildError = rebuildErrors.get(p.block.id) ?? null;
|
|
891
|
+
if (previous !== void 0 && rebuildError !== null) {
|
|
892
|
+
next.set(p.block.id, {
|
|
893
|
+
...previous,
|
|
894
|
+
rebuildError
|
|
895
|
+
});
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
const device = this.devices.get(composedDeviceStableId(p.block.id)) ?? null;
|
|
899
|
+
const appliedName = device === null ? null : p.block.name === null ? previous?.appliedName ?? device.name : await this.applyName(device, p.block.name, previous?.appliedName ?? null);
|
|
900
|
+
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)) {
|
|
901
|
+
next.set(p.block.id, {
|
|
902
|
+
planned: p,
|
|
903
|
+
device,
|
|
904
|
+
runtime: previous.runtime,
|
|
905
|
+
appliedName,
|
|
906
|
+
rebuildError: null
|
|
907
|
+
});
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
previous?.runtime?.stop();
|
|
911
|
+
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(compositionSourceKey(r.source));
|
|
912
|
+
const runtime = device === null || p.fields.length === 0 ? null : this.newRuntime(p, device);
|
|
913
|
+
if (runtime !== null) fresh.add(runtime);
|
|
914
|
+
next.set(p.block.id, {
|
|
915
|
+
planned: p,
|
|
916
|
+
device,
|
|
917
|
+
runtime,
|
|
918
|
+
appliedName,
|
|
919
|
+
rebuildError: null
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
const held = new Set(declarations.map((d) => d.stableId));
|
|
923
|
+
for (const [blockId, entry] of this.live) {
|
|
924
|
+
if (next.has(blockId)) continue;
|
|
925
|
+
if (held.has(composedDeviceStableId(blockId))) next.set(blockId, entry);
|
|
926
|
+
else entry.runtime?.stop();
|
|
927
|
+
}
|
|
928
|
+
this.live = next;
|
|
929
|
+
return {
|
|
930
|
+
reset,
|
|
931
|
+
fresh
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
newRuntime(p, device) {
|
|
935
|
+
return new CompositionRuntime({
|
|
936
|
+
blockId: p.block.id,
|
|
937
|
+
fields: p.fields,
|
|
938
|
+
target: device,
|
|
939
|
+
read: (read) => this.deps.tracker.read(read),
|
|
940
|
+
now: this.deps.now,
|
|
941
|
+
timer: this.deps.newTimer(),
|
|
942
|
+
logger: this.deps.logger.child("runtime")
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
async applyName(device, name, applied) {
|
|
946
|
+
if ((applied ?? device.name) === name) return name;
|
|
947
|
+
try {
|
|
948
|
+
await this.deps.devices.setName(device.id, name);
|
|
949
|
+
this.deps.logger.info("composed device renamed after its block", {
|
|
950
|
+
tags: { deviceId: device.id },
|
|
951
|
+
meta: { name }
|
|
952
|
+
});
|
|
953
|
+
return name;
|
|
954
|
+
} catch (err) {
|
|
955
|
+
this.deps.logger.warn("composed device rename failed — retried on the next apply", {
|
|
956
|
+
tags: { deviceId: device.id },
|
|
957
|
+
meta: {
|
|
958
|
+
name,
|
|
959
|
+
error: errMsg(err)
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
return applied ?? device.name;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
async syncSources(rebuilt) {
|
|
966
|
+
const requests = [...this.live.values()].filter((e) => e.runtime !== null && e.planned.block.enabled).flatMap((e) => e.planned.fields.flatMap((f) => f.reads.map((r) => ({
|
|
967
|
+
ref: r.source,
|
|
968
|
+
caps: [r.cap]
|
|
969
|
+
}))));
|
|
970
|
+
const changed = await this.syncTracker(requests, rebuilt.reset);
|
|
971
|
+
for (const entry of this.live.values()) {
|
|
972
|
+
const runtime = entry.runtime;
|
|
973
|
+
if (runtime === null) continue;
|
|
974
|
+
if (!entry.planned.block.enabled) runtime.disable("the block is disabled");
|
|
975
|
+
else if (rebuilt.fresh.has(runtime)) runtime.start();
|
|
976
|
+
else runtime.onSourcesChanged(changed);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
/** A failed sync leaves the tracker as it was: the runtimes read what it holds. */
|
|
980
|
+
async syncTracker(requests, reset) {
|
|
981
|
+
try {
|
|
982
|
+
return await this.deps.tracker.sync(requests, reset);
|
|
983
|
+
} catch (err) {
|
|
984
|
+
this.deps.logger.warn("composer: syncing the tracked sources failed — retried on the next apply", { meta: {
|
|
985
|
+
error: errMsg(err),
|
|
986
|
+
sources: requests.length
|
|
987
|
+
} });
|
|
988
|
+
return /* @__PURE__ */ new Set();
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
dispatch(changed) {
|
|
992
|
+
if (changed.size === 0) return;
|
|
993
|
+
for (const entry of this.live.values()) if (entry.planned.block.enabled) entry.runtime?.onSourcesChanged(changed);
|
|
994
|
+
}
|
|
995
|
+
tagsOf(blockId) {
|
|
996
|
+
const deviceId = this.devices.get(composedDeviceStableId(blockId))?.id ?? null;
|
|
997
|
+
return deviceId === null ? {} : { deviceId };
|
|
998
|
+
}
|
|
999
|
+
verdict(blockId) {
|
|
1000
|
+
const entry = this.live.get(blockId);
|
|
1001
|
+
if (entry === void 0) return {
|
|
1002
|
+
blockId,
|
|
1003
|
+
status: "starting",
|
|
1004
|
+
reason: "not applied yet"
|
|
1005
|
+
};
|
|
1006
|
+
if (entry.rebuildError !== null) return {
|
|
1007
|
+
blockId,
|
|
1008
|
+
status: "failed",
|
|
1009
|
+
reason: `rebuilding the composed device for its new shape failed: ${entry.rebuildError}`
|
|
1010
|
+
};
|
|
1011
|
+
if (entry.planned.problems.length > 0) {
|
|
1012
|
+
const kept = entry.planned.declaration === null ? "" : " — its device is kept offline with no capabilities";
|
|
1013
|
+
return {
|
|
1014
|
+
blockId,
|
|
1015
|
+
status: "failed",
|
|
1016
|
+
reason: `the composition does not validate: ${entry.planned.problems.join("; ")}${kept}`
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
if (!entry.planned.block.enabled) return {
|
|
1020
|
+
blockId,
|
|
1021
|
+
status: "stopped"
|
|
1022
|
+
};
|
|
1023
|
+
if (entry.device === null) return {
|
|
1024
|
+
blockId,
|
|
1025
|
+
status: "starting",
|
|
1026
|
+
reason: "the composed device has not been constructed yet"
|
|
1027
|
+
};
|
|
1028
|
+
const failure = entry.runtime?.state().failure ?? null;
|
|
1029
|
+
if (failure !== null) return {
|
|
1030
|
+
blockId,
|
|
1031
|
+
status: "failed",
|
|
1032
|
+
reason: failure
|
|
1033
|
+
};
|
|
1034
|
+
return {
|
|
1035
|
+
blockId,
|
|
1036
|
+
status: "running"
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
//#endregion
|
|
1041
|
+
//#region src/builtins/composer/deadline-timer.ts
|
|
1042
|
+
/** Node's largest `setTimeout` delay; anything longer is clamped to 1 ms (`TimeoutOverflowWarning`). */
|
|
1043
|
+
var MAX_TIMER_DELAY_MS = 2147483647;
|
|
1044
|
+
/** One pending wake-up per composition, at its earliest expression deadline. Never a poll. */
|
|
1045
|
+
var NodeDeadlineTimer = class {
|
|
1046
|
+
now;
|
|
1047
|
+
handle = null;
|
|
1048
|
+
constructor(now) {
|
|
1049
|
+
this.now = now;
|
|
1050
|
+
}
|
|
1051
|
+
arm(at, fire) {
|
|
1052
|
+
this.disarm();
|
|
1053
|
+
this.schedule(at, fire);
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* A deadline further away than the largest delay is reached in legs: the
|
|
1057
|
+
* clamped wake-up is NOT the deadline, so it re-arms the remainder instead of
|
|
1058
|
+
* firing — firing early would re-arm the same deadline and, unclamped, Node
|
|
1059
|
+
* turns it into a 1 ms spin (D3).
|
|
1060
|
+
*/
|
|
1061
|
+
schedule(at, fire) {
|
|
1062
|
+
const remaining = Math.max(0, at - this.now());
|
|
1063
|
+
const clamped = remaining > MAX_TIMER_DELAY_MS;
|
|
1064
|
+
this.handle = setTimeout(() => {
|
|
1065
|
+
this.handle = null;
|
|
1066
|
+
if (clamped) this.schedule(at, fire);
|
|
1067
|
+
else fire();
|
|
1068
|
+
}, clamped ? MAX_TIMER_DELAY_MS : remaining);
|
|
1069
|
+
this.handle.unref?.();
|
|
1070
|
+
}
|
|
1071
|
+
disarm() {
|
|
1072
|
+
if (this.handle === null) return;
|
|
1073
|
+
clearTimeout(this.handle);
|
|
1074
|
+
this.handle = null;
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
//#endregion
|
|
1078
|
+
//#region src/builtins/composer/source-tracker.ts
|
|
1079
|
+
/**
|
|
1080
|
+
* What a composition's sources currently say, per source device and per cap
|
|
1081
|
+
* (D659).
|
|
1082
|
+
*
|
|
1083
|
+
* Fed by the events sources ALREADY emit (`device.state-changed`,
|
|
1084
|
+
* `device.unregistered`, `device.bindings-changed`). It never polls and arms no
|
|
1085
|
+
* timer. It reads the hub only to HYDRATE a source it has not resolved yet (on
|
|
1086
|
+
* `sync`, driven by the supervisor's existing converge), to CONFIRM a
|
|
1087
|
+
* destructive verdict, and to RECONCILE when the source device (re)registers.
|
|
1088
|
+
* A source once resolved is not re-read by `sync`: its values arrive by
|
|
1089
|
+
* `device.state-changed`. Events are telemetry and may be dropped (D8), so the
|
|
1090
|
+
* reconcile point is the one the source itself raises: a
|
|
1091
|
+
* `device.bindings-changed` for a cap it serves (`native-registered` on a
|
|
1092
|
+
* provider restart, a wrapper toggle) re-reads that cap — and `device-status`
|
|
1093
|
+
* re-reads `online` — and a source found under a NEW id after an unregister is
|
|
1094
|
+
* re-read at once. No timer, no poll (R7c).
|
|
1095
|
+
*
|
|
1096
|
+
* - fresh: the last value, from a device that is online;
|
|
1097
|
+
* - stale: the last value, while the device is offline or a re-read
|
|
1098
|
+
* flickered. Nothing is destroyed (D49);
|
|
1099
|
+
* - unavailable: no value — never a number that looks plausible (D393). A cap
|
|
1100
|
+
* the provider unregistered is unavailable, NOT stale (spec §2), and so is a
|
|
1101
|
+
* cap absent from the bindings of a device whose ROW exists: after a hub
|
|
1102
|
+
* restart or a provider respawn the bindings are empty until the provider
|
|
1103
|
+
* re-registers, and two back-to-back binding reads are one correlated read.
|
|
1104
|
+
* `fatal` only when the source DEVICE is GONE and two index reads agreed.
|
|
1105
|
+
* Fatal is sticky: only an operator reset ends it (no auto-repair, 2026-08-08).
|
|
1106
|
+
*
|
|
1107
|
+
* A re-read that answers "no report" never replaces a value already held: the
|
|
1108
|
+
* value is kept, marked stale with the reason (D49 — one empty read is not
|
|
1109
|
+
* proof). Only `native-unregistered` takes a value away (R7b).
|
|
1110
|
+
*
|
|
1111
|
+
* Every entry point runs on ONE promise chain (R3): `sync` builds its next map
|
|
1112
|
+
* across awaits, and an event that landed mid-way used to be overwritten when
|
|
1113
|
+
* that map was assigned. `read` is synchronous, from the last settled map.
|
|
1114
|
+
*
|
|
1115
|
+
* D224 (R7d): what is held here is the INPUT of a field the composition owns.
|
|
1116
|
+
* It is served to nobody else — no cap method, no getter for another addon
|
|
1117
|
+
* reads this map; the composed device's slice is the only output. A consumer
|
|
1118
|
+
* that wants a source's value asks the addon that owns that source.
|
|
1119
|
+
*/
|
|
1120
|
+
var DEVICE_STATUS_CAP = "device-status";
|
|
1121
|
+
function valueAtPath(slice, path) {
|
|
1122
|
+
return path.split(".").reduce((node, segment) => isRecord(node) ? node[segment] : void 0, slice);
|
|
1123
|
+
}
|
|
1124
|
+
function onlineOf(slice) {
|
|
1125
|
+
const online = slice["online"];
|
|
1126
|
+
return typeof online === "boolean" ? online : null;
|
|
1127
|
+
}
|
|
1128
|
+
function findRow(read, stableId) {
|
|
1129
|
+
if (read.kind === "unreadable") return {
|
|
1130
|
+
kind: "unknown",
|
|
1131
|
+
error: read.error
|
|
1132
|
+
};
|
|
1133
|
+
const row = read.rows.find((r) => r.stableId === stableId);
|
|
1134
|
+
return row === void 0 ? { kind: "absent" } : {
|
|
1135
|
+
kind: "found",
|
|
1136
|
+
deviceId: row.id
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
var SourceTracker = class {
|
|
1140
|
+
ports;
|
|
1141
|
+
sources = /* @__PURE__ */ new Map();
|
|
1142
|
+
/** The one chain every entry point joins (R3); the pattern is `BlockSupervisor.inFlight`. */
|
|
1143
|
+
chain = Promise.resolve();
|
|
1144
|
+
constructor(ports) {
|
|
1145
|
+
this.ports = ports;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Track exactly `requests`. Hydrate what is new or unresolved; keep what is
|
|
1149
|
+
* known. `reset` names source keys an OPERATOR action re-opens (a block edit
|
|
1150
|
+
* or restart): only those lose a sticky `missing`.
|
|
1151
|
+
*/
|
|
1152
|
+
sync(requests, reset) {
|
|
1153
|
+
return this.serialized(() => this.syncNow(requests, reset));
|
|
1154
|
+
}
|
|
1155
|
+
onStateChanged(deviceId, capName, slice) {
|
|
1156
|
+
return this.serialized(async () => this.applyStateChanged(deviceId, capName, slice));
|
|
1157
|
+
}
|
|
1158
|
+
onDeviceUnregistered(deviceId) {
|
|
1159
|
+
return this.serialized(() => this.confirmUnregistered(deviceId));
|
|
1160
|
+
}
|
|
1161
|
+
onBindingsChanged(deviceId, capName, reason) {
|
|
1162
|
+
return this.serialized(() => this.applyBindingsChanged(deviceId, capName, reason));
|
|
1163
|
+
}
|
|
1164
|
+
/** Run `work` after everything already queued, whether that settled or failed; never let one failure stall the chain. */
|
|
1165
|
+
serialized(work) {
|
|
1166
|
+
const run = this.chain.then(work, work);
|
|
1167
|
+
this.chain = run.then(() => void 0, () => void 0);
|
|
1168
|
+
return run;
|
|
1169
|
+
}
|
|
1170
|
+
async syncNow(requests, reset) {
|
|
1171
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1172
|
+
for (const request of requests) {
|
|
1173
|
+
const k = compositionSourceKey(request.ref);
|
|
1174
|
+
const prior = merged.get(k);
|
|
1175
|
+
merged.set(k, {
|
|
1176
|
+
ref: request.ref,
|
|
1177
|
+
caps: [...new Set([...prior?.caps ?? [], ...request.caps])]
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
const lists = /* @__PURE__ */ new Map();
|
|
1181
|
+
const next = /* @__PURE__ */ new Map();
|
|
1182
|
+
const changed = /* @__PURE__ */ new Set();
|
|
1183
|
+
for (const [k, request] of merged) {
|
|
1184
|
+
const before = reset.has(k) ? void 0 : this.sources.get(k);
|
|
1185
|
+
const after = await this.hydrate(request, before, lists);
|
|
1186
|
+
next.set(k, after);
|
|
1187
|
+
for (const cap of request.caps) if (before?.caps.get(cap) !== after.caps.get(cap) || before?.missingReason !== after.missingReason) changed.add(compositionSliceKey(request.ref, cap));
|
|
1188
|
+
}
|
|
1189
|
+
this.sources = next;
|
|
1190
|
+
return changed;
|
|
1191
|
+
}
|
|
1192
|
+
read(read) {
|
|
1193
|
+
const label = `${compositionSourceKey(read.source)} ${read.cap}.${read.fieldPath}`;
|
|
1194
|
+
const tracked = this.sources.get(compositionSourceKey(read.source));
|
|
1195
|
+
if (tracked === void 0) return {
|
|
1196
|
+
state: "unavailable",
|
|
1197
|
+
reason: `${label}: not tracked yet`,
|
|
1198
|
+
fatal: false
|
|
1199
|
+
};
|
|
1200
|
+
if (tracked.missingReason !== null) return {
|
|
1201
|
+
state: "unavailable",
|
|
1202
|
+
reason: tracked.missingReason,
|
|
1203
|
+
fatal: true
|
|
1204
|
+
};
|
|
1205
|
+
const cap = tracked.caps.get(read.cap);
|
|
1206
|
+
if (cap === void 0) return {
|
|
1207
|
+
state: "unavailable",
|
|
1208
|
+
reason: `${label}: not tracked yet`,
|
|
1209
|
+
fatal: false
|
|
1210
|
+
};
|
|
1211
|
+
if (cap.kind === "none" || cap.kind === "unbound") return {
|
|
1212
|
+
state: "unavailable",
|
|
1213
|
+
reason: `${label}: ${cap.reason}`,
|
|
1214
|
+
fatal: false
|
|
1215
|
+
};
|
|
1216
|
+
const value = toExpressionValue(valueAtPath(cap.slice, read.fieldPath));
|
|
1217
|
+
if (value === void 0) return {
|
|
1218
|
+
state: "unavailable",
|
|
1219
|
+
reason: `${label}: the field is absent or not a scalar`,
|
|
1220
|
+
fatal: false
|
|
1221
|
+
};
|
|
1222
|
+
if (cap.staleReason !== null) return {
|
|
1223
|
+
state: "stale",
|
|
1224
|
+
value,
|
|
1225
|
+
reason: cap.staleReason
|
|
1226
|
+
};
|
|
1227
|
+
if (tracked.online === false) return {
|
|
1228
|
+
state: "stale",
|
|
1229
|
+
value,
|
|
1230
|
+
reason: "the source device is offline"
|
|
1231
|
+
};
|
|
1232
|
+
return {
|
|
1233
|
+
state: "fresh",
|
|
1234
|
+
value
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
applyStateChanged(deviceId, capName, slice) {
|
|
1238
|
+
const changed = /* @__PURE__ */ new Set();
|
|
1239
|
+
const next = new Map(this.sources);
|
|
1240
|
+
for (const [k, source] of this.sources) {
|
|
1241
|
+
if (source.deviceId !== deviceId || source.missingReason !== null) continue;
|
|
1242
|
+
let updated = source;
|
|
1243
|
+
if (capName === DEVICE_STATUS_CAP) {
|
|
1244
|
+
const online = onlineOf(slice);
|
|
1245
|
+
if (online !== source.online) {
|
|
1246
|
+
updated = {
|
|
1247
|
+
...updated,
|
|
1248
|
+
online
|
|
1249
|
+
};
|
|
1250
|
+
for (const cap of source.caps.keys()) changed.add(compositionSliceKey(source.ref, cap));
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
const current = source.caps.get(capName);
|
|
1254
|
+
if (current !== void 0) {
|
|
1255
|
+
if (current.kind === "unbound") this.logHealed(deviceId, capName, "device.state-changed");
|
|
1256
|
+
updated = {
|
|
1257
|
+
...updated,
|
|
1258
|
+
caps: new Map([...updated.caps, [capName, {
|
|
1259
|
+
kind: "slice",
|
|
1260
|
+
slice,
|
|
1261
|
+
staleReason: null
|
|
1262
|
+
}]])
|
|
1263
|
+
};
|
|
1264
|
+
changed.add(compositionSliceKey(source.ref, capName));
|
|
1265
|
+
}
|
|
1266
|
+
if (updated !== source) next.set(k, updated);
|
|
1267
|
+
}
|
|
1268
|
+
this.sources = next;
|
|
1269
|
+
return changed;
|
|
1270
|
+
}
|
|
1271
|
+
async confirmUnregistered(deviceId) {
|
|
1272
|
+
const changed = /* @__PURE__ */ new Set();
|
|
1273
|
+
for (const [k, source] of this.sources) {
|
|
1274
|
+
if (source.deviceId !== deviceId || source.missingReason !== null) continue;
|
|
1275
|
+
const resolution = await this.resolveConfirmed(source.ref, void 0);
|
|
1276
|
+
if (resolution.kind === "unknown") {
|
|
1277
|
+
this.ports.logger.warn("composition source: could not confirm a removal — nothing changes", {
|
|
1278
|
+
tags: { deviceId },
|
|
1279
|
+
meta: {
|
|
1280
|
+
source: k,
|
|
1281
|
+
error: resolution.error
|
|
1282
|
+
}
|
|
1283
|
+
});
|
|
1284
|
+
continue;
|
|
1285
|
+
}
|
|
1286
|
+
if (resolution.kind === "found" && resolution.deviceId === deviceId) continue;
|
|
1287
|
+
const next = resolution.kind === "missing" ? {
|
|
1288
|
+
...source,
|
|
1289
|
+
deviceId: null,
|
|
1290
|
+
missingReason: `source \`${k}\` (device ${deviceId}) was removed — two reads agreed`
|
|
1291
|
+
} : await this.rereadUnderNewId(source, resolution.deviceId);
|
|
1292
|
+
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", {
|
|
1293
|
+
tags: { deviceId },
|
|
1294
|
+
meta: {
|
|
1295
|
+
source: k,
|
|
1296
|
+
...resolution.kind === "found" ? { newDeviceId: resolution.deviceId } : {}
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1299
|
+
this.sources = new Map([...this.sources, [k, next]]);
|
|
1300
|
+
for (const cap of source.caps.keys()) changed.add(compositionSliceKey(source.ref, cap));
|
|
1301
|
+
}
|
|
1302
|
+
return changed;
|
|
1303
|
+
}
|
|
1304
|
+
async applyBindingsChanged(deviceId, capName, reason) {
|
|
1305
|
+
const changed = /* @__PURE__ */ new Set();
|
|
1306
|
+
for (const [k, source] of this.sources) {
|
|
1307
|
+
if (source.deviceId !== deviceId || source.missingReason !== null) continue;
|
|
1308
|
+
const current = source.caps.get(capName);
|
|
1309
|
+
let updated = source;
|
|
1310
|
+
if (reason === "native-unregistered") {
|
|
1311
|
+
if (current?.kind === "slice") {
|
|
1312
|
+
const reading = {
|
|
1313
|
+
kind: "none",
|
|
1314
|
+
reason: `the provider unregistered \`${capName}\` — no value until it registers again`
|
|
1315
|
+
};
|
|
1316
|
+
updated = {
|
|
1317
|
+
...updated,
|
|
1318
|
+
caps: new Map([...updated.caps, [capName, reading]])
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
} else {
|
|
1322
|
+
const reading = current !== void 0 ? await this.readCap(deviceId, capName, "bound", current) : null;
|
|
1323
|
+
if (reading !== null) updated = {
|
|
1324
|
+
...updated,
|
|
1325
|
+
caps: new Map([...updated.caps, [capName, reading]])
|
|
1326
|
+
};
|
|
1327
|
+
if (current?.kind === "unbound" && reading !== null && reading.kind !== "unbound") this.logHealed(deviceId, capName, reason);
|
|
1328
|
+
if (capName === DEVICE_STATUS_CAP) {
|
|
1329
|
+
const online = reading === null ? await this.readOnline(deviceId) : reading.kind === "slice" ? onlineOf(reading.slice) : null;
|
|
1330
|
+
if (online !== null && online !== source.online) {
|
|
1331
|
+
updated = {
|
|
1332
|
+
...updated,
|
|
1333
|
+
online
|
|
1334
|
+
};
|
|
1335
|
+
for (const cap of source.caps.keys()) changed.add(compositionSliceKey(source.ref, cap));
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
if (updated === source) continue;
|
|
1340
|
+
this.sources = new Map([...this.sources, [k, updated]]);
|
|
1341
|
+
if (updated.caps !== source.caps) changed.add(compositionSliceKey(source.ref, capName));
|
|
1342
|
+
}
|
|
1343
|
+
return changed;
|
|
1344
|
+
}
|
|
1345
|
+
/** A source found under a NEW id is a re-registration: read it there now (R7c). */
|
|
1346
|
+
async rereadUnderNewId(source, deviceId) {
|
|
1347
|
+
const caps = /* @__PURE__ */ new Map();
|
|
1348
|
+
for (const [cap, prior] of source.caps) caps.set(cap, await this.readCap(deviceId, cap, "bound", prior));
|
|
1349
|
+
const online = await this.readOnline(deviceId) ?? source.online;
|
|
1350
|
+
return {
|
|
1351
|
+
...source,
|
|
1352
|
+
deviceId,
|
|
1353
|
+
online,
|
|
1354
|
+
caps
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
async hydrate(request, before, lists) {
|
|
1358
|
+
if (before !== void 0 && before.missingReason !== null) return before;
|
|
1359
|
+
if (before !== void 0 && before.deviceId !== null) {
|
|
1360
|
+
const added = request.caps.filter((cap) => !before.caps.has(cap));
|
|
1361
|
+
if (added.length === 0 && request.caps.length === before.caps.size) return before;
|
|
1362
|
+
const caps = await this.readCaps(before.deviceId, request.caps, before.caps, added);
|
|
1363
|
+
return {
|
|
1364
|
+
...before,
|
|
1365
|
+
caps
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
const k = compositionSourceKey(request.ref);
|
|
1369
|
+
const first = lists.get(request.ref.addonId) ?? await this.ports.listDevicesOf(request.ref.addonId);
|
|
1370
|
+
lists.set(request.ref.addonId, first);
|
|
1371
|
+
const resolution = await this.resolveConfirmed(request.ref, first);
|
|
1372
|
+
if (resolution.kind === "missing") {
|
|
1373
|
+
this.ports.logger.warn("composition source does not exist — two reads agreed", { meta: { source: k } });
|
|
1374
|
+
return {
|
|
1375
|
+
ref: request.ref,
|
|
1376
|
+
deviceId: null,
|
|
1377
|
+
missingReason: `source \`${k}\` does not exist — two reads agreed`,
|
|
1378
|
+
online: null,
|
|
1379
|
+
caps: /* @__PURE__ */ new Map()
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
if (resolution.kind === "unknown") {
|
|
1383
|
+
this.ports.logger.warn("composition source: the device index could not be read — its fields are unavailable until the next apply", { meta: {
|
|
1384
|
+
source: k,
|
|
1385
|
+
error: resolution.error
|
|
1386
|
+
} });
|
|
1387
|
+
return {
|
|
1388
|
+
ref: request.ref,
|
|
1389
|
+
deviceId: null,
|
|
1390
|
+
missingReason: null,
|
|
1391
|
+
online: null,
|
|
1392
|
+
caps: new Map(request.caps.map((cap) => [cap, {
|
|
1393
|
+
kind: "none",
|
|
1394
|
+
reason: `the source could not be resolved: ${resolution.error}`
|
|
1395
|
+
}]))
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
const deviceId = resolution.deviceId;
|
|
1399
|
+
const known = before?.caps ?? /* @__PURE__ */ new Map();
|
|
1400
|
+
const toRead = request.caps.filter((cap) => known.get(cap)?.kind !== "slice");
|
|
1401
|
+
const caps = await this.readCaps(deviceId, request.caps, known, toRead);
|
|
1402
|
+
const online = before?.online ?? await this.readOnline(deviceId);
|
|
1403
|
+
return {
|
|
1404
|
+
ref: request.ref,
|
|
1405
|
+
deviceId,
|
|
1406
|
+
missingReason: null,
|
|
1407
|
+
online,
|
|
1408
|
+
caps
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
/** Read `toRead` (bindings first, then slices); every other requested cap keeps what `known` holds. */
|
|
1412
|
+
async readCaps(deviceId, requested, known, toRead) {
|
|
1413
|
+
const bound = toRead.length > 0 ? await this.readBindings(deviceId, toRead) : /* @__PURE__ */ new Map();
|
|
1414
|
+
const caps = /* @__PURE__ */ new Map();
|
|
1415
|
+
for (const cap of requested) {
|
|
1416
|
+
const prior = known.get(cap);
|
|
1417
|
+
caps.set(cap, prior !== void 0 && !toRead.includes(cap) ? prior : await this.readCap(deviceId, cap, bound.get(cap) ?? "unknown", prior));
|
|
1418
|
+
}
|
|
1419
|
+
return caps;
|
|
1420
|
+
}
|
|
1421
|
+
async resolveConfirmed(ref, first) {
|
|
1422
|
+
const verdict = findRow(first ?? await this.ports.listDevicesOf(ref.addonId), ref.stableId);
|
|
1423
|
+
if (verdict.kind !== "absent") return verdict;
|
|
1424
|
+
const second = findRow(await this.ports.listDevicesOf(ref.addonId), ref.stableId);
|
|
1425
|
+
if (second.kind === "found") {
|
|
1426
|
+
this.ports.logger.warn("composition source: the device index disagreed with itself — keeping the source", {
|
|
1427
|
+
tags: { deviceId: second.deviceId },
|
|
1428
|
+
meta: { source: compositionSourceKey(ref) }
|
|
1429
|
+
});
|
|
1430
|
+
return second;
|
|
1431
|
+
}
|
|
1432
|
+
return second.kind === "absent" ? { kind: "missing" } : second;
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* One bindings read. A second one would not confirm anything: back to back
|
|
1436
|
+
* they are one correlated read, and "unbound" no longer destroys work — it is
|
|
1437
|
+
* a non-fatal `none` that `native-registered` heals.
|
|
1438
|
+
*/
|
|
1439
|
+
async readBindings(deviceId, caps) {
|
|
1440
|
+
const read = await this.ports.readBoundCaps(deviceId);
|
|
1441
|
+
return new Map(caps.map((c) => {
|
|
1442
|
+
if (read.kind === "unreadable") return [c, "unknown"];
|
|
1443
|
+
return [c, read.caps.has(c) ? "bound" : "unbound"];
|
|
1444
|
+
}));
|
|
1445
|
+
}
|
|
1446
|
+
async readCap(deviceId, cap, binding, prior) {
|
|
1447
|
+
if (binding === "unbound") {
|
|
1448
|
+
const reason = `capability \`${cap}\` is not bound on device ${deviceId} — waiting for \`native-registered\``;
|
|
1449
|
+
if (prior?.kind !== "unbound") this.ports.logger.warn("composition source: a requested capability is not bound — the field is unavailable until it registers", {
|
|
1450
|
+
tags: { deviceId },
|
|
1451
|
+
meta: {
|
|
1452
|
+
cap,
|
|
1453
|
+
reason
|
|
1454
|
+
}
|
|
1455
|
+
});
|
|
1456
|
+
return {
|
|
1457
|
+
kind: "unbound",
|
|
1458
|
+
reason
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
const read = await this.ports.readSlice(deviceId, cap);
|
|
1462
|
+
if (read.kind === "slice") return {
|
|
1463
|
+
kind: "slice",
|
|
1464
|
+
slice: read.slice,
|
|
1465
|
+
staleReason: null
|
|
1466
|
+
};
|
|
1467
|
+
if (read.kind === "none") return prior?.kind === "slice" ? {
|
|
1468
|
+
...prior,
|
|
1469
|
+
staleReason: `a re-read found no \`${cap}\` report — the last value is kept`
|
|
1470
|
+
} : {
|
|
1471
|
+
kind: "none",
|
|
1472
|
+
reason: `the source has not reported \`${cap}\` yet`
|
|
1473
|
+
};
|
|
1474
|
+
this.ports.logger.warn("composition source: a slice read failed — the last value is kept", {
|
|
1475
|
+
tags: { deviceId },
|
|
1476
|
+
meta: {
|
|
1477
|
+
cap,
|
|
1478
|
+
error: read.error
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
return prior?.kind === "slice" ? {
|
|
1482
|
+
...prior,
|
|
1483
|
+
staleReason: `re-read failed: ${read.error}`
|
|
1484
|
+
} : {
|
|
1485
|
+
kind: "none",
|
|
1486
|
+
reason: `the slice could not be read: ${read.error}`
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
logHealed(deviceId, cap, via) {
|
|
1490
|
+
this.ports.logger.info("composition source: an unbound capability is served again", {
|
|
1491
|
+
tags: { deviceId },
|
|
1492
|
+
meta: {
|
|
1493
|
+
cap,
|
|
1494
|
+
via
|
|
1495
|
+
}
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
async readOnline(deviceId) {
|
|
1499
|
+
const read = await this.ports.readSlice(deviceId, DEVICE_STATUS_CAP);
|
|
1500
|
+
if (read.kind === "unreadable") this.ports.logger.warn("composition source: `device-status` could not be read — online unknown", {
|
|
1501
|
+
tags: { deviceId },
|
|
1502
|
+
meta: { error: read.error }
|
|
1503
|
+
});
|
|
1504
|
+
return read.kind === "slice" ? onlineOf(read.slice) : null;
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
//#endregion
|
|
1508
|
+
//#region src/builtins/composer/composer.addon.ts
|
|
1509
|
+
/**
|
|
1510
|
+
* The `composer` builtin (D659): runs every declarative composition in ONE
|
|
1511
|
+
* isolated runner (`execution.isolate` + `group: composer`). It is
|
|
1512
|
+
* frame-free and low-crash, and its group is a group of ONE — no device
|
|
1513
|
+
* provider or data plane shares it, so a crash respawns nothing else (D29,
|
|
1514
|
+
* enforced by `scripts/check-runner-group-isolation.ts`). `CrashSupervisor`
|
|
1515
|
+
* bounds it like every forked runner (D6). Hub-only: declarations are cluster
|
|
1516
|
+
* state.
|
|
1517
|
+
*/
|
|
1518
|
+
/** Ignored by `DeclaredDevices` whenever `integrationId` is supplied, which is always here. */
|
|
1519
|
+
var DECLARED_INTEGRATION_NAME_UNUSED = "Blocks";
|
|
1520
|
+
var ComposerAddon = class extends BaseAddon {
|
|
1521
|
+
composer = null;
|
|
1522
|
+
constructor() {
|
|
1523
|
+
super({});
|
|
1524
|
+
}
|
|
1525
|
+
async onInitialize() {
|
|
1526
|
+
const api = this.ctx.api;
|
|
1527
|
+
const logger = this.ctx.logger;
|
|
1528
|
+
const tracker = new SourceTracker({
|
|
1529
|
+
logger: logger.child("sources"),
|
|
1530
|
+
listDevicesOf: async (addonId) => {
|
|
1531
|
+
try {
|
|
1532
|
+
return {
|
|
1533
|
+
kind: "rows",
|
|
1534
|
+
rows: (await api.deviceManager.listAll.query({
|
|
1535
|
+
addonId,
|
|
1536
|
+
projection: "slim"
|
|
1537
|
+
})).map((r) => ({
|
|
1538
|
+
id: r.id,
|
|
1539
|
+
stableId: r.stableId
|
|
1540
|
+
}))
|
|
1541
|
+
};
|
|
1542
|
+
} catch (err) {
|
|
1543
|
+
return {
|
|
1544
|
+
kind: "unreadable",
|
|
1545
|
+
error: errMsg(err)
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
},
|
|
1549
|
+
readSlice: async (deviceId, capName) => {
|
|
1550
|
+
try {
|
|
1551
|
+
const slice = await api.deviceState.getCapSlice.query({
|
|
1552
|
+
deviceId,
|
|
1553
|
+
capName
|
|
1554
|
+
});
|
|
1555
|
+
return slice === null || slice === void 0 ? { kind: "none" } : {
|
|
1556
|
+
kind: "slice",
|
|
1557
|
+
slice
|
|
1558
|
+
};
|
|
1559
|
+
} catch (err) {
|
|
1560
|
+
return {
|
|
1561
|
+
kind: "unreadable",
|
|
1562
|
+
error: errMsg(err)
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
},
|
|
1566
|
+
readBoundCaps: async (deviceId) => {
|
|
1567
|
+
try {
|
|
1568
|
+
const bindings = await api.deviceManager.getBindings.query({ deviceId });
|
|
1569
|
+
return {
|
|
1570
|
+
kind: "caps",
|
|
1571
|
+
caps: new Set(bindings.entries.map((e) => e.capName))
|
|
1572
|
+
};
|
|
1573
|
+
} catch (err) {
|
|
1574
|
+
return {
|
|
1575
|
+
kind: "unreadable",
|
|
1576
|
+
error: errMsg(err)
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
});
|
|
1581
|
+
const listOwnDevices = async () => api.deviceManager.listAll.query({ addonId: COMPOSER_ADDON_ID });
|
|
1582
|
+
const declared = new DeclaredDevices({
|
|
1583
|
+
logger: logger.child("declaration"),
|
|
1584
|
+
addonId: COMPOSER_ADDON_ID,
|
|
1585
|
+
devices: this.ctx.kernel.devices,
|
|
1586
|
+
localNodeId: this.ctx.kernel.localNodeId,
|
|
1587
|
+
getIntegration: async () => {
|
|
1588
|
+
throw new Error("composer: devices hang off the Blocks integration; it never reads its own");
|
|
1589
|
+
},
|
|
1590
|
+
createIntegration: async () => {
|
|
1591
|
+
throw new Error("composer: devices hang off the Blocks integration; it never mints one");
|
|
1592
|
+
},
|
|
1593
|
+
updateIntegration: async () => {
|
|
1594
|
+
throw new Error("composer: devices hang off the Blocks integration; it never updates one");
|
|
1595
|
+
},
|
|
1596
|
+
listOwnDevices
|
|
1597
|
+
});
|
|
1598
|
+
const devices = this.ctx.kernel.devices;
|
|
1599
|
+
this.composer = new Composer({
|
|
1600
|
+
logger: logger.child("composer"),
|
|
1601
|
+
tracker,
|
|
1602
|
+
lookupCap: buildCapabilityLookup(ALL_CAPABILITY_DEFINITIONS),
|
|
1603
|
+
readPersisted: async () => (await listOwnDevices()).map((r) => ({
|
|
1604
|
+
id: r.id,
|
|
1605
|
+
stableId: r.stableId
|
|
1606
|
+
})),
|
|
1607
|
+
declare: async (integrationId, declarations) => {
|
|
1608
|
+
const result = await declared.reconcile({
|
|
1609
|
+
integrationName: DECLARED_INTEGRATION_NAME_UNUSED,
|
|
1610
|
+
integrationId,
|
|
1611
|
+
devices: declarations
|
|
1612
|
+
});
|
|
1613
|
+
const handles = /* @__PURE__ */ new Map();
|
|
1614
|
+
for (const outcome of result.devices) if (isComposedDevice(outcome.device)) handles.set(outcome.stableId, outcome.device);
|
|
1615
|
+
else logger.error("a declared composition is not a ComposedDevice — its caps cannot be written", {
|
|
1616
|
+
tags: { deviceId: outcome.deviceId },
|
|
1617
|
+
meta: { stableId: outcome.stableId }
|
|
1618
|
+
});
|
|
1619
|
+
return { devices: handles };
|
|
1620
|
+
},
|
|
1621
|
+
devices: {
|
|
1622
|
+
setName: (deviceId, name) => devices.setName(deviceId, name),
|
|
1623
|
+
decommission: (deviceId) => devices.decommission(deviceId),
|
|
1624
|
+
applyDerivedSpec: (deviceId, derived) => devices.applyDerivedSpec(deviceId, derived)
|
|
1625
|
+
},
|
|
1626
|
+
now: () => Date.now(),
|
|
1627
|
+
newTimer: () => new NodeDeadlineTimer(() => Date.now())
|
|
1628
|
+
});
|
|
1629
|
+
this.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
|
|
1630
|
+
const data = parseDeviceStateChanged(event.data);
|
|
1631
|
+
if (data !== null) this.composer?.onStateChanged(data).catch((err) => {
|
|
1632
|
+
this.logEventFailure("device.state-changed", data.deviceId, err);
|
|
1633
|
+
});
|
|
1634
|
+
});
|
|
1635
|
+
this.subscribe({ category: EventCategory.DeviceUnregistered }, (event) => {
|
|
1636
|
+
this.composer?.onDeviceUnregistered(event.data.deviceId).catch((err) => {
|
|
1637
|
+
this.logEventFailure("device.unregistered", event.data.deviceId, err);
|
|
1638
|
+
});
|
|
1639
|
+
});
|
|
1640
|
+
this.subscribe({ category: EventCategory.DeviceBindingsChanged }, (event) => {
|
|
1641
|
+
this.composer?.onBindingsChanged(event.data).catch((err) => {
|
|
1642
|
+
this.logEventFailure("device.bindings-changed", event.data.deviceId, err);
|
|
1643
|
+
});
|
|
1644
|
+
});
|
|
1645
|
+
logger.info("composer ready — waiting for core-blocks to apply the compositions");
|
|
1646
|
+
return [{
|
|
1647
|
+
capability: composerCapability,
|
|
1648
|
+
provider: this.buildProvider()
|
|
1649
|
+
}];
|
|
1650
|
+
}
|
|
1651
|
+
async onShutdown() {
|
|
1652
|
+
this.composer?.shutdown();
|
|
1653
|
+
this.composer = null;
|
|
1654
|
+
}
|
|
1655
|
+
/** An event handler never rejects unhandled: in a forked runner that is a crash strike (D6). */
|
|
1656
|
+
logEventFailure(category, deviceId, err) {
|
|
1657
|
+
this.ctx.logger.warn("composer: handling a device event failed — the next event or apply re-reads", {
|
|
1658
|
+
tags: { deviceId },
|
|
1659
|
+
meta: {
|
|
1660
|
+
category,
|
|
1661
|
+
error: errMsg(err)
|
|
1662
|
+
}
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
buildProvider() {
|
|
1666
|
+
return {
|
|
1667
|
+
applyBlocks: async (input) => this.requireComposer().apply(input),
|
|
1668
|
+
getStates: async ({ blockIds }) => ({ states: [...this.requireComposer().getStates(blockIds)] })
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
requireComposer() {
|
|
1672
|
+
if (this.composer === null) throw new Error("composer: not initialized");
|
|
1673
|
+
return this.composer;
|
|
1674
|
+
}
|
|
1675
|
+
};
|
|
1676
|
+
//#endregion
|
|
1677
|
+
export { ComposerAddon, ComposerAddon as default };
|