@camstack/system 1.2.307 → 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-DdfJa8Zn.mjs → dist-BEuQPDk_.mjs} +1333 -83
  71. package/dist/{dist-D3ch99WD.js → dist-BhO9eYKO.js} +1422 -82
  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-BJNhhXap.mjs → retired-settings-keys-CQvG6B-Q.mjs} +1 -1
  76. package/dist/{retired-settings-keys-DG_LEPqC.js → retired-settings-keys-DHgnJqS3.js} +1 -1
  77. package/package.json +20 -1
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("../../chunk-Cek0wNdY.js");
3
- const require_dist = require("../../dist-D3ch99WD.js");
3
+ const require_dist = require("../../dist-BhO9eYKO.js");
4
4
  let node_crypto = require("node:crypto");
5
5
  let node_fs_promises = require("node:fs/promises");
6
6
  node_fs_promises = require_chunk.__toESM(node_fs_promises);
@@ -156,6 +156,27 @@ async function removeBlockPackage(root, blockId) {
156
156
  }
157
157
  //#endregion
158
158
  //#region src/builtins/core-blocks/block-supervisor.ts
159
+ /**
160
+ * Converging the running block processes onto the stored blocks.
161
+ *
162
+ * Reconcile against the registry, not one-shot reactions to events: a spawn
163
+ * that was missed because an event was dropped (D8 — events are telemetry) has
164
+ * to be fixed by the next pass, and an operator restarting the hub must end up
165
+ * with exactly the blocks the store says are enabled. So `converge()` is
166
+ * idempotent and safe to call from a timer, from a write, and from boot.
167
+ *
168
+ * **The supervision itself is not in this file, and that is deliberate.** A
169
+ * block that never returns is bounded by `process-service`'s `CrashSupervisor`
170
+ * (D6) — the same circuit-breaker that bounds every addon. This class only
171
+ * decides which runners should exist and translates their state back into
172
+ * something the author can read. Re-implementing backoff here would be a second
173
+ * supervisor to keep in step with the first.
174
+ *
175
+ * The invariant it exists to hold: **a block that is not running says why.**
176
+ * Every path out of `converge()` that does not end in a live process writes a
177
+ * status the operator can see, with the reason attached. A block that is
178
+ * silently absent is the failure mode this whole feature has to avoid.
179
+ */
159
180
  /** Runner state → what the author should read. `crashed` is the window between
160
181
  * an exit and the scheduled respawn, so it reads as `starting` with the reason
161
182
  * attached rather than as a terminal failure it is not. */
@@ -180,6 +201,16 @@ var BlockSupervisor = class {
180
201
  /** Serialises passes so a timer tick cannot interleave with a write-driven
181
202
  * converge and spawn the same runner twice. */
182
203
  inFlight = Promise.resolve();
204
+ /** Composition restarts not yet delivered to the composer. Stores nothing durable (D62). */
205
+ pendingCompositionRestarts = /* @__PURE__ */ new Set();
206
+ /**
207
+ * Empty applies still owed: the composer withdraws only after TWO agree
208
+ * (D49), and a boot sends one. Only an apply the composer says it ACTED on
209
+ * (`result.acted`) pays one off — it can refuse with the store loaded.
210
+ */
211
+ emptyAppliesOwed = 2;
212
+ /** Consecutive applies that could not reach the composer: warned once per run. */
213
+ failedApplies = 0;
183
214
  constructor(deps) {
184
215
  this.deps = deps;
185
216
  }
@@ -216,6 +247,11 @@ var BlockSupervisor = class {
216
247
  * then never re-launch it.
217
248
  */
218
249
  restart(blockId) {
250
+ const block = this.deps.store.get(blockId);
251
+ if (block !== null && require_dist.coreBlockSourceKind(block) === "composition") {
252
+ this.pendingCompositionRestarts = new Set([...this.pendingCompositionRestarts, blockId]);
253
+ return this.converge();
254
+ }
219
255
  this.inFlight = this.inFlight.then(() => this.runRestart(blockId), () => this.runRestart(blockId));
220
256
  return this.inFlight;
221
257
  }
@@ -240,9 +276,11 @@ var BlockSupervisor = class {
240
276
  }
241
277
  }
242
278
  async reconcile() {
279
+ if (!this.deps.storeLoaded()) await this.deps.loadStore();
243
280
  const blocks = this.deps.store.list();
244
- const mine = blocks.filter((b) => b.enabled && this.isPlacedHere(b));
245
- const runners = new Map((await this.deps.host.list()).map((r) => [blockIdFromAddonId(r.name), r]).filter((pair) => pair[0] !== null));
281
+ const mine = blocks.filter((b) => b.enabled && this.isPlacedHere(b) && require_dist.coreBlockSourceKind(b) === "code");
282
+ const snapshots = await this.deps.host.list();
283
+ const runners = new Map(snapshots.map((r) => [blockIdFromAddonId(r.name), r]).filter((pair) => pair[0] !== null));
246
284
  for (const block of blocks) {
247
285
  if (!block.enabled || this.isPlacedHere(block)) continue;
248
286
  await this.report(block.id, "failed", `no core-block supervisor runs on "${block.placement}" — this block is not running anywhere. Move it to "${this.deps.nodeId}" to run it.`);
@@ -260,6 +298,7 @@ var BlockSupervisor = class {
260
298
  if (this.deps.store.get(blockId) !== null) await this.report(blockId, "stopped");
261
299
  else await removeBlockPackage(this.deps.root, blockId);
262
300
  }
301
+ for (const block of blocks) if (block.enabled && require_dist.coreBlockSourceKind(block) === "recipe") await this.report(block.id, "failed", "a recipe block cannot run yet — recipes are stored, not executed, in this release");
263
302
  for (const block of mine) {
264
303
  const runner = runners.get(block.id);
265
304
  const stale = runner !== void 0 && this.materialized.get(block.id) !== block.updatedAt;
@@ -277,6 +316,71 @@ var BlockSupervisor = class {
277
316
  }
278
317
  await this.launch(block);
279
318
  }
319
+ await this.convergeCompositions(blocks, snapshots);
320
+ }
321
+ /**
322
+ * Send every composition placed here to the composer and mirror its verdicts
323
+ * into the block status, reason verbatim. A `starting` verdict (the composer
324
+ * does not trust its store or seed yet) is mirrored as `starting`; the next
325
+ * pass of this same converge is the retry — no timer of its own (D3).
326
+ */
327
+ async convergeCompositions(blocks, runners) {
328
+ const compositions = blocks.flatMap((block) => {
329
+ const composition = require_dist.coreBlockComposition(block);
330
+ return composition !== null && this.isPlacedHere(block) ? [{
331
+ block,
332
+ composition
333
+ }] : [];
334
+ });
335
+ const unreadable = this.deps.store.unreadableCompositions();
336
+ const nothingToSay = compositions.length === 0 && unreadable.length === 0;
337
+ if (nothingToSay && this.emptyAppliesOwed === 0) return;
338
+ const composer = this.deps.composer;
339
+ if (composer === null) {
340
+ for (const { block } of compositions) await this.report(block.id, "failed", "no composer is available on this node — a composition cannot run here");
341
+ return;
342
+ }
343
+ if (runners.find((r) => r.name === "composer")?.state === "failed") {
344
+ for (const { block } of compositions) await this.report(block.id, "failed", "the composer runner's crash circuit-breaker tripped — no composition runs until it is restarted");
345
+ return;
346
+ }
347
+ const restart = this.pendingCompositionRestarts;
348
+ const integrationId = this.deps.integrationId();
349
+ const storeLoaded = this.deps.storeLoaded();
350
+ try {
351
+ const result = await composer.apply({
352
+ integrationId,
353
+ storeLoaded,
354
+ blocks: compositions.map(({ block, composition }) => ({
355
+ id: block.id,
356
+ name: block.name,
357
+ enabled: block.enabled,
358
+ updatedAt: block.updatedAt,
359
+ composition
360
+ })),
361
+ restartBlockIds: [...restart],
362
+ unreadableBlocks: [...unreadable]
363
+ });
364
+ if (this.failedApplies > 0) {
365
+ this.deps.logger.info("the composer answered again", { meta: { failedApplies: this.failedApplies } });
366
+ this.failedApplies = 0;
367
+ }
368
+ this.pendingCompositionRestarts = new Set([...this.pendingCompositionRestarts].filter((id) => !restart.has(id)));
369
+ this.emptyAppliesOwed = !nothingToSay || !result.acted ? 2 : Math.max(0, this.emptyAppliesOwed - 1);
370
+ const verdicts = new Map(result.blocks.map((v) => [v.blockId, v]));
371
+ for (const { block } of compositions) {
372
+ const verdict = verdicts.get(block.id);
373
+ if (verdict === void 0) await this.report(block.id, "starting", "the composer did not answer for this block");
374
+ else await this.report(block.id, verdict.status, verdict.reason);
375
+ }
376
+ } catch (err) {
377
+ this.failedApplies += 1;
378
+ if (this.failedApplies === 1) this.deps.logger.warn("the composer could not be reached — compositions keep their last status", { meta: {
379
+ error: require_dist.errMsg(err),
380
+ compositions: compositions.length
381
+ } });
382
+ for (const { block } of compositions) if (block.status === "stopped" && block.enabled) await this.report(block.id, "starting", `the composer is not reachable yet: ${require_dist.errMsg(err)}`);
383
+ }
280
384
  }
281
385
  /** Compile, write the package, spawn. Each failure is reported ON the block
282
386
  * — the author asked for this to run, so they are owed the reason it does
@@ -547,12 +651,42 @@ var CORE_BLOCKS_INDEXES = [{
547
651
  name: "idx_core_blocks_enabled",
548
652
  columns: ["enabled"]
549
653
  }];
654
+ /**
655
+ * Rows one read asks for. A read that ANSWERS this many is not a loaded table:
656
+ * rows past the limit would be absent, and an absent composition reads as a
657
+ * deleted one (D49).
658
+ */
659
+ var CORE_BLOCKS_LOAD_LIMIT = 1e3;
660
+ /**
661
+ * What an unparseable row's `source.kind` says, or null when it cannot be told.
662
+ * Absent means `code`, as it does for a readable row.
663
+ */
664
+ function rawSourceKind(raw) {
665
+ if (typeof raw !== "object" || raw === null) return null;
666
+ if (!("source" in raw) || raw.source === void 0) return "code";
667
+ const source = raw.source;
668
+ if (typeof source !== "object" || source === null || !("kind" in source)) return null;
669
+ return typeof source.kind === "string" ? source.kind : null;
670
+ }
671
+ /** An unreadable row that may be a composition: one whose kind says so, or cannot be told. */
672
+ function mayBeComposition(raw) {
673
+ const kind = rawSourceKind(raw);
674
+ return kind !== "code" && kind !== "recipe";
675
+ }
676
+ function parseErrorText(issues) {
677
+ return issues.map((i) => `${i.path.map(String).join(".")}: ${i.message}`).join("; ");
678
+ }
550
679
  var CoreBlockStore = class {
551
680
  byId = /* @__PURE__ */ new Map();
681
+ /** Rows that exist but do not parse and may be compositions — held for the composer, never dropped (D49). */
682
+ unreadable = [];
552
683
  store;
553
684
  logger;
554
685
  now;
555
686
  newId;
687
+ loaded = false;
688
+ /** Consecutive failed reads — the warn is once per run of them, not once per retry. */
689
+ failedLoads = 0;
556
690
  constructor(deps) {
557
691
  this.store = deps.store;
558
692
  this.logger = deps.logger;
@@ -566,32 +700,70 @@ var CoreBlockStore = class {
566
700
  indexes: [...CORE_BLOCKS_INDEXES]
567
701
  });
568
702
  }
703
+ /** True only after a `load()` that READ the table. The composer withdraws nothing otherwise (D49). */
704
+ isLoaded() {
705
+ return this.loaded;
706
+ }
707
+ /** Stored composition rows that do not parse, by id — the composer holds their devices. */
708
+ unreadableCompositions() {
709
+ return this.unreadable;
710
+ }
569
711
  /**
570
712
  * Hydrate every block.
571
713
  *
572
714
  * A row whose JSON no longer validates is SKIPPED with a warning rather than
573
715
  * dropped or repaired: it is somebody's code, and the honest answer to "I
574
- * cannot read this" is to leave it on disk and say so.
716
+ * cannot read this" is to leave it on disk and say so. A skipped row that may
717
+ * be a COMPOSITION is also kept in `unreadableCompositions()`: absent from the
718
+ * composer's input it would read as deleted, and its device would go (D49).
719
+ *
720
+ * `source` is parsed here, and a build older than D659 strips `source` and
721
+ * rewrites the row — rolling back below D659 erases compositions.
575
722
  */
576
723
  async load() {
577
724
  try {
578
725
  const rows = await this.store.query.query({
579
726
  collection: CORE_BLOCKS_COLLECTION,
580
- filter: { limit: 1e3 }
727
+ filter: { limit: CORE_BLOCKS_LOAD_LIMIT }
581
728
  });
729
+ if (rows.length >= 1e3) throw new Error(`core_blocks answered ${rows.length} rows, the read limit — rows past it would read as deleted`);
582
730
  this.byId.clear();
731
+ this.loaded = true;
732
+ if (this.failedLoads > 0) {
733
+ this.logger.info("core blocks loaded after failed reads", { meta: { failedAttempts: this.failedLoads } });
734
+ this.failedLoads = 0;
735
+ }
583
736
  let skipped = 0;
737
+ const unreadable = [];
584
738
  for (const row of rows) {
585
- const parsed = require_dist.CoreBlockSchema.safeParse(row.data["block"]);
739
+ const raw = row.data["block"];
740
+ const parsed = require_dist.CoreBlockSchema.safeParse(raw);
586
741
  if (!parsed.success) {
587
742
  skipped += 1;
743
+ if (mayBeComposition(raw)) {
744
+ const name = row.data["name"];
745
+ unreadable.push({
746
+ id: row.id,
747
+ name: typeof name === "string" ? name : null,
748
+ error: parseErrorText(parsed.error.issues)
749
+ });
750
+ }
588
751
  continue;
589
752
  }
590
753
  this.byId.set(parsed.data.id, parsed.data);
591
754
  }
592
- if (skipped > 0) this.logger.warn("core blocks skipped as unreadable — left on disk, not repaired", { meta: { skipped } });
755
+ this.unreadable = unreadable;
756
+ if (skipped > 0) this.logger.warn("core blocks skipped as unreadable — left on disk, not repaired", { meta: {
757
+ skipped,
758
+ heldForComposer: unreadable.map((u) => u.id)
759
+ } });
593
760
  } catch (err) {
594
- this.logger.warn("core block load failed", { meta: { error: String(err) } });
761
+ this.failedLoads += 1;
762
+ if (this.failedLoads === 1) this.logger.warn("core block load failed — retried on every converge until it reads", { meta: { error: String(err) } });
763
+ else this.logger.debug("core block load failed again", { meta: {
764
+ error: String(err),
765
+ failedAttempts: this.failedLoads
766
+ } });
595
767
  }
596
768
  }
597
769
  list() {
@@ -605,6 +777,7 @@ var CoreBlockStore = class {
605
777
  return this.list().filter((b) => b.enabled && b.placement === nodeId);
606
778
  }
607
779
  async create(input, createdBy) {
780
+ this.requireLoaded();
608
781
  const now = this.now();
609
782
  const block = require_dist.CoreBlockSchema.parse({
610
783
  ...input,
@@ -623,13 +796,61 @@ var CoreBlockStore = class {
623
796
  async update(blockId, patch) {
624
797
  const existing = this.byId.get(blockId);
625
798
  if (!existing) throw new Error(`core block not found: ${blockId}`);
799
+ if ((patch.code !== void 0 && patch.code !== existing.code || patch.placement !== void 0 && patch.placement !== existing.placement || patch.integrationId !== void 0 && patch.integrationId !== existing.integrationId) && require_dist.coreBlockSourceKind(existing) !== "code") throw new Error(`core block "${existing.name}" is a composition — its code, placement and integration are fixed; edit it with updateComposition`);
800
+ if (patch.name !== void 0 && patch.name !== existing.name && require_dist.coreBlockSourceKind(existing) !== "code") throw new Error(`core block "${existing.name}" is a composition — rename it with updateComposition, which checks expectedUpdatedAt`);
626
801
  const updated = require_dist.CoreBlockSchema.parse({
627
802
  ...existing,
628
803
  ...patch,
629
804
  id: existing.id,
630
805
  createdBy: existing.createdBy,
631
806
  createdAt: existing.createdAt,
632
- updatedAt: this.now()
807
+ updatedAt: Math.max(this.now(), existing.updatedAt + 1)
808
+ });
809
+ await this.persist(updated);
810
+ this.byId.set(updated.id, updated);
811
+ return updated;
812
+ }
813
+ /**
814
+ * Store a composition block (D659): hub-placed, no code, never `running` on
815
+ * creation — the composer's verdict, mirrored by the supervisor, says that.
816
+ */
817
+ async createComposition(input, createdBy) {
818
+ this.requireLoaded();
819
+ const now = this.now();
820
+ const block = require_dist.CoreBlockSchema.parse({
821
+ name: input.name,
822
+ enabled: input.enabled,
823
+ placement: "hub",
824
+ code: "",
825
+ source: {
826
+ kind: "composition",
827
+ composition: input.composition
828
+ },
829
+ id: this.newId(),
830
+ createdBy,
831
+ createdAt: now,
832
+ updatedAt: now,
833
+ status: "stopped",
834
+ lastChangedAt: now
835
+ });
836
+ await this.persist(block);
837
+ this.byId.set(block.id, block);
838
+ return block;
839
+ }
840
+ /** Patch a composition block. Immutable: returns the NEW block. Refuses a code block by name. */
841
+ async updateComposition(blockId, patch) {
842
+ const existing = this.byId.get(blockId);
843
+ if (!existing) throw new Error(`core block not found: ${blockId}`);
844
+ if (require_dist.coreBlockSourceKind(existing) !== "composition") throw new Error(`core block "${existing.name}" is a code block — edit it with update`);
845
+ const updated = require_dist.CoreBlockSchema.parse({
846
+ ...existing,
847
+ ...patch.name === void 0 ? {} : { name: patch.name },
848
+ ...patch.enabled === void 0 ? {} : { enabled: patch.enabled },
849
+ ...patch.composition === void 0 ? {} : { source: {
850
+ kind: "composition",
851
+ composition: patch.composition
852
+ } },
853
+ updatedAt: Math.max(this.now(), existing.updatedAt + 1)
633
854
  });
634
855
  await this.persist(updated);
635
856
  this.byId.set(updated.id, updated);
@@ -655,9 +876,17 @@ var CoreBlockStore = class {
655
876
  await this.persist(next);
656
877
  this.byId.set(blockId, next);
657
878
  }
879
+ /**
880
+ * A write before the table has been read would be wiped from memory by the
881
+ * load that follows (it clears and refills), so it is refused by name.
882
+ */
883
+ requireLoaded() {
884
+ if (!this.loaded) throw new Error("core_blocks has not loaded yet — nothing can be created until it has; retry shortly");
885
+ }
658
886
  /** Idempotent delete — an unknown id is a no-op. */
659
887
  async delete(blockId) {
660
888
  this.byId.delete(blockId);
889
+ this.unreadable = this.unreadable.filter((u) => u.id !== blockId);
661
890
  await this.store.delete.mutate({
662
891
  collection: CORE_BLOCKS_COLLECTION,
663
892
  key: blockId
@@ -722,6 +951,11 @@ var BLOCKS_INTEGRATION_NAME = "Blocks";
722
951
  * with every device on the cluster, and the sweep would delete the blocks'), or
723
952
  * make `core-blocks` declare a device of its own. Neither is worth the
724
953
  * convenience; the spec test pins both.
954
+ *
955
+ * A SECOND owner hangs devices here since D659: `composer` (every composed
956
+ * device). It is equally safe for the same reason — its rows are owned by
957
+ * `composer`, never by `core-blocks`, and its own withdrawal sweep is scoped to
958
+ * `composer`.
725
959
  */
726
960
  async function reconcileBlocksIntegration(ports) {
727
961
  return (await new require_dist.DeclaredDevices({
@@ -779,6 +1013,229 @@ function armBlocksIntegration(options) {
779
1013
  })() };
780
1014
  }
781
1015
  //#endregion
1016
+ //#region src/builtins/core-blocks/composition-api.ts
1017
+ /**
1018
+ * The composition half of the `core-blocks` provider (D659). Flat methods,
1019
+ * spread into the provider: no nested sub-object.
1020
+ *
1021
+ * Every save is VALIDATED against the live sources and every other stored
1022
+ * composition first; a refusal names each problem by path and reason (D391)
1023
+ * and stores nothing. The store's own refusals (not loaded, unknown block, a
1024
+ * code block) propagate unchanged — this layer never swallows them.
1025
+ */
1026
+ var NOT_LOADED_REASON = "core_blocks has not loaded yet";
1027
+ function writeQueue() {
1028
+ let tail = Promise.resolve();
1029
+ return (write) => {
1030
+ const run = tail.then(write, write);
1031
+ tail = run.then(() => void 0, () => void 0);
1032
+ return run;
1033
+ };
1034
+ }
1035
+ /**
1036
+ * Optimistic concurrency: an update names the `updatedAt` it read. A block
1037
+ * changed since is refused by name — last-write-wins would drop the other
1038
+ * admin's edit without a word. An unknown block falls through to the store,
1039
+ * whose own refusal names it.
1040
+ */
1041
+ function refuseIfChanged(deps, blockId, expectedUpdatedAt) {
1042
+ const stored = deps.store.get(blockId);
1043
+ if (stored === null || stored.updatedAt === expectedUpdatedAt) return;
1044
+ deps.logger.warn("composition update refused: changed since read", { meta: {
1045
+ blockId,
1046
+ storedUpdatedAt: stored.updatedAt,
1047
+ expectedUpdatedAt
1048
+ } });
1049
+ throw new Error(`core-blocks: the composition "${stored.name}" changed since you read it: stored updatedAt ${stored.updatedAt}, yours ${expectedUpdatedAt} — reload and re-apply your edit`);
1050
+ }
1051
+ async function validateAgainstStore(deps, composition, blockId) {
1052
+ if (!deps.store.isLoaded()) throw new Error(`${NOT_LOADED_REASON} — a composition cannot be judged against the stored ones until it has; retry shortly`);
1053
+ const self = blockId === null ? null : require_dist.composedDeviceRef(blockId);
1054
+ const peers = deps.store.list().flatMap((block) => {
1055
+ const peer = require_dist.coreBlockComposition(block);
1056
+ return peer === null || block.id === blockId ? [] : [{
1057
+ target: require_dist.composedDeviceRef(block.id),
1058
+ composition: peer
1059
+ }];
1060
+ });
1061
+ const selfKey = self === null ? null : require_dist.compositionSourceKey(self);
1062
+ const refs = require_dist.compositionSourceRefs(composition).filter((r) => require_dist.compositionSourceKey(r) !== selfKey);
1063
+ const sources = await deps.describeSources(refs);
1064
+ return require_dist.validateComposition(composition, {
1065
+ lookupCap: deps.lookupCap,
1066
+ sources,
1067
+ self,
1068
+ peers
1069
+ });
1070
+ }
1071
+ function describeProblems(validation) {
1072
+ return validation.problems.map((p) => `${p.path} [${p.code}]: ${p.message}`);
1073
+ }
1074
+ function refuse(deps, subject, validation) {
1075
+ const problems = describeProblems(validation);
1076
+ deps.logger.warn("composition save refused", { meta: {
1077
+ name: subject.name,
1078
+ blockId: subject.blockId,
1079
+ problems
1080
+ } });
1081
+ throw new Error(`core-blocks: the composition "${subject.name}" was refused — ${problems.join("; ")}`);
1082
+ }
1083
+ async function readStates(deps, blockIds) {
1084
+ try {
1085
+ const states = await deps.composerStates(blockIds);
1086
+ const byId = new Map(states.map((s) => [s.blockId, s]));
1087
+ return new Map(blockIds.map((id) => [id, {
1088
+ kind: "answered",
1089
+ state: byId.get(id) ?? null
1090
+ }]));
1091
+ } catch (err) {
1092
+ const error = require_dist.errMsg(err);
1093
+ deps.logger.warn("composer state unreachable", { meta: {
1094
+ blocks: blockIds.length,
1095
+ error
1096
+ } });
1097
+ return new Map(blockIds.map((id) => [id, {
1098
+ kind: "unreachable",
1099
+ error
1100
+ }]));
1101
+ }
1102
+ }
1103
+ function toViews(blocks, states) {
1104
+ return blocks.flatMap((block) => {
1105
+ const state = states.get(block.id);
1106
+ return state === void 0 ? [] : [{
1107
+ block,
1108
+ state
1109
+ }];
1110
+ });
1111
+ }
1112
+ /**
1113
+ * Every write runs validate-then-persist on ONE chain. Validation awaits a
1114
+ * source read before the save lands; two unserialized saves (A reads B, B
1115
+ * reads A) would each be judged against a store without the other and BOTH
1116
+ * pass — a stored cycle nothing downstream catches.
1117
+ */
1118
+ function buildCompositionApi(deps) {
1119
+ const serialized = writeQueue();
1120
+ return {
1121
+ createComposition: ({ block, caller }) => serialized(async () => {
1122
+ const validation = await validateAgainstStore(deps, block.composition, null);
1123
+ if (!validation.ok) refuse(deps, {
1124
+ name: block.name,
1125
+ blockId: null
1126
+ }, validation);
1127
+ const created = await deps.store.createComposition(block, caller.userId);
1128
+ deps.logger.info("composition created", { meta: {
1129
+ blockId: created.id,
1130
+ name: created.name,
1131
+ by: caller.userId
1132
+ } });
1133
+ deps.convergeSoon();
1134
+ return { block: created };
1135
+ }),
1136
+ updateComposition: ({ blockId, expectedUpdatedAt, block, caller }) => serialized(async () => {
1137
+ refuseIfChanged(deps, blockId, expectedUpdatedAt);
1138
+ if (block.composition !== void 0) {
1139
+ const validation = await validateAgainstStore(deps, block.composition, blockId);
1140
+ if (!validation.ok) refuse(deps, {
1141
+ name: block.name ?? blockId,
1142
+ blockId
1143
+ }, validation);
1144
+ }
1145
+ const updated = await deps.store.updateComposition(blockId, block);
1146
+ deps.logger.info("composition updated", { meta: {
1147
+ blockId,
1148
+ by: caller.userId
1149
+ } });
1150
+ deps.convergeSoon();
1151
+ return { block: updated };
1152
+ }),
1153
+ getComposition: async ({ blockId }) => {
1154
+ if (!deps.store.isLoaded()) return {
1155
+ state: "not-loaded",
1156
+ reason: NOT_LOADED_REASON
1157
+ };
1158
+ const block = deps.store.get(blockId);
1159
+ if (block === null || require_dist.coreBlockComposition(block) === null) return {
1160
+ state: "loaded",
1161
+ view: null
1162
+ };
1163
+ const [view] = toViews([block], await readStates(deps, [blockId]));
1164
+ return {
1165
+ state: "loaded",
1166
+ view: view ?? null
1167
+ };
1168
+ },
1169
+ listCompositions: async () => {
1170
+ if (!deps.store.isLoaded()) return {
1171
+ state: "not-loaded",
1172
+ reason: NOT_LOADED_REASON
1173
+ };
1174
+ const blocks = deps.store.list().filter((b) => require_dist.coreBlockComposition(b) !== null);
1175
+ if (blocks.length === 0) return {
1176
+ state: "loaded",
1177
+ views: []
1178
+ };
1179
+ return {
1180
+ state: "loaded",
1181
+ views: toViews(blocks, await readStates(deps, blocks.map((b) => b.id)))
1182
+ };
1183
+ },
1184
+ validateComposition: async ({ composition, blockId }) => validateAgainstStore(deps, composition, blockId ?? null)
1185
+ };
1186
+ }
1187
+ //#endregion
1188
+ //#region src/builtins/core-blocks/composition-sources.ts
1189
+ /**
1190
+ * Describe a composition's sources for SAVE-time validation (D659): one
1191
+ * `listAll` per owning addon, one `getBindings` per found device. A read that
1192
+ * fails makes the source `unreadable`, and the save is REFUSED rather than
1193
+ * stored unchecked. Refusing a save destroys nothing (D49).
1194
+ *
1195
+ * Sources are keyed by `{addonId, stableId}` — never a numeric device id,
1196
+ * which is this node's allocation and not the composition's identity.
1197
+ */
1198
+ async function listOwners(ports, refs) {
1199
+ const addons = [...new Set(refs.map((r) => r.addonId))];
1200
+ const listed = await Promise.all(addons.map(async (addonId) => {
1201
+ try {
1202
+ return [addonId, await ports.listByAddon(addonId)];
1203
+ } catch (err) {
1204
+ return [addonId, { error: require_dist.errMsg(err) }];
1205
+ }
1206
+ }));
1207
+ return new Map(listed);
1208
+ }
1209
+ async function describeOne(ports, ref, listing) {
1210
+ if (listing === void 0) return {
1211
+ kind: "unreadable",
1212
+ error: `\`${ref.addonId}\` was not listed`
1213
+ };
1214
+ if ("error" in listing) return {
1215
+ kind: "unreadable",
1216
+ error: listing.error
1217
+ };
1218
+ const row = listing.find((r) => r.stableId === ref.stableId);
1219
+ if (row === void 0) return { kind: "absent" };
1220
+ try {
1221
+ return {
1222
+ kind: "found",
1223
+ deviceId: row.id,
1224
+ caps: new Set(await ports.boundCaps(row.id))
1225
+ };
1226
+ } catch (err) {
1227
+ return {
1228
+ kind: "unreadable",
1229
+ error: require_dist.errMsg(err)
1230
+ };
1231
+ }
1232
+ }
1233
+ async function describeCompositionSources(ports, refs) {
1234
+ const owners = await listOwners(ports, refs);
1235
+ const described = await Promise.all(refs.map(async (ref) => [require_dist.compositionSourceKey(ref), await describeOne(ports, ref, owners.get(ref.addonId))]));
1236
+ return new Map(described);
1237
+ }
1238
+ //#endregion
782
1239
  //#region src/builtins/core-blocks/core-blocks.addon.ts
783
1240
  /**
784
1241
  * The `core-blocks` provider — CRUD over user-authored TypeScript, with the
@@ -890,6 +1347,7 @@ var CoreBlocksAddon = class extends require_dist.BaseAddon {
890
1347
  store: {
891
1348
  list: () => store.list(),
892
1349
  get: (blockId) => store.get(blockId),
1350
+ unreadableCompositions: () => store.unreadableCompositions(),
893
1351
  setStatus: (blockId, status, error) => store.setStatus(blockId, status, error)
894
1352
  },
895
1353
  host: createBrokerProcessHost(broker),
@@ -900,7 +1358,15 @@ var CoreBlocksAddon = class extends require_dist.BaseAddon {
900
1358
  };
901
1359
  return compileBlock(this.transpiler, code);
902
1360
  },
903
- logger: this.ctx.logger.child("supervisor")
1361
+ logger: this.ctx.logger.child("supervisor"),
1362
+ composer: { apply: async (input) => {
1363
+ const api = this.ctx.api;
1364
+ if (!api) throw new Error("no ctx.api on this node — the composer cannot be reached");
1365
+ return api.composer.applyBlocks.mutate(input);
1366
+ } },
1367
+ integrationId: () => this.blocksIntegrationId,
1368
+ storeLoaded: () => store.isLoaded(),
1369
+ loadStore: () => store.load()
904
1370
  });
905
1371
  }
906
1372
  /**
@@ -1036,8 +1502,35 @@ var CoreBlocksAddon = class extends require_dist.BaseAddon {
1036
1502
  filePath: l.filePath,
1037
1503
  content: l.content
1038
1504
  })) };
1039
- }
1505
+ },
1506
+ ...this.buildCompositionApi()
1507
+ };
1508
+ }
1509
+ /**
1510
+ * The composition methods (D659), flat. Sources are read over `ctx.api` at
1511
+ * SAVE time — one listing per owning addon, one binding read per found
1512
+ * device — and a read that fails refuses the save rather than skipping it.
1513
+ */
1514
+ buildCompositionApi() {
1515
+ const requireApi = () => {
1516
+ const api = this.ctx.api;
1517
+ if (!api) throw new Error("core-blocks: no ctx.api on this node — compositions cannot be read");
1518
+ return api;
1040
1519
  };
1520
+ return buildCompositionApi({
1521
+ store: this.requireStore(),
1522
+ describeSources: (refs) => describeCompositionSources({
1523
+ listByAddon: async (addonId) => requireApi().deviceManager.listAll.query({
1524
+ addonId,
1525
+ projection: "slim"
1526
+ }),
1527
+ boundCaps: async (deviceId) => (await requireApi().deviceManager.getBindings.query({ deviceId })).entries.map((e) => e.capName)
1528
+ }, refs),
1529
+ lookupCap: require_dist.buildCapabilityLookup(require_dist.ALL_CAPABILITY_DEFINITIONS),
1530
+ composerStates: async (blockIds) => (await requireApi().composer.getStates.query({ blockIds: [...blockIds] })).states,
1531
+ convergeSoon: () => this.convergeSoon(),
1532
+ logger: this.ctx.logger.child("compositions")
1533
+ });
1041
1534
  }
1042
1535
  };
1043
1536
  /**