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