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