@camstack/system 1.2.322 → 1.2.324

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 (61) 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/composer.addon.js +2 -2
  14. package/dist/builtins/composer/composer.addon.mjs +2 -2
  15. package/dist/builtins/console-logging/index.js +1 -1
  16. package/dist/builtins/console-logging/index.mjs +1 -1
  17. package/dist/builtins/core-blocks/composition-api.d.ts +4 -2
  18. package/dist/builtins/core-blocks/core-blocks.addon.js +6 -4
  19. package/dist/builtins/core-blocks/core-blocks.addon.mjs +6 -4
  20. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  21. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  22. package/dist/builtins/doorbell/doorbell-composition-migration.d.ts +1 -13
  23. package/dist/builtins/doorbell/virtual-doorbell.addon.js +6 -64
  24. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +5 -63
  25. package/dist/builtins/hub-forwarder/index.js +1 -1
  26. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  27. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  28. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  29. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  30. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  31. package/dist/builtins/local-network/local-network.addon.js +1 -1
  32. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  33. package/dist/builtins/loki-logging/index.js +1 -1
  34. package/dist/builtins/loki-logging/index.mjs +1 -1
  35. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  36. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  37. package/dist/builtins/platform-probe/index.js +1 -1
  38. package/dist/builtins/platform-probe/index.mjs +1 -1
  39. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  40. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  41. package/dist/builtins/snapshot/index.js +1 -1
  42. package/dist/builtins/snapshot/index.mjs +1 -1
  43. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  44. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  45. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  46. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  47. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  48. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  49. package/dist/builtins/system-config/system-config.addon.js +1 -1
  50. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  51. package/dist/builtins/winston-logging/index.js +1 -1
  52. package/dist/builtins/winston-logging/index.mjs +1 -1
  53. package/dist/{composition-sources-CB_xQieM.js → composition-sources-BMyxu0Gs.js} +1 -1
  54. package/dist/{composition-sources-RzhpNlEu.mjs → composition-sources-C7tZE1JO.mjs} +1 -1
  55. package/dist/{dist-DENeGKrn.mjs → dist-BjwcGX3O.mjs} +2083 -1474
  56. package/dist/{dist-D25eq33W.js → dist-DZtw79V-.js} +2100 -1485
  57. package/dist/index.js +1 -1
  58. package/dist/index.mjs +1 -1
  59. package/dist/{retired-settings-keys-iFdgpbfM.mjs → retired-settings-keys-B3mGdOSU.mjs} +1 -1
  60. package/dist/{retired-settings-keys-BH9LRNsg.js → retired-settings-keys-DkLVDf0-.js} +1 -1
  61. package/package.json +1 -1
@@ -701,7 +701,7 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
701
701
  return EventCategory;
702
702
  }({});
703
703
  //#endregion
704
- //#region ../types/dist/sleep-C-v96WpE.mjs
704
+ //#region ../types/dist/sleep-gU6hqv4_.mjs
705
705
  /**
706
706
  * The audio chunk plane's byte format, and the ONE expansion from a coded
707
707
  * window to float samples (D455).
@@ -6066,6 +6066,82 @@ var ArchivedDebugNoteQueryInputSchema = zod.z.object({
6066
6066
  /** Max rows returned, newest-first. */
6067
6067
  limit: zod.z.number().int().min(1).max(1e3).optional()
6068
6068
  });
6069
+ var TrackRelationKindSchema = zod.z.enum(["same-subject", "different-subjects"]);
6070
+ var TrackSideSchema = zod.z.enum(["before", "after"]);
6071
+ var TrackPurityValueSchema = zod.z.enum(["mixed", "not-a-subject"]);
6072
+ var TrackRelationSchema = zod.z.object({
6073
+ id: zod.z.string(),
6074
+ deviceId: zod.z.number().int(),
6075
+ leftTrackId: zod.z.string(),
6076
+ rightTrackId: zod.z.string(),
6077
+ leftSide: TrackSideSchema.nullable(),
6078
+ rightSide: TrackSideSchema.nullable(),
6079
+ relation: TrackRelationKindSchema,
6080
+ presence: zod.z.literal("continuous").nullable(),
6081
+ leftStartedAt: zod.z.number().int().nullable(),
6082
+ rightStartedAt: zod.z.number().int().nullable(),
6083
+ createdAt: zod.z.number().int(),
6084
+ updatedAt: zod.z.number().int(),
6085
+ author: zod.z.string().nullable()
6086
+ });
6087
+ var TrackPuritySchema = zod.z.object({
6088
+ id: zod.z.string(),
6089
+ deviceId: zod.z.number().int(),
6090
+ sourceTrackId: zod.z.string(),
6091
+ purity: TrackPurityValueSchema,
6092
+ switchAtMs: zod.z.number().int().nullable(),
6093
+ startedAt: zod.z.number().int().nullable(),
6094
+ createdAt: zod.z.number().int(),
6095
+ updatedAt: zod.z.number().int(),
6096
+ author: zod.z.string().nullable()
6097
+ });
6098
+ var SetTrackRelationInputSchema = zod.z.object({
6099
+ deviceId: zod.z.number().int(),
6100
+ trackId: zod.z.string().min(1),
6101
+ otherTrackId: zod.z.string().min(1),
6102
+ relation: TrackRelationKindSchema,
6103
+ presence: zod.z.literal("continuous").optional(),
6104
+ trackSide: TrackSideSchema.optional(),
6105
+ otherSide: TrackSideSchema.optional()
6106
+ });
6107
+ var ClearTrackRelationInputSchema = zod.z.object({
6108
+ deviceId: zod.z.number().int(),
6109
+ trackId: zod.z.string().min(1),
6110
+ otherTrackId: zod.z.string().min(1)
6111
+ });
6112
+ var ClearTrackRelationResultSchema = zod.z.object({ cleared: zod.z.boolean() });
6113
+ var SetTrackPurityInputSchema = zod.z.object({
6114
+ deviceId: zod.z.number().int(),
6115
+ trackId: zod.z.string().min(1),
6116
+ purity: zod.z.enum([
6117
+ "pure",
6118
+ "mixed",
6119
+ "not-a-subject"
6120
+ ]),
6121
+ switchAtMs: zod.z.number().int().optional()
6122
+ });
6123
+ /** `null` when `pure` cleared the row. */
6124
+ var SetTrackPurityResultSchema = zod.z.object({ purity: TrackPuritySchema.nullable() });
6125
+ var ListTrackRelationsInputSchema = zod.z.object({
6126
+ deviceId: zod.z.number().int(),
6127
+ trackId: zod.z.string().optional(),
6128
+ since: zod.z.number().int().optional(),
6129
+ until: zod.z.number().int().optional(),
6130
+ limit: zod.z.number().int().min(1).max(5e3).optional()
6131
+ });
6132
+ var ListTrackRelationsResultSchema = zod.z.object({
6133
+ relations: zod.z.array(TrackRelationSchema).readonly(),
6134
+ purities: zod.z.array(TrackPuritySchema).readonly(),
6135
+ truncated: zod.z.boolean()
6136
+ });
6137
+ zod.z.enum([
6138
+ "unknown-track",
6139
+ "cross-device",
6140
+ "self-relation",
6141
+ "contradiction",
6142
+ "switch-outside-track",
6143
+ "switch-needs-mixed"
6144
+ ]);
6069
6145
  /** Friendly display labels for stream quality IDs. */
6070
6146
  var STREAM_QUALITY_LABELS = {
6071
6147
  high: "High",
@@ -11717,259 +11793,1152 @@ var CompositionBlockStateSchema = zod.z.object({
11717
11793
  reason: zod.z.string().optional(),
11718
11794
  fields: zod.z.array(CompositionFieldStateSchema)
11719
11795
  });
11796
+ function problem(code, path, message) {
11797
+ return {
11798
+ code,
11799
+ path,
11800
+ message
11801
+ };
11802
+ }
11803
+ function refused(...problems) {
11804
+ return {
11805
+ problems,
11806
+ fields: [],
11807
+ features: [],
11808
+ events: []
11809
+ };
11810
+ }
11811
+ /** Where a planned field sits in the composition: `features[N].fields.<path>` or `features[N].items.<key>.fields.<path>`. */
11812
+ function compositionFieldAt(field) {
11813
+ const inner = field.item === void 0 ? "fields" : `items.${field.item.key}.fields`;
11814
+ return `features[${field.featureIndex}].${inner}.${field.path}`;
11815
+ }
11816
+ var NUMBER = {
11817
+ kind: "fixed",
11818
+ result: ["number"]
11819
+ };
11820
+ var BOOLEAN = {
11821
+ kind: "fixed",
11822
+ result: ["boolean"]
11823
+ };
11824
+ /** One rule per builtin. `expression-kinds.spec.ts` fails for a builtin missing here. */
11825
+ var BUILTIN_RESULT_KINDS = Object.freeze({
11826
+ min: NUMBER,
11827
+ max: NUMBER,
11828
+ abs: NUMBER,
11829
+ floor: NUMBER,
11830
+ ceil: NUMBER,
11831
+ sqrt: NUMBER,
11832
+ round: NUMBER,
11833
+ pow: NUMBER,
11834
+ clamp: NUMBER,
11835
+ avg: NUMBER,
11836
+ sum: NUMBER,
11837
+ age: NUMBER,
11838
+ convert: NUMBER,
11839
+ coalesce: { kind: "coalesce" },
11840
+ any: BOOLEAN,
11841
+ all: BOOLEAN,
11842
+ within: BOOLEAN,
11843
+ latest: {
11844
+ kind: "fixed",
11845
+ result: ["number", "null"]
11846
+ },
11847
+ number: {
11848
+ kind: "fixed",
11849
+ result: ["number", "null"]
11850
+ },
11851
+ rose: {
11852
+ kind: "fixed",
11853
+ result: ["number", "null"]
11854
+ },
11855
+ count: NUMBER
11856
+ });
11857
+ var KindInferenceError = class extends Error {};
11858
+ function kindOfValue(value) {
11859
+ if (value === null) return "null";
11860
+ if (typeof value === "number") return "number";
11861
+ if (typeof value === "string") return "string";
11862
+ return "boolean";
11863
+ }
11864
+ function only(kind) {
11865
+ return new Set([kind]);
11866
+ }
11867
+ function union(...sets) {
11868
+ return new Set(sets.flatMap((s) => [...s]));
11869
+ }
11870
+ function withoutNull(set) {
11871
+ return new Set([...set].filter((k) => k !== "null"));
11872
+ }
11873
+ function binaryKinds(op, left, right) {
11874
+ if (op === "+") {
11875
+ const all = [...left, ...right];
11876
+ if (all.every((k) => k === "string")) return only("string");
11877
+ if (all.every((k) => k === "number")) return only("number");
11878
+ return new Set(["number", "string"]);
11879
+ }
11880
+ if (op === "-" || op === "*" || op === "/" || op === "%") return only("number");
11881
+ return only("boolean");
11882
+ }
11883
+ /** "exactly N" / "at least N" / "N–M", for an arity refusal that says what IS allowed. */
11884
+ function arityRange(builtin) {
11885
+ if (builtin.minArgs === builtin.maxArgs) return `exactly ${builtin.minArgs}`;
11886
+ if (builtin.maxArgs === Number.POSITIVE_INFINITY) return `at least ${builtin.minArgs}`;
11887
+ return `${builtin.minArgs}–${builtin.maxArgs}`;
11888
+ }
11889
+ function callKinds(callee, args) {
11890
+ const builtin = EXPRESSION_BUILTINS[callee];
11891
+ if (builtin !== void 0 && (args.length < builtin.minArgs || args.length > builtin.maxArgs)) throw new KindInferenceError(`${callee}: wrong number of arguments (${args.length}); expected ${arityRange(builtin)}`);
11892
+ const rule = BUILTIN_RESULT_KINDS[callee];
11893
+ if (rule === void 0) throw new KindInferenceError(`no result-kind rule for builtin '${callee}'`);
11894
+ if (rule.kind === "fixed") return new Set(rule.result);
11895
+ const nonNull = union(...args.map(withoutNull));
11896
+ return args.every((a) => a.has("null")) ? union(nonNull, only("null")) : nonNull;
11897
+ }
11898
+ function kindsOf(node, bindings) {
11899
+ switch (node.kind) {
11900
+ case "literal": return only(kindOfValue(node.value));
11901
+ case "identifier": {
11902
+ if (node.name === "now") return only("number");
11903
+ const bound = bindings.get(node.name);
11904
+ if (bound === void 0) throw new KindInferenceError(`unbound identifier '${node.name}'`);
11905
+ return bound;
11906
+ }
11907
+ case "unary": return only(node.op === "!" ? "boolean" : "number");
11908
+ case "binary": return binaryKinds(node.op, kindsOf(node.left, bindings), kindsOf(node.right, bindings));
11909
+ case "logical": return union(kindsOf(node.left, bindings), kindsOf(node.right, bindings));
11910
+ case "conditional": return union(kindsOf(node.consequent, bindings), kindsOf(node.alternate, bindings));
11911
+ case "call": return callKinds(node.callee, node.args.map((a) => kindsOf(a, bindings)));
11912
+ }
11913
+ }
11914
+ function nodeContainsCall(node, callee) {
11915
+ switch (node.kind) {
11916
+ case "literal":
11917
+ case "identifier": return false;
11918
+ case "unary": return nodeContainsCall(node.operand, callee);
11919
+ case "binary":
11920
+ case "logical": return nodeContainsCall(node.left, callee) || nodeContainsCall(node.right, callee);
11921
+ case "conditional": return nodeContainsCall(node.test, callee) || nodeContainsCall(node.consequent, callee) || nodeContainsCall(node.alternate, callee);
11922
+ case "call": return node.callee === callee || node.args.some((arg) => nodeContainsCall(arg, callee));
11923
+ }
11924
+ }
11925
+ function expressionContainsBuiltinCall(expr, callee) {
11926
+ const compiled = compileExpressionSafe(expr);
11927
+ return compiled.ok && nodeContainsCall(compiled.parsed.ast, callee);
11928
+ }
11929
+ function inferExpressionKinds(input) {
11930
+ const compiled = compileExpressionSafe(input.expr);
11931
+ if (!compiled.ok) return {
11932
+ ok: false,
11933
+ error: compiled.error
11934
+ };
11935
+ try {
11936
+ return {
11937
+ ok: true,
11938
+ kinds: kindsOf(compiled.parsed.ast, input.bindingKinds)
11939
+ };
11940
+ } catch (err) {
11941
+ if (err instanceof KindInferenceError) return {
11942
+ ok: false,
11943
+ error: err.message
11944
+ };
11945
+ throw err;
11946
+ }
11947
+ }
11720
11948
  /**
11721
- * core-blocks — user-authored TypeScript, stored in the kernel and executed in
11722
- * its own process.
11723
- *
11724
- * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
11725
- *
11726
- * The first use is **owning devices without being a device provider**: a block
11727
- * declares devices under a system or custom integration and drives their state,
11728
- * with the same `ctx` an addon gets. Automations come later; nothing here
11729
- * models a trigger.
11730
- *
11731
- * **Stated plainly, because it does not change by being true:** a block has an
11732
- * addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
11733
- * with no review step. What makes that survivable is not a sandbox, it is
11734
- * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
11735
- * so a block that throws or never returns is marked `failed` and visible
11736
- * instead of taking the hub with it (D6). Every method here is admin-only, and
11737
- * must stay so.
11738
- */
11739
- /**
11740
- * Prefix for a block's generated addon id AND its runner id — one addon, one
11741
- * process, so the two are the same string (D2).
11742
- *
11743
- * It lives on the CONTRACT rather than beside the code that materialises the
11744
- * package, because it is not an implementation detail: the operator surface
11745
- * reads a block's logs by asking `logs.query` for `addonId = core-block-<id>`,
11746
- * with no cap and no store of its own. Two copies of this string is one copy
11747
- * too many — a drift would show as a logs pane that is simply always empty.
11748
- */
11749
- var CORE_BLOCK_ADDON_PREFIX = "core-block-";
11750
- /**
11751
- * The builtin's own addon id — and, because of that, the OWNER of the
11752
- * integration every block's devices hang from.
11753
- *
11754
- * A block cannot own one. Every integration read is gated on the installed-addon
11755
- * set (`addon-registry.service.ts` → `createFilteredRegistry`) and a block's
11756
- * `core-block-<uuid>` is never in it, so its `getIntegrationByAddonId` answers
11757
- * null forever while the write succeeds — one invisible, undeletable row per
11758
- * start. `core-blocks` IS installed, so it reconciles one shared integration and
11759
- * a block names it by id (`DeclaredDevicesSpec.integrationId`), minting nothing.
11760
- *
11761
- * Exported from the contract, next to the runner prefix and for the same reason:
11762
- * a block reads it as
11763
- * `ctx.api.integrations.getByAddonId.query({ addonId: CORE_BLOCKS_ADDON_ID })`,
11764
- * so a second copy of the string is a block that silently declares into nowhere.
11949
+ * The kind half of planning a composition field (D659): which kinds a source
11950
+ * can produce, whether they fit a target field, and the field's role.
11951
+ * Shared by the top-level planner (`validate-composition.ts`) and the item
11952
+ * planner (`composition-items.ts`). Internal to the composition module — NOT
11953
+ * in the barrel: these are planner internals, not a published contract.
11765
11954
  */
11766
- var CORE_BLOCKS_ADDON_ID = "core-blocks";
11767
- /** The addon/runner id a block's process runs under. */
11768
- function coreBlockAddonId(blockId) {
11769
- return `${CORE_BLOCK_ADDON_PREFIX}${blockId}`;
11955
+ function readKinds(read, lookupCap) {
11956
+ const def = lookupCap(read.cap);
11957
+ const schema = def?.runtimeState ?? def?.status?.schema;
11958
+ if (def === null || schema === void 0) return {
11959
+ ok: false,
11960
+ code: "source-field-unknown",
11961
+ message: `no capability \`${read.cap}\` with state to read`
11962
+ };
11963
+ const field = describeStateField(schema, read.fieldPath);
11964
+ if (field === null) return {
11965
+ ok: false,
11966
+ code: "source-field-unknown",
11967
+ message: `\`${read.cap}\` has no field \`${read.fieldPath}\``
11968
+ };
11969
+ const base = field.kind === "enum" ? "string" : field.kind;
11970
+ return {
11971
+ ok: true,
11972
+ kinds: new Set(field.nullable ? [base, "null"] : [base])
11973
+ };
11770
11974
  }
11771
- /** The block id behind a generated addon/runner id, or null when the id belongs
11772
- * to something else. The inverse of {@link coreBlockAddonId}. */
11773
- function coreBlockIdFromAddonId(addonId) {
11774
- if (!addonId.startsWith("core-block-")) return null;
11775
- const rest = addonId.slice(11);
11776
- return rest.length > 0 ? rest : null;
11975
+ function sourceKinds(source, lookupCap) {
11976
+ if (source.kind === "from") return readKinds(source, lookupCap);
11977
+ if (source.kind === "code") return {
11978
+ ok: false,
11979
+ code: "reserved-variant",
11980
+ message: "code fields arrive in slice 4"
11981
+ };
11982
+ const bindingKinds = /* @__PURE__ */ new Map();
11983
+ for (const [name, binding] of Object.entries(source.bindings)) {
11984
+ if (binding.kind === "literal") {
11985
+ bindingKinds.set(name, new Set([kindOfValue(binding.value)]));
11986
+ continue;
11987
+ }
11988
+ const kinds = readKinds(binding, lookupCap);
11989
+ if (!kinds.ok) return {
11990
+ ...kinds,
11991
+ message: `binding \`${name}\`: ${kinds.message}`
11992
+ };
11993
+ bindingKinds.set(name, kinds.kinds);
11994
+ }
11995
+ const inferred = inferExpressionKinds({
11996
+ expr: source.expr,
11997
+ bindingKinds
11998
+ });
11999
+ return inferred.ok ? {
12000
+ ok: true,
12001
+ kinds: inferred.kinds
12002
+ } : {
12003
+ ok: false,
12004
+ code: "expression-invalid",
12005
+ message: inferred.error
12006
+ };
12007
+ }
12008
+ function compatibility(cap, target, kinds, at) {
12009
+ const allowed = target.kind === "enum" ? "string" : target.kind;
12010
+ const offending = [...kinds].filter((k) => k !== "null" && k !== allowed);
12011
+ const problems = [];
12012
+ if (offending.length > 0) problems.push(problem("kind-mismatch", at, `\`${cap}.${target.path}\` is a ${target.kind}; this source can produce ${offending.join(" or ")}`));
12013
+ if (kinds.has("null") && !target.nullable) problems.push(problem("may-be-null", at, `\`${cap}.${target.path}\` cannot be null and this source can produce null — wrap it in coalesce(…) or pick a non-null source`));
12014
+ return problems;
11777
12015
  }
11778
- /** Where a block runs. The operator chooses — a block driving a device on an
11779
- * agent is the reason placement is not fixed to the hub. */
11780
- var CoreBlockPlacementSchema = zod.z.union([zod.z.literal("hub"), zod.z.string().min(1)]);
11781
- /** What a block's process is doing. Mirrors the addon runner's own lifecycle so
11782
- * a failing block reads the same way a failing addon does. `degraded` is a
11783
- * composition on an existing device with a field handed back to the native
11784
- * provider while its source is unavailable (S1b, D663); the error names it. */
11785
- var CoreBlockStatusSchema = zod.z.enum([
11786
- "stopped",
11787
- "starting",
11788
- "running",
11789
- "degraded",
11790
- "failed"
11791
- ]);
11792
- /** Client-authored fields. */
11793
- var CoreBlockInputSchema = zod.z.object({
11794
- name: zod.z.string().min(1).max(120),
11795
- /** TypeScript source. Compiled server-side before it is ever stored — a
11796
- * block that does not compile is a fork failure the operator would meet
11797
- * minutes later, in a log, instead of in the editor. */
11798
- code: zod.z.string().max(2e5),
11799
- enabled: zod.z.boolean(),
11800
- placement: CoreBlockPlacementSchema,
11801
- /**
11802
- * Integration the block's devices hang from. Absent = the system integration
11803
- * blocks share. A block may declare its own instead.
11804
- */
11805
- integrationId: zod.z.string().optional()
11806
- });
11807
12016
  /**
11808
- * What a block IS (D659). Absent means `code` — every row stored before this
11809
- * union keeps working unchanged.
11810
- * - `code`: the TypeScript in `code`, compiled and run in its own runner;
11811
- * - `recipe`: RESERVED (automations), stored shape only; refused at run by name;
11812
- * - `composition`: declarative, run by the shared `composer`; `code` is `''`.
12017
+ * A string a source produces WITHOUT reading any device — a field-level
12018
+ * `expression` source whose compiled AST is a bare string literal (`"red"`,
12019
+ * never `a` or `a + "x"`). Anything else that can only produce `'string'` at
12020
+ * the kind level (a `from` read of an enum-typed source field, a computed
12021
+ * expression) is NOT checked against the target's `enumValues` here: the
12022
+ * kind inference already collapsed it to the coarse `'string'` kind, losing
12023
+ * whichever enum it actually came from, so membership can only be judged at
12024
+ * write time against the runtime-state schema. Known limitation — see the
12025
+ * fix report for Task 6 round 1.
11813
12026
  */
11814
- var CoreBlockSourceSchema = zod.z.discriminatedUnion("kind", [
11815
- zod.z.object({ kind: zod.z.literal("code") }),
11816
- zod.z.object({
11817
- kind: zod.z.literal("recipe"),
11818
- recipe: AutomationRecipeSchema
11819
- }),
11820
- zod.z.object({
11821
- kind: zod.z.literal("composition"),
11822
- composition: CompositionSchema
11823
- })
11824
- ]);
12027
+ function literalStringValue(source) {
12028
+ if (source.kind !== "expression") return null;
12029
+ const compiled = compileExpressionSafe(source.expr);
12030
+ if (!compiled.ok) return null;
12031
+ const ast = compiled.parsed.ast;
12032
+ return ast.kind === "literal" && typeof ast.value === "string" ? ast.value : null;
12033
+ }
11825
12034
  /**
11826
- * The client-authored half of a composition block. No `code`, no placement (hub-only; Phase 7).
11827
- *
11828
- * `name`: REQUIRED for a `new` target — the block's name IS the device's name
11829
- * (D659). ABSENT for an `existing` target — a customization has no name of its
11830
- * own; the server derives one (`customizationBlockName`, D663) and refuses a
11831
- * client-sent name by name.
12035
+ * An enum target whose source is a save-time-KNOWN string literal outside its
12036
+ * `enumValues` is refused now rather than left to fail the write later. A
12037
+ * dynamic source (a `from` read, a non-literal expression) is left to
12038
+ * runtime — see {@link literalStringValue}.
11832
12039
  */
11833
- var CompositionBlockInputSchema = zod.z.object({
11834
- name: zod.z.string().min(1).max(120).optional(),
11835
- enabled: zod.z.boolean(),
11836
- composition: CompositionSchema
11837
- });
11838
- /** A stored block. */
11839
- var CoreBlockSchema = CoreBlockInputSchema.extend({
11840
- id: zod.z.string(),
11841
- createdAt: zod.z.number(),
11842
- updatedAt: zod.z.number(),
11843
- /** Server-stamped author. */
11844
- createdBy: zod.z.string(),
11845
- status: CoreBlockStatusSchema,
11846
- /**
11847
- * Why the block is not running, when it is not. The operator's ONLY window
11848
- * into a block that failed at load — a block that is silently absent is the
11849
- * failure mode this whole feature has to avoid.
11850
- */
11851
- lastError: zod.z.string().optional(),
11852
- /** Ms epoch of the last state change. */
11853
- lastChangedAt: zod.z.number(),
11854
- /** What the block is. Absent means `code` (D659). */
11855
- source: CoreBlockSourceSchema.optional()
11856
- });
11857
- function coreBlockSourceKind(block) {
11858
- return block.source?.kind ?? "code";
12040
+ function enumLiteralProblem(cap, target, source, at) {
12041
+ if (target.kind !== "enum" || target.enumValues === void 0) return null;
12042
+ const value = literalStringValue(source);
12043
+ if (value === null || target.enumValues.includes(value)) return null;
12044
+ const allowed = target.enumValues.map((v) => `\`${v}\``).join(", ");
12045
+ return problem("kind-mismatch", at, `\`${cap}.${target.path}\` accepts only ${allowed}; this source is the literal \`${value}\``);
11859
12046
  }
11860
- function coreBlockComposition(block) {
11861
- return block.source?.kind === "composition" ? block.source.composition : null;
12047
+ function roleOf(source) {
12048
+ if (source.kind !== "expression") return "derived";
12049
+ if (expressionContainsBuiltinCall(source.expr, "count")) return "accumulator";
12050
+ if (fieldReads(source).length > 0) return "derived";
12051
+ const compiled = compileExpressionSafe(source.expr);
12052
+ return compiled.ok && compiled.parsed.identifiers.has("now") ? "clock" : "constant";
11862
12053
  }
11863
- /** What the composer said, or that it could not be asked. Unknown never looks like absent (D315). */
11864
- var CompositionStateReadSchema = zod.z.discriminatedUnion("kind", [zod.z.object({
11865
- kind: zod.z.literal("answered"),
11866
- state: CompositionBlockStateSchema.nullable()
11867
- }), zod.z.object({
11868
- kind: zod.z.literal("unreachable"),
11869
- error: zod.z.string()
11870
- })]);
11871
- /** A stored composition block and what the composer says about it. */
11872
- var CompositionViewSchema = zod.z.object({
11873
- block: CoreBlockSchema,
11874
- state: CompositionStateReadSchema
11875
- });
11876
12054
  /**
11877
- * A read of the stored compositions. `not-loaded` while the `core_blocks`
11878
- * table has not been READ: an empty answer then would make "not yet known"
11879
- * look like "none", and invite the operator to recreate what exists (D315).
12055
+ * How a planned field says "unavailable", per feature mode and AFTER `roleOf`
12056
+ * (D393, D663 rulings S1 + S1b). The composer cannot take a device it does not
12057
+ * own offline, and D393 forbids a stale number, so on an existing target a
12058
+ * field that READS a source never gets `offline`.
11880
12059
  */
11881
- var CompositionsNotLoadedSchema = zod.z.object({
11882
- state: zod.z.literal("not-loaded"),
11883
- reason: zod.z.string()
11884
- });
11885
- var CompositionListReadSchema = zod.z.discriminatedUnion("state", [zod.z.object({
11886
- state: zod.z.literal("loaded"),
11887
- views: zod.z.array(CompositionViewSchema)
11888
- }), CompositionsNotLoadedSchema]);
11889
- var CompositionGetReadSchema = zod.z.discriminatedUnion("state", [zod.z.object({
11890
- state: zod.z.literal("loaded"),
11891
- view: CompositionViewSchema.nullable()
11892
- }), CompositionsNotLoadedSchema]);
11893
- /** The customization of one existing device, found by its TARGET, or `not-loaded` (D315). */
11894
- var CompositionCustomizationReadSchema = zod.z.discriminatedUnion("state", [zod.z.object({
11895
- state: zod.z.literal("loaded"),
11896
- view: CompositionViewSchema.nullable()
11897
- }), CompositionsNotLoadedSchema]);
11898
- /** What a compile attempt produced. */
11899
- var CoreBlockCompileResultSchema = zod.z.object({
11900
- ok: zod.z.boolean(),
11901
- /** Present when `ok` is false — the first error, in the author's words. */
11902
- error: zod.z.string().optional(),
11903
- line: zod.z.number().optional(),
11904
- column: zod.z.number().optional()
11905
- });
11906
- var coreBlocksCapability = {
11907
- name: "core-blocks",
11908
- scope: "system",
11909
- mode: "singleton",
11910
- methods: {
11911
- list: method(zod.z.object({}), zod.z.object({ blocks: zod.z.array(CoreBlockSchema) }), { auth: "admin" }),
11912
- get: method(zod.z.object({ blockId: zod.z.string() }), zod.z.object({ block: CoreBlockSchema.nullable() }), { auth: "admin" }),
11913
- /**
11914
- * Create a block. The code is COMPILED first: storing source that does not
11915
- * compile turns an editor error into a fork failure the operator meets
11916
- * minutes later in a log.
11917
- */
11918
- create: method(zod.z.object({ block: CoreBlockInputSchema }), zod.z.object({ block: CoreBlockSchema }), {
11919
- kind: "mutation",
11920
- auth: "admin",
11921
- caller: "required"
11922
- }),
11923
- update: method(zod.z.object({
11924
- blockId: zod.z.string(),
11925
- block: CoreBlockInputSchema.partial()
11926
- }), zod.z.object({ block: CoreBlockSchema }), {
11927
- kind: "mutation",
11928
- auth: "admin",
11929
- caller: "required"
11930
- }),
11931
- delete: method(zod.z.object({ blockId: zod.z.string() }), zod.z.object({ success: zod.z.literal(true) }), {
11932
- kind: "mutation",
11933
- auth: "admin"
11934
- }),
11935
- setEnabled: method(zod.z.object({
11936
- blockId: zod.z.string(),
11937
- enabled: zod.z.boolean()
11938
- }), zod.z.object({ block: CoreBlockSchema }), {
11939
- kind: "mutation",
11940
- auth: "admin"
11941
- }),
11942
- /**
11943
- * Stop this block's runner and let the supervisor bring it back.
11944
- *
11945
- * **It stores nothing.** There is no "restart requested" flag and no second
11946
- * lifecycle field: the supervisor already stops and respawns a runner when
11947
- * the code hash changes, and this exposes that same path deliberately
11948
- * (D62 — a switch writes the authority that already owned the function).
11949
- * The next reconcile pass remains the authority, so a restart that lost a
11950
- * race is corrected within one pass rather than leaving a stale flag behind.
11951
- *
11952
- * Refused for a DISABLED block: nothing would come back, and a button that
11953
- * silently does nothing is worse than one that says why.
11954
- */
11955
- restart: method(zod.z.object({ blockId: zod.z.string() }), zod.z.object({ block: CoreBlockSchema }), {
11956
- kind: "mutation",
11957
- auth: "admin"
11958
- }),
11959
- /**
11960
- * Type-check without saving — what the editor calls as the author types, so
11961
- * the compiler's verdict is the same one the server will reach.
11962
- */
11963
- compile: method(zod.z.object({ code: zod.z.string() }), CoreBlockCompileResultSchema, {
11964
- kind: "mutation",
11965
- auth: "admin"
11966
- }),
11967
- /**
11968
- * The declaration files the editor type-checks against.
11969
- *
11970
- * Served rather than bundled: the graph is 3.4 MB across 344 files, and
11971
- * bundling it would roughly double the admin remote. Served rather than
11972
- * hand-stubbed: a block runs with the same `ctx` an addon gets, so a stub
12060
+ function unavailableFor(tf, role, mode, cap, at) {
12061
+ if (mode === "replace" && (role === "derived" || role === "accumulator")) return {
12062
+ ok: true,
12063
+ unavailable: "release"
12064
+ };
12065
+ if (mode === "new" || role !== "derived") return {
12066
+ ok: true,
12067
+ unavailable: tf.nullable ? "null" : "offline"
12068
+ };
12069
+ if (tf.nullable) return {
12070
+ ok: true,
12071
+ unavailable: "null"
12072
+ };
12073
+ if (tf.enumValues?.includes("unknown") === true) return {
12074
+ ok: true,
12075
+ unavailable: "unknown"
12076
+ };
12077
+ return {
12078
+ ok: false,
12079
+ problem: problem("not-replaceable", at, `\`${cap}.${tf.path}\` reads a source but can say neither null nor 'unknown', and an ADDED capability has no native reading to fall back to (D393, D663)`)
12080
+ };
12081
+ }
12082
+ /** Item fields the composer writes itself, with the value it writes. */
12083
+ var COMPOSED_ITEM_FIXED_FIELDS = { resettable: false };
12084
+ function reservedItemFields(itemArray) {
12085
+ return new Set([
12086
+ itemArray.keyField,
12087
+ ...itemArray.labelField === void 0 ? [] : [itemArray.labelField],
12088
+ ...Object.keys(COMPOSED_ITEM_FIXED_FIELDS)
12089
+ ]);
12090
+ }
12091
+ /** A required leaf under an optional parent is required only once something writes that parent. */
12092
+ function leafRequired(itemArray, path, written) {
12093
+ const described = describeStateField(itemArray.itemSchema, path);
12094
+ if (described === null || !described.required) return false;
12095
+ return optionalAncestors(itemArray.itemSchema, path).every((parent) => written.some((w) => w.startsWith(`${parent}.`)));
12096
+ }
12097
+ function missingLeaves({ capName, itemArray, reserved }, item, at) {
12098
+ const written = Object.keys(item.fields);
12099
+ return enumerateSchemaFields(itemArray.itemSchema).flatMap((leaf) => {
12100
+ if (reserved.has(leaf.path) || leaf.path in item.fields) return [];
12101
+ if (!leafRequired(itemArray, leaf.path, written)) return [];
12102
+ const described = describeStateField(itemArray.itemSchema, leaf.path);
12103
+ if (described === null || described.nullable) return [];
12104
+ return [problem("missing-field", `${at}.fields.${leaf.path}`, `\`${capName}.${itemArray.path}[].${leaf.path}\` is required and has no source`)];
12105
+ });
12106
+ }
12107
+ function planItemField(ctx, leaf) {
12108
+ const { capName, itemArray, featureIndex, lookupCap } = ctx;
12109
+ const { key, label, path, source } = leaf;
12110
+ const at = `features[${featureIndex}].items.${key}.fields.${path}`;
12111
+ if (ctx.reserved.has(path)) return refused(problem("item-field-reserved", at, `\`${capName}.${itemArray.path}[].${path}\` is set by the composer itself — it takes no source`));
12112
+ const tf = describeStateField(itemArray.itemSchema, path);
12113
+ if (tf === null) return refused(problem("unknown-field", at, `a \`${capName}\` item has no field \`${path}\``));
12114
+ const kinds = sourceKinds(source, lookupCap);
12115
+ if (!kinds.ok) return refused(problem(kinds.code, at, kinds.message));
12116
+ const role = roleOf(source);
12117
+ const unavailable = unavailableFor(tf, role, ctx.mode, capName, at);
12118
+ if (!unavailable.ok) return refused(unavailable.problem);
12119
+ const plan = {
12120
+ cap: capName,
12121
+ path,
12122
+ item: {
12123
+ arrayPath: itemArray.path,
12124
+ key,
12125
+ label
12126
+ },
12127
+ featureIndex,
12128
+ kind: tf.kind,
12129
+ nullable: tf.nullable,
12130
+ required: tf.required,
12131
+ unavailable: unavailable.unavailable,
12132
+ role,
12133
+ source,
12134
+ reads: [...fieldReads(source)]
12135
+ };
12136
+ const enumProblem = enumLiteralProblem(capName, tf, source, at);
12137
+ return {
12138
+ problems: [...compatibility(capName, tf, kinds.kinds, at), ...enumProblem === null ? [] : [enumProblem]],
12139
+ fields: [plan],
12140
+ features: [],
12141
+ events: []
12142
+ };
12143
+ }
12144
+ function planItems(def, feature, featureIndex, lookupCap, mode = "new") {
12145
+ const at = `features[${featureIndex}].items`;
12146
+ const itemArray = def.status?.itemArray;
12147
+ if (itemArray === void 0) return refused(problem("not-item-array", at, `\`${def.name}\` has no item array — it takes no \`items\``));
12148
+ if (itemArray.path !== "items") return refused(problem("not-item-array", at, `\`${def.name}\`'s item array is at \`${itemArray.path}\`, not \`${COMPOSITION_ITEM_ARRAY_PATH}\` — not composable yet`));
12149
+ const ctx = {
12150
+ capName: def.name,
12151
+ itemArray,
12152
+ reserved: reservedItemFields(itemArray),
12153
+ featureIndex,
12154
+ lookupCap,
12155
+ mode
12156
+ };
12157
+ const results = Object.entries(feature.items ?? {}).flatMap(([key, item]) => [{
12158
+ problems: missingLeaves(ctx, item, `${at}.${key}`),
12159
+ fields: [],
12160
+ features: [],
12161
+ events: []
12162
+ }, ...Object.entries(item.fields).map(([path, source]) => planItemField(ctx, {
12163
+ key,
12164
+ label: item.label,
12165
+ path,
12166
+ source
12167
+ }))]);
12168
+ return {
12169
+ problems: results.flatMap((r) => r.problems),
12170
+ fields: results.flatMap((r) => r.fields),
12171
+ features: [],
12172
+ events: []
12173
+ };
12174
+ }
12175
+ /**
12176
+ * The field graph across every composition, and the cycles in it (D659).
12177
+ *
12178
+ * A node is ONE field of ONE device (`<addon>/<stableId>#<cap>.<field>`); an
12179
+ * edge points from a composed field to each field it reads. A cycle, direct or
12180
+ * through another composition, is refused at save by name (spec §2).
12181
+ *
12182
+ * The graph is built for ONE save: the CANDIDATE composition (the one being
12183
+ * validated) plus every STORED composition. The candidate replaces its own old
12184
+ * version, identified BY BLOCK ID wherever it sits in `stored` — whether a save
12185
+ * introduces or removes a cycle must never depend on array order. A DIFFERENT
12186
+ * block targeting the candidate's device is refused by name: one composition
12187
+ * per device (D663). Two different stored peers that (wrongly) target the same
12188
+ * device are corrupted state, not a cycle to resolve by picking one silently;
12189
+ * that is refused by name too (D391).
12190
+ *
12191
+ * A customization of an EXISTING device reads that device's NATIVE value for a
12192
+ * field it replaces or does not write ("Invert state" reads the `on` it
12193
+ * replaces): such a self-read is no edge, so it is never a cycle (D663).
12194
+ * Another composition reading that field reads the customizer's output.
12195
+ *
12196
+ * `findCompositionCycles` reports at least one cycle per cycle-SET (the DFS
12197
+ * marks a node `done` after its first expansion) — not every elementary cycle
12198
+ * through a shared node, so the path it names may AVOID the candidate while
12199
+ * one through it goes unreported. What a save refuses is therefore decided per
12200
+ * strongly connected component (`findCandidateCycles`, C-1). A dotted
12201
+ * `fieldPath` is matched only as an exact string: a write to `state.value` and
12202
+ * a read of `state` would not connect (no nested field writes exist yet).
12203
+ *
12204
+ * An item leaf is its own node, `…#consumables.items[desiccant].status`
12205
+ * (`featureFieldEntries`), so two items sharing an inner path never merge. No
12206
+ * READ can name an array element (`describeStateField` never describes one),
12207
+ * so an item node is write-only: its edges to sources are what the check needs.
12208
+ */
12209
+ /** Two blocks' compositions target the same device — refused by name, never silently resolved. */
12210
+ var CompositionGraphError = class extends Error {};
12211
+ /** `"cap.field"` of every field a peer writes — the same spelling as `compositionFieldKey`. */
12212
+ function writtenFields(peer) {
12213
+ return new Set(peer.composition.features.flatMap((feature) => !compositionFeatureEnabled(feature) || feature.kind !== "fields" ? [] : featureFieldEntries(feature).map(({ fieldPath }) => `${feature.cap}.${fieldPath}`)));
12214
+ }
12215
+ /** A read of the peer's OWN device is an edge only when it reads the peer's own output (rule 5, D663). */
12216
+ function readsComposedValue(peer, written, read) {
12217
+ if (compositionSourceKey(read.source) !== compositionSourceKey(peer.target)) return true;
12218
+ const field = `${read.cap}.${read.fieldPath}`;
12219
+ return written.has(field) && peer.replaced?.has(field) !== true;
12220
+ }
12221
+ function peerEdges(peer) {
12222
+ const written = writtenFields(peer);
12223
+ return peer.composition.features.flatMap((feature) => !compositionFeatureEnabled(feature) || feature.kind !== "fields" ? [] : featureFieldEntries(feature).map(({ fieldPath, source }) => [compositionReadKey({
12224
+ source: peer.target,
12225
+ cap: feature.cap,
12226
+ fieldPath
12227
+ }), fieldReads(source).filter((read) => readsComposedValue(peer, written, read)).map(compositionReadKey)]));
12228
+ }
12229
+ /**
12230
+ * Every peer that actually takes part in the graph: each stored peer minus the
12231
+ * candidate's own old version (same block id — the drop happens before any
12232
+ * edge is built, so its old edges never enter the map), plus the candidate
12233
+ * itself, exactly once. Throws {@link CompositionGraphError} when a DIFFERENT
12234
+ * block targets the candidate's device, or two stored peers share a device.
12235
+ */
12236
+ function resolvedPeers(candidate, stored) {
12237
+ const candidateKey = compositionSourceKey(candidate.target);
12238
+ const survivors = /* @__PURE__ */ new Map();
12239
+ for (const peer of stored) {
12240
+ if (peer.blockId === candidate.blockId) continue;
12241
+ const key = compositionSourceKey(peer.target);
12242
+ if (key === candidateKey) throw new CompositionGraphError(`block \`${peer.blockId}\` and block \`${candidate.blockId}\` both target device \`${key}\` — one composition per device; delete or edit the existing one`);
12243
+ const clash = survivors.get(key);
12244
+ if (clash !== void 0) throw new CompositionGraphError(`two stored compositions (\`${clash.blockId}\`, \`${peer.blockId}\`) both target device \`${key}\` — refusing to build the field graph`);
12245
+ survivors.set(key, peer);
12246
+ }
12247
+ return [...survivors.values(), candidate];
12248
+ }
12249
+ function compositionEdges(candidate, stored) {
12250
+ const edges = resolvedPeers(candidate, stored).flatMap(peerEdges);
12251
+ return new Map(edges);
12252
+ }
12253
+ /** Tarjan's strongly connected components over every node of the graph (sources included). */
12254
+ function stronglyConnected(edges) {
12255
+ const index = /* @__PURE__ */ new Map();
12256
+ const low = /* @__PURE__ */ new Map();
12257
+ const onStack = /* @__PURE__ */ new Set();
12258
+ const stack = [];
12259
+ const components = [];
12260
+ const connect = (node) => {
12261
+ index.set(node, index.size);
12262
+ low.set(node, index.get(node) ?? 0);
12263
+ stack.push(node);
12264
+ onStack.add(node);
12265
+ for (const next of edges.get(node) ?? []) if (!index.has(next)) {
12266
+ connect(next);
12267
+ low.set(node, Math.min(low.get(node) ?? 0, low.get(next) ?? 0));
12268
+ } else if (onStack.has(next)) low.set(node, Math.min(low.get(node) ?? 0, index.get(next) ?? 0));
12269
+ if (low.get(node) !== index.get(node)) return;
12270
+ const component = [];
12271
+ for (let member = stack.pop(); member !== void 0; member = stack.pop()) {
12272
+ onStack.delete(member);
12273
+ component.push(member);
12274
+ if (member === node) break;
12275
+ }
12276
+ components.push(component);
12277
+ };
12278
+ for (const node of edges.keys()) if (!index.has(node)) connect(node);
12279
+ return components;
12280
+ }
12281
+ /** The shortest walk from `start` back to itself inside `component` (one exists: the component has a cycle). */
12282
+ function cycleThrough(start, component, edges) {
12283
+ const parent = /* @__PURE__ */ new Map();
12284
+ const queue = [start];
12285
+ for (let head = 0; head < queue.length; head += 1) {
12286
+ const node = queue[head];
12287
+ if (node === void 0) break;
12288
+ for (const next of edges.get(node) ?? []) {
12289
+ if (next === start) {
12290
+ const chain = [];
12291
+ for (let at = node; at !== void 0 && at !== start; at = parent.get(at)) chain.push(at);
12292
+ return [
12293
+ start,
12294
+ ...chain.reverse(),
12295
+ start
12296
+ ];
12297
+ }
12298
+ if (!component.has(next) || parent.has(next)) continue;
12299
+ parent.set(next, node);
12300
+ queue.push(next);
12301
+ }
12302
+ }
12303
+ return [start, start];
12304
+ }
12305
+ /**
12306
+ * The cycles THIS save closes, decided per strongly connected component — never
12307
+ * per reported DFS path, which names one back-edge per revisit and can report a
12308
+ * component's cycle by a path that avoids the candidate while the one through it
12309
+ * goes unreported. A component refuses when it holds a candidate node and a
12310
+ * cycle (more than one node, or a self-loop). Components without a candidate
12311
+ * node are not this save's cycles.
12312
+ */
12313
+ function findCandidateCycles(candidate, stored) {
12314
+ const edges = compositionEdges(candidate, stored);
12315
+ const prefix = `${compositionSourceKey(candidate.target)}#`;
12316
+ return stronglyConnected(edges).flatMap((members) => {
12317
+ const start = members.find((n) => n.startsWith(prefix));
12318
+ if (start === void 0) return [];
12319
+ const component = new Set(members);
12320
+ const selfLoop = members.length === 1 && (edges.get(start) ?? []).includes(start);
12321
+ if (members.length === 1 && !selfLoop) return [];
12322
+ return [{
12323
+ path: cycleThrough(start, component, edges),
12324
+ component
12325
+ }];
12326
+ });
12327
+ }
12328
+ function buildCapabilityLookup(defs) {
12329
+ const byName = new Map(defs.map((d) => [d.name, d]));
12330
+ return (capName) => byName.get(capName) ?? null;
12331
+ }
12332
+ /** Caps every `BaseDevice` registers on itself; a second registration throws. */
12333
+ var BASE_DEVICE_CAPS = new Set(["device-status", "feature-probe"]);
12334
+ var PENDING_SELF = {
12335
+ addonId: COMPOSER_ADDON_ID,
12336
+ stableId: `${COMPOSED_DEVICE_STABLE_ID_PREFIX}(unsaved)`
12337
+ };
12338
+ function composableCap(feature, at, lookupCap) {
12339
+ const def = lookupCap(feature.cap);
12340
+ if (def === null) return problem("unknown-capability", `${at}.cap`, `no capability is named \`${feature.cap}\``);
12341
+ if (BASE_DEVICE_CAPS.has(def.name)) return problem("not-composable", `${at}.cap`, `\`${def.name}\` is registered by every device itself — it cannot be composed`);
12342
+ const state = def.runtimeState;
12343
+ if (def.scope !== "device" || state === void 0) return problem("not-composable", `${at}.cap`, `\`${def.name}\` has no per-device state to compose`);
12344
+ return {
12345
+ def,
12346
+ state,
12347
+ fields: describeTopLevelStateFields(state)
12348
+ };
12349
+ }
12350
+ /**
12351
+ * A capability BUILT by the composition (a new device, or one ADDED to an
12352
+ * existing device) must fit the device type, serve no command, and have every
12353
+ * required field. A REPLACE needs none of it: the cap is bound (a fact), the
12354
+ * native keeps serving its methods, and only the named fields are owned (D663).
12355
+ */
12356
+ function builtCapProblems({ def, state, fields: targetFields }, feature, at, deviceType) {
12357
+ const problems = [];
12358
+ if (def.deviceTypes !== void 0 && !def.deviceTypes.includes(deviceType)) problems.push(problem("wrong-device-type", `${at}.cap`, `\`${def.name}\` is not offered on a \`${deviceType}\` device (it allows ${def.deviceTypes.join(", ")})`));
12359
+ const served = Object.entries(def.methods).filter(([name, m]) => m.systemOnly !== true && def.composedMethods?.[name] === void 0).map(([name]) => `\`${name}\``);
12360
+ if (served.length > 0) problems.push(problem("has-commands", `${at}.cap`, `\`${def.name}\` has ${served.join(", ")}, which a composed device cannot serve until commands arrive (slice 3)`));
12361
+ const itemArrayPath = feature.items === void 0 ? null : def.status?.itemArray?.path ?? null;
12362
+ for (const key of requiredTopLevelKeys(state)) {
12363
+ if (key === itemArrayPath) continue;
12364
+ if (!targetFields.has(key)) problems.push(problem("not-composable", `${at}.fields`, `\`${def.name}.${key}\` is not a scalar — nested fields arrive in a later slice`));
12365
+ else if (!(key in feature.fields)) problems.push(problem("missing-field", `${at}.fields.${key}`, `\`${def.name}.${key}\` is required and has no source`));
12366
+ }
12367
+ return problems;
12368
+ }
12369
+ function planTopLevelField({ def, fields }, field, mode, lookupCap) {
12370
+ const { featureIndex, path, source } = field;
12371
+ const fieldAt = `features[${featureIndex}].fields.${path}`;
12372
+ if (path.includes(".")) return refused(problem("nested-field", fieldAt, `\`${path}\` is nested — nested fields arrive in a later slice`));
12373
+ const tf = fields.get(path);
12374
+ if (tf === void 0) return refused(problem("unknown-field", fieldAt, `\`${def.name}\` has no field \`${path}\``));
12375
+ const kinds = sourceKinds(source, lookupCap);
12376
+ if (!kinds.ok) return refused(problem(kinds.code, fieldAt, kinds.message));
12377
+ const role = roleOf(source);
12378
+ const unavailable = unavailableFor(tf, role, mode, def.name, fieldAt);
12379
+ if (!unavailable.ok) return refused(unavailable.problem);
12380
+ const plan = {
12381
+ cap: def.name,
12382
+ path,
12383
+ featureIndex,
12384
+ kind: tf.kind,
12385
+ nullable: tf.nullable,
12386
+ required: tf.required,
12387
+ unavailable: unavailable.unavailable,
12388
+ role,
12389
+ source,
12390
+ reads: [...fieldReads(source)]
12391
+ };
12392
+ const enumProblem = enumLiteralProblem(def.name, tf, source, fieldAt);
12393
+ return {
12394
+ problems: [...compatibility(def.name, tf, kinds.kinds, fieldAt), ...enumProblem === null ? [] : [enumProblem]],
12395
+ fields: [plan],
12396
+ features: [],
12397
+ events: []
12398
+ };
12399
+ }
12400
+ function planFeature(feature, featureIndex, target, lookupCap) {
12401
+ const at = `features[${featureIndex}]`;
12402
+ const cap = composableCap(feature, at, lookupCap);
12403
+ if ("code" in cap) return refused(cap);
12404
+ const capProblems = target.mode === "replace" ? [] : [...builtCapProblems(cap, feature, at, target.deviceType)];
12405
+ if (feature.commands !== void 0 && Object.keys(feature.commands).length > 0) capProblems.push(problem("reserved-variant", `${at}.commands`, "commands (`forward`) arrive in slice 3"));
12406
+ const perField = Object.entries(feature.fields).map(([path, source]) => planTopLevelField(cap, {
12407
+ featureIndex,
12408
+ path,
12409
+ source
12410
+ }, target.mode, lookupCap));
12411
+ const items = feature.items === void 0 ? [] : [planItems(cap.def, feature, featureIndex, lookupCap, target.mode)];
12412
+ const all = [...perField, ...items];
12413
+ const fields = all.flatMap((result) => result.fields);
12414
+ const featurePlan = {
12415
+ featureIndex,
12416
+ cap: cap.def.name,
12417
+ mode: target.mode
12418
+ };
12419
+ const eventPlan = planCompositionEvents(cap.def, featurePlan, fields);
12420
+ return {
12421
+ problems: [
12422
+ ...capProblems,
12423
+ ...all.flatMap((r) => r.problems),
12424
+ ...eventPlan.problems
12425
+ ],
12426
+ fields,
12427
+ features: [featurePlan],
12428
+ events: eventPlan.events
12429
+ };
12430
+ }
12431
+ /** A cap the device serves itself; one the composer grafted (an earlier ADD) is not native. */
12432
+ function isNativeCap(target, cap) {
12433
+ const owner = target.nativeCaps.get(cap);
12434
+ return owner !== void 0 && owner !== "composer";
12435
+ }
12436
+ /** The target read, or the refusal that replaces the whole plan when it could not be (D391). */
12437
+ function planTargetOf(composition, target) {
12438
+ if (composition.target.kind === "new") return {
12439
+ kind: "new",
12440
+ type: composition.target.type
12441
+ };
12442
+ const key = compositionSourceKey(composition.target.device);
12443
+ if (target === void 0) return problem("target-unreadable", "target", `target device \`${key}\` was not read — the save is refused rather than stored unchecked`);
12444
+ if (target.kind === "absent") return problem("target-missing", "target", `target device \`${key}\` does not exist`);
12445
+ if (target.kind === "unreadable") return problem("target-unreadable", "target", `could not read target device \`${key}\` (${target.error}) — the save is refused rather than stored unchecked`);
12446
+ return {
12447
+ kind: "existing",
12448
+ found: target
12449
+ };
12450
+ }
12451
+ function featureTargetOf(target, cap) {
12452
+ if (target.kind === "new") return {
12453
+ mode: "new",
12454
+ deviceType: target.type
12455
+ };
12456
+ return {
12457
+ mode: isNativeCap(target.found, cap) ? "replace" : "add",
12458
+ deviceType: target.found.type
12459
+ };
12460
+ }
12461
+ /**
12462
+ * The static plan. `target` describes an EXISTING target device (one read);
12463
+ * it is ignored for a new one. On an existing target each feature REPLACES a
12464
+ * cap the device has natively, else ADDS it (D663).
12465
+ */
12466
+ function planComposition(composition, lookupCap, target) {
12467
+ const planTarget = planTargetOf(composition, target);
12468
+ if ("code" in planTarget) return refused(planTarget);
12469
+ const results = composition.features.map((feature, index) => {
12470
+ const result = feature.kind === "passthrough" ? refused(problem("reserved-variant", `features[${index}]`, `whole-capability passthrough of \`${feature.cap}\` arrives in slice 3`)) : planFeature(feature, index, featureTargetOf(planTarget, feature.cap), lookupCap);
12471
+ return compositionFeatureEnabled(feature) ? result : {
12472
+ problems: result.problems,
12473
+ fields: [],
12474
+ features: [],
12475
+ events: []
12476
+ };
12477
+ });
12478
+ return {
12479
+ problems: results.flatMap((r) => r.problems),
12480
+ fields: results.flatMap((r) => r.fields),
12481
+ features: results.flatMap((r) => r.features),
12482
+ events: results.flatMap((r) => r.events)
12483
+ };
12484
+ }
12485
+ function compositionSourceRefs(composition) {
12486
+ const refs = composition.features.flatMap((f) => compositionFeatureEnabled(f) && f.kind === "fields" ? featureSources(f).flatMap(fieldReads).map((r) => r.source) : []);
12487
+ return [...new Map(refs.map((r) => [compositionSourceKey(r), r])).values()];
12488
+ }
12489
+ /**
12490
+ * The candidate's device: the existing target itself, else `self`, else a
12491
+ * placeholder that cannot collide with a stored target. An existing target is
12492
+ * known even on create, so a second customization of it is refused on create too.
12493
+ */
12494
+ function candidateSelf(composition, ctx, plan) {
12495
+ return {
12496
+ ref: composition.target.kind === "existing" ? composition.target.device : ctx.self ?? PENDING_SELF,
12497
+ native: composition.target.kind === "existing" && ctx.target?.kind === "found" ? ctx.target : null,
12498
+ replaced: replacedFieldKeys(plan)
12499
+ };
12500
+ }
12501
+ /**
12502
+ * `compositionFieldKey` of every field a REPLACE feature of `plan` owns — the
12503
+ * candidate's own, and a stored peer's `CompositionPeer.replaced` (D663): one
12504
+ * derivation, so the graph reads both the same way.
12505
+ */
12506
+ function replacedFieldKeys(plan) {
12507
+ const replacing = new Set(plan.features.filter((f) => f.mode === "replace").map((f) => f.featureIndex));
12508
+ return new Set(plan.fields.filter((f) => replacing.has(f.featureIndex)).map(compositionFieldKey));
12509
+ }
12510
+ /**
12511
+ * A read of the candidate's own device. Its own composed output (a new device's
12512
+ * field, an ADDED field) is the slice-1 own-field rule; on an existing target
12513
+ * every other field — replaced, or not composed at all — is the NATIVE value,
12514
+ * valid when the device serves that cap natively (D663, rule 5).
12515
+ */
12516
+ function selfReadProblems(read, at, fields, self) {
12517
+ const readKey = `${read.cap}.${read.fieldPath}`;
12518
+ if (fields.some((f) => compositionFieldKey(f) === readKey && !self.replaced.has(readKey))) return [];
12519
+ if (self.native === null) return [problem("source-field-unknown", at, `this device has no composed field \`${read.cap}.${read.fieldPath}\``)];
12520
+ if (isNativeCap(self.native, read.cap)) return [];
12521
+ return [problem("source-capability-missing", at, `the target device (device ${self.native.deviceId}) has no native \`${read.cap}\``)];
12522
+ }
12523
+ function sourceProblems(fields, ctx, self) {
12524
+ const selfKey = compositionSourceKey(self.ref);
12525
+ return fields.flatMap((field) => field.reads.flatMap((read) => {
12526
+ const at = compositionFieldAt(field);
12527
+ const key = compositionSourceKey(read.source);
12528
+ if (key === selfKey) return selfReadProblems(read, at, fields, self);
12529
+ const desc = ctx.sources.get(key);
12530
+ if (desc === void 0 || desc.kind === "unreadable") return [problem("source-unreadable", at, `could not read source \`${key}\` (${desc === void 0 ? "it was not read" : desc.error}) — the save is refused rather than stored unchecked`)];
12531
+ if (desc.kind === "absent") return [problem("source-missing", at, `source \`${key}\` does not exist`)];
12532
+ if (!desc.caps.has(read.cap)) return [problem("source-capability-missing", at, `source \`${key}\` (device ${desc.deviceId}) has no \`${read.cap}\``)];
12533
+ return [];
12534
+ }));
12535
+ }
12536
+ /**
12537
+ * A cycle's node keys are `compositionReadKey` strings:
12538
+ * `<addonId>/<stableId>#<cap>.<fieldPath>`. When a node belongs to the
12539
+ * CANDIDATE itself (this composition being saved), pull its `cap`/`fieldPath`
12540
+ * back out so the refusal can point at that exact field, matching the
12541
+ * documented `features[N].fields.<path>` convention. A cap name has no `.`,
12542
+ * so splitting `rest` on the FIRST `.` is exact; an item leaf's remainder
12543
+ * (`items[desiccant].status`) is matched through `compositionFieldKey`.
12544
+ */
12545
+ function candidateFieldOfNode(nodeKey, candidateKey) {
12546
+ const prefix = `${candidateKey}#`;
12547
+ if (!nodeKey.startsWith(prefix)) return null;
12548
+ const rest = nodeKey.slice(prefix.length);
12549
+ const dot = rest.indexOf(".");
12550
+ if (dot === -1) return null;
12551
+ return {
12552
+ cap: rest.slice(0, dot),
12553
+ fieldPath: rest.slice(dot + 1)
12554
+ };
12555
+ }
12556
+ /** The first node of a cycle's path that is one of THIS composition's own
12557
+ * planned fields, as a documented-convention path; `'features'` when the
12558
+ * cycle runs entirely through other stored compositions. */
12559
+ function cyclePath(nodePath, candidateKey, fields) {
12560
+ for (const nodeKey of nodePath) {
12561
+ const hit = candidateFieldOfNode(nodeKey, candidateKey);
12562
+ if (hit === null) continue;
12563
+ const hitKey = `${hit.cap}.${hit.fieldPath}`;
12564
+ const field = fields.find((f) => compositionFieldKey(f) === hitKey);
12565
+ if (field !== void 0) return compositionFieldAt(field);
12566
+ }
12567
+ return "features";
12568
+ }
12569
+ /** The first unread device any of `nodes` belongs to; null when none does. */
12570
+ function unreadDeviceOf(nodes, unread) {
12571
+ for (const [key, error] of unread) if (nodes.some((node) => node.startsWith(`${key}#`))) return {
12572
+ key,
12573
+ error
12574
+ };
12575
+ return null;
12576
+ }
12577
+ /**
12578
+ * Cycle check runs against the CANDIDATE (this composition, as block
12579
+ * `ctx.blockId`, writing `self.ref`) plus every OTHER stored composition,
12580
+ * decided per strongly connected component (`findCandidateCycles`): a cyclic
12581
+ * component holding a candidate node refuses, with one representative cycle
12582
+ * through it; one without is not this save's cycle. A
12583
+ * stored composition of the same block is its old version; another block on
12584
+ * the same device, or two stored peers sharing a device, make
12585
+ * `compositionEdges` throw `CompositionGraphError` — refused BY NAME as
12586
+ * `duplicate-target` (D391, D663) instead of escaping as an uncaught throw.
12587
+ */
12588
+ function cycleProblems(composition, ctx, fields, self) {
12589
+ const candidate = {
12590
+ blockId: ctx.blockId ?? "(unsaved)",
12591
+ target: self.ref,
12592
+ composition,
12593
+ replaced: self.replaced
12594
+ };
12595
+ const candidateKey = compositionSourceKey(candidate.target);
12596
+ const unread = ctx.unreadTargets ?? /* @__PURE__ */ new Map();
12597
+ try {
12598
+ return findCandidateCycles(candidate, ctx.peers).map((c) => {
12599
+ const at = cyclePath(c.path, candidateKey, fields);
12600
+ const through = unreadDeviceOf([...c.component], unread);
12601
+ return through === null ? problem("cycle", at, `a field reads itself: ${c.path.join(" → ")}`) : problem("cycle", at, `possible cycle through \`${through.key}\` — its state could not be read (${through.error}): ${c.path.join(" → ")}`);
12602
+ });
12603
+ } catch (err) {
12604
+ if (err instanceof CompositionGraphError) return [problem("duplicate-target", "features", err.message)];
12605
+ throw err;
12606
+ }
12607
+ }
12608
+ function validateComposition(composition, ctx) {
12609
+ const plan = planComposition(composition, ctx.lookupCap, ctx.target ?? void 0);
12610
+ const self = candidateSelf(composition, ctx, plan);
12611
+ const cycles = cycleProblems(composition, ctx, plan.fields, self);
12612
+ const problems = [
12613
+ ...plan.problems,
12614
+ ...sourceProblems(plan.fields, ctx, self),
12615
+ ...cycles
12616
+ ];
12617
+ return {
12618
+ ok: problems.length === 0,
12619
+ problems,
12620
+ fields: [...plan.fields],
12621
+ features: [...plan.features],
12622
+ events: [...plan.events]
12623
+ };
12624
+ }
12625
+ /**
12626
+ * What a composition may BUILD on a device type (G2, D676): the caps a new
12627
+ * device of that type, or an existing one that lacks them, can be given. The
12628
+ * blockers are the validator's own refusal codes, and a spec compares this
12629
+ * listing with `planComposition` for every capability — the editor can never
12630
+ * offer a feature the save would refuse for the same reason.
12631
+ */
12632
+ var ComposableFieldSchema = zod.z.object({
12633
+ path: zod.z.string(),
12634
+ kind: CompositionFieldKindSchema,
12635
+ nullable: zod.z.boolean(),
12636
+ required: zod.z.boolean(),
12637
+ enumValues: zod.z.array(zod.z.string()).optional()
12638
+ });
12639
+ var ComposableCapabilitySchema = zod.z.object({
12640
+ cap: zod.z.string(),
12641
+ fields: zod.z.array(ComposableFieldSchema),
12642
+ /** Present for an item-array cap (`consumables.items`). */
12643
+ itemArray: zod.z.object({
12644
+ path: zod.z.string(),
12645
+ keyField: zod.z.string()
12646
+ }).optional()
12647
+ });
12648
+ var ComposableCapabilitiesSchema = zod.z.object({ caps: zod.z.array(ComposableCapabilitySchema) });
12649
+ /** Why a cap cannot be BUILT (new device, or added to an existing one) on `deviceType`; [] = it can. */
12650
+ function builtCapabilityBlockers(def, deviceType) {
12651
+ if (BASE_DEVICE_CAPS.has(def.name)) return ["not-composable"];
12652
+ const state = def.runtimeState;
12653
+ if (def.scope !== "device" || state === void 0) return ["not-composable"];
12654
+ const blockers = [];
12655
+ if (def.deviceTypes !== void 0 && !def.deviceTypes.includes(deviceType)) blockers.push("wrong-device-type");
12656
+ if (Object.entries(def.methods).filter(([name, m]) => m.systemOnly !== true && def.composedMethods?.[name] === void 0).length > 0) blockers.push("has-commands");
12657
+ const fields = describeTopLevelStateFields(state);
12658
+ const itemArrayPath = def.status?.itemArray?.path ?? null;
12659
+ if (requiredTopLevelKeys(state).some((key) => key !== itemArrayPath && !fields.has(key))) blockers.push("not-composable");
12660
+ if (planCompositionEvents(def, {
12661
+ featureIndex: 0,
12662
+ cap: def.name,
12663
+ mode: "new"
12664
+ }, []).problems.some((p) => p.code === "not-composable") && !blockers.includes("not-composable")) blockers.push("not-composable");
12665
+ return blockers;
12666
+ }
12667
+ /** Every cap with no blocker, sorted by name. */
12668
+ function describeComposableCaps(defs, deviceType) {
12669
+ return defs.filter((def) => builtCapabilityBlockers(def, deviceType).length === 0).map((def) => {
12670
+ const state = def.runtimeState;
12671
+ const fields = state === void 0 ? [] : [...describeTopLevelStateFields(state).values()];
12672
+ const itemArray = def.status?.itemArray;
12673
+ return {
12674
+ cap: def.name,
12675
+ fields: fields.map((f) => ({
12676
+ path: f.path,
12677
+ kind: f.kind,
12678
+ nullable: f.nullable,
12679
+ required: f.required,
12680
+ ...f.enumValues === void 0 ? {} : { enumValues: [...f.enumValues] }
12681
+ })),
12682
+ ...itemArray === void 0 ? {} : { itemArray: {
12683
+ path: itemArray.path,
12684
+ keyField: itemArray.keyField
12685
+ } }
12686
+ };
12687
+ }).toSorted((a, b) => a.cap.localeCompare(b.cap));
12688
+ }
12689
+ /**
12690
+ * core-blocks — user-authored TypeScript, stored in the kernel and executed in
12691
+ * its own process.
12692
+ *
12693
+ * Spec: `docs/superpowers/specs/2026-08-04-core-blocks-and-synthetic-devices-design.md`.
12694
+ *
12695
+ * The first use is **owning devices without being a device provider**: a block
12696
+ * declares devices under a system or custom integration and drives their state,
12697
+ * with the same `ctx` an addon gets. Automations come later; nothing here
12698
+ * models a trigger.
12699
+ *
12700
+ * **Stated plainly, because it does not change by being true:** a block has an
12701
+ * addon's powers — devices, storage, the event bus, `ctx.api`. It is a plugin
12702
+ * with no review step. What makes that survivable is not a sandbox, it is
12703
+ * PROCESS ISOLATION: one process per block, supervised by `CrashSupervisor`,
12704
+ * so a block that throws or never returns is marked `failed` and visible
12705
+ * instead of taking the hub with it (D6). Every method here is admin-only, and
12706
+ * must stay so.
12707
+ */
12708
+ /**
12709
+ * Prefix for a block's generated addon id AND its runner id — one addon, one
12710
+ * process, so the two are the same string (D2).
12711
+ *
12712
+ * It lives on the CONTRACT rather than beside the code that materialises the
12713
+ * package, because it is not an implementation detail: the operator surface
12714
+ * reads a block's logs by asking `logs.query` for `addonId = core-block-<id>`,
12715
+ * with no cap and no store of its own. Two copies of this string is one copy
12716
+ * too many — a drift would show as a logs pane that is simply always empty.
12717
+ */
12718
+ var CORE_BLOCK_ADDON_PREFIX = "core-block-";
12719
+ /**
12720
+ * The builtin's own addon id — and, because of that, the OWNER of the
12721
+ * integration every block's devices hang from.
12722
+ *
12723
+ * A block cannot own one. Every integration read is gated on the installed-addon
12724
+ * set (`addon-registry.service.ts` → `createFilteredRegistry`) and a block's
12725
+ * `core-block-<uuid>` is never in it, so its `getIntegrationByAddonId` answers
12726
+ * null forever while the write succeeds — one invisible, undeletable row per
12727
+ * start. `core-blocks` IS installed, so it reconciles one shared integration and
12728
+ * a block names it by id (`DeclaredDevicesSpec.integrationId`), minting nothing.
12729
+ *
12730
+ * Exported from the contract, next to the runner prefix and for the same reason:
12731
+ * a block reads it as
12732
+ * `ctx.api.integrations.getByAddonId.query({ addonId: CORE_BLOCKS_ADDON_ID })`,
12733
+ * so a second copy of the string is a block that silently declares into nowhere.
12734
+ */
12735
+ var CORE_BLOCKS_ADDON_ID = "core-blocks";
12736
+ /** The addon/runner id a block's process runs under. */
12737
+ function coreBlockAddonId(blockId) {
12738
+ return `${CORE_BLOCK_ADDON_PREFIX}${blockId}`;
12739
+ }
12740
+ /** The block id behind a generated addon/runner id, or null when the id belongs
12741
+ * to something else. The inverse of {@link coreBlockAddonId}. */
12742
+ function coreBlockIdFromAddonId(addonId) {
12743
+ if (!addonId.startsWith("core-block-")) return null;
12744
+ const rest = addonId.slice(11);
12745
+ return rest.length > 0 ? rest : null;
12746
+ }
12747
+ /** Where a block runs. The operator chooses — a block driving a device on an
12748
+ * agent is the reason placement is not fixed to the hub. */
12749
+ var CoreBlockPlacementSchema = zod.z.union([zod.z.literal("hub"), zod.z.string().min(1)]);
12750
+ /** What a block's process is doing. Mirrors the addon runner's own lifecycle so
12751
+ * a failing block reads the same way a failing addon does. `degraded` is a
12752
+ * composition on an existing device with a field handed back to the native
12753
+ * provider while its source is unavailable (S1b, D663); the error names it. */
12754
+ var CoreBlockStatusSchema = zod.z.enum([
12755
+ "stopped",
12756
+ "starting",
12757
+ "running",
12758
+ "degraded",
12759
+ "failed"
12760
+ ]);
12761
+ /** Client-authored fields. */
12762
+ var CoreBlockInputSchema = zod.z.object({
12763
+ name: zod.z.string().min(1).max(120),
12764
+ /** TypeScript source. Compiled server-side before it is ever stored — a
12765
+ * block that does not compile is a fork failure the operator would meet
12766
+ * minutes later, in a log, instead of in the editor. */
12767
+ code: zod.z.string().max(2e5),
12768
+ enabled: zod.z.boolean(),
12769
+ placement: CoreBlockPlacementSchema,
12770
+ /**
12771
+ * Integration the block's devices hang from. Absent = the system integration
12772
+ * blocks share. A block may declare its own instead.
12773
+ */
12774
+ integrationId: zod.z.string().optional()
12775
+ });
12776
+ /**
12777
+ * What a block IS (D659). Absent means `code` — every row stored before this
12778
+ * union keeps working unchanged.
12779
+ * - `code`: the TypeScript in `code`, compiled and run in its own runner;
12780
+ * - `recipe`: RESERVED (automations), stored shape only; refused at run by name;
12781
+ * - `composition`: declarative, run by the shared `composer`; `code` is `''`.
12782
+ */
12783
+ var CoreBlockSourceSchema = zod.z.discriminatedUnion("kind", [
12784
+ zod.z.object({ kind: zod.z.literal("code") }),
12785
+ zod.z.object({
12786
+ kind: zod.z.literal("recipe"),
12787
+ recipe: AutomationRecipeSchema
12788
+ }),
12789
+ zod.z.object({
12790
+ kind: zod.z.literal("composition"),
12791
+ composition: CompositionSchema
12792
+ })
12793
+ ]);
12794
+ /**
12795
+ * The client-authored half of a composition block. No `code`, no placement (hub-only; Phase 7).
12796
+ *
12797
+ * `name`: REQUIRED for a `new` target — the block's name IS the device's name
12798
+ * (D659). ABSENT for an `existing` target — a customization has no name of its
12799
+ * own; the server derives one (`customizationBlockName`, D663) and refuses a
12800
+ * client-sent name by name.
12801
+ */
12802
+ var CompositionBlockInputSchema = zod.z.object({
12803
+ name: zod.z.string().min(1).max(120).optional(),
12804
+ enabled: zod.z.boolean(),
12805
+ composition: CompositionSchema
12806
+ });
12807
+ /** A stored block. */
12808
+ var CoreBlockSchema = CoreBlockInputSchema.extend({
12809
+ id: zod.z.string(),
12810
+ createdAt: zod.z.number(),
12811
+ updatedAt: zod.z.number(),
12812
+ /** Server-stamped author. */
12813
+ createdBy: zod.z.string(),
12814
+ status: CoreBlockStatusSchema,
12815
+ /**
12816
+ * Why the block is not running, when it is not. The operator's ONLY window
12817
+ * into a block that failed at load — a block that is silently absent is the
12818
+ * failure mode this whole feature has to avoid.
12819
+ */
12820
+ lastError: zod.z.string().optional(),
12821
+ /** Ms epoch of the last state change. */
12822
+ lastChangedAt: zod.z.number(),
12823
+ /** What the block is. Absent means `code` (D659). */
12824
+ source: CoreBlockSourceSchema.optional()
12825
+ });
12826
+ function coreBlockSourceKind(block) {
12827
+ return block.source?.kind ?? "code";
12828
+ }
12829
+ function coreBlockComposition(block) {
12830
+ return block.source?.kind === "composition" ? block.source.composition : null;
12831
+ }
12832
+ /** What the composer said, or that it could not be asked. Unknown never looks like absent (D315). */
12833
+ var CompositionStateReadSchema = zod.z.discriminatedUnion("kind", [zod.z.object({
12834
+ kind: zod.z.literal("answered"),
12835
+ state: CompositionBlockStateSchema.nullable()
12836
+ }), zod.z.object({
12837
+ kind: zod.z.literal("unreachable"),
12838
+ error: zod.z.string()
12839
+ })]);
12840
+ /** A stored composition block and what the composer says about it. */
12841
+ var CompositionViewSchema = zod.z.object({
12842
+ block: CoreBlockSchema,
12843
+ state: CompositionStateReadSchema
12844
+ });
12845
+ /**
12846
+ * A read of the stored compositions. `not-loaded` while the `core_blocks`
12847
+ * table has not been READ: an empty answer then would make "not yet known"
12848
+ * look like "none", and invite the operator to recreate what exists (D315).
12849
+ */
12850
+ var CompositionsNotLoadedSchema = zod.z.object({
12851
+ state: zod.z.literal("not-loaded"),
12852
+ reason: zod.z.string()
12853
+ });
12854
+ var CompositionListReadSchema = zod.z.discriminatedUnion("state", [zod.z.object({
12855
+ state: zod.z.literal("loaded"),
12856
+ views: zod.z.array(CompositionViewSchema)
12857
+ }), CompositionsNotLoadedSchema]);
12858
+ var CompositionGetReadSchema = zod.z.discriminatedUnion("state", [zod.z.object({
12859
+ state: zod.z.literal("loaded"),
12860
+ view: CompositionViewSchema.nullable()
12861
+ }), CompositionsNotLoadedSchema]);
12862
+ /** The customization of one existing device, found by its TARGET, or `not-loaded` (D315). */
12863
+ var CompositionCustomizationReadSchema = zod.z.discriminatedUnion("state", [zod.z.object({
12864
+ state: zod.z.literal("loaded"),
12865
+ view: CompositionViewSchema.nullable()
12866
+ }), CompositionsNotLoadedSchema]);
12867
+ /** What a compile attempt produced. */
12868
+ var CoreBlockCompileResultSchema = zod.z.object({
12869
+ ok: zod.z.boolean(),
12870
+ /** Present when `ok` is false — the first error, in the author's words. */
12871
+ error: zod.z.string().optional(),
12872
+ line: zod.z.number().optional(),
12873
+ column: zod.z.number().optional()
12874
+ });
12875
+ var coreBlocksCapability = {
12876
+ name: "core-blocks",
12877
+ scope: "system",
12878
+ mode: "singleton",
12879
+ methods: {
12880
+ list: method(zod.z.object({}), zod.z.object({ blocks: zod.z.array(CoreBlockSchema) }), { auth: "admin" }),
12881
+ get: method(zod.z.object({ blockId: zod.z.string() }), zod.z.object({ block: CoreBlockSchema.nullable() }), { auth: "admin" }),
12882
+ /**
12883
+ * Create a block. The code is COMPILED first: storing source that does not
12884
+ * compile turns an editor error into a fork failure the operator meets
12885
+ * minutes later in a log.
12886
+ */
12887
+ create: method(zod.z.object({ block: CoreBlockInputSchema }), zod.z.object({ block: CoreBlockSchema }), {
12888
+ kind: "mutation",
12889
+ auth: "admin",
12890
+ caller: "required"
12891
+ }),
12892
+ update: method(zod.z.object({
12893
+ blockId: zod.z.string(),
12894
+ block: CoreBlockInputSchema.partial()
12895
+ }), zod.z.object({ block: CoreBlockSchema }), {
12896
+ kind: "mutation",
12897
+ auth: "admin",
12898
+ caller: "required"
12899
+ }),
12900
+ delete: method(zod.z.object({ blockId: zod.z.string() }), zod.z.object({ success: zod.z.literal(true) }), {
12901
+ kind: "mutation",
12902
+ auth: "admin"
12903
+ }),
12904
+ setEnabled: method(zod.z.object({
12905
+ blockId: zod.z.string(),
12906
+ enabled: zod.z.boolean()
12907
+ }), zod.z.object({ block: CoreBlockSchema }), {
12908
+ kind: "mutation",
12909
+ auth: "admin"
12910
+ }),
12911
+ /**
12912
+ * Stop this block's runner and let the supervisor bring it back.
12913
+ *
12914
+ * **It stores nothing.** There is no "restart requested" flag and no second
12915
+ * lifecycle field: the supervisor already stops and respawns a runner when
12916
+ * the code hash changes, and this exposes that same path deliberately
12917
+ * (D62 — a switch writes the authority that already owned the function).
12918
+ * The next reconcile pass remains the authority, so a restart that lost a
12919
+ * race is corrected within one pass rather than leaving a stale flag behind.
12920
+ *
12921
+ * Refused for a DISABLED block: nothing would come back, and a button that
12922
+ * silently does nothing is worse than one that says why.
12923
+ */
12924
+ restart: method(zod.z.object({ blockId: zod.z.string() }), zod.z.object({ block: CoreBlockSchema }), {
12925
+ kind: "mutation",
12926
+ auth: "admin"
12927
+ }),
12928
+ /**
12929
+ * Type-check without saving — what the editor calls as the author types, so
12930
+ * the compiler's verdict is the same one the server will reach.
12931
+ */
12932
+ compile: method(zod.z.object({ code: zod.z.string() }), CoreBlockCompileResultSchema, {
12933
+ kind: "mutation",
12934
+ auth: "admin"
12935
+ }),
12936
+ /**
12937
+ * The declaration files the editor type-checks against.
12938
+ *
12939
+ * Served rather than bundled: the graph is 3.4 MB across 344 files, and
12940
+ * bundling it would roughly double the admin remote. Served rather than
12941
+ * hand-stubbed: a block runs with the same `ctx` an addon gets, so a stub
11973
12942
  * would drift and the editor would confidently autocomplete methods that
11974
12943
  * do not exist.
11975
12944
  */
@@ -12013,6 +12982,14 @@ var coreBlocksCapability = {
12013
12982
  stableId: zod.z.string().min(1)
12014
12983
  }), CompositionCustomizationReadSchema, { auth: "admin" }),
12015
12984
  /**
12985
+ * The capabilities a composition may BUILD on a device of `deviceType`
12986
+ * (a new device, or added to an existing one), with their fields — the
12987
+ * editor's feature list and the presets' availability (G2, D676). Built
12988
+ * from the validator's own blockers; a replace of a BOUND cap is not
12989
+ * listed here (the device's bindings say what it has).
12990
+ */
12991
+ listComposableCaps: method(zod.z.object({ deviceType: zod.z.enum(DeviceType) }), ComposableCapabilitiesSchema, { auth: "admin" }),
12992
+ /**
12016
12993
  * Validate without saving — the same verdict the save reaches. A mutation
12017
12994
  * for its body size; it writes nothing, so its access is `view`.
12018
12995
  */
@@ -20992,7 +21969,7 @@ function cameraSensorRefKey(ref) {
20992
21969
  }
20993
21970
  /** CS-1 accepts exactly one mode; CS-2 (D673) widens it to confirm / replace. */
20994
21971
  var CameraSensorsModeSchema = zod.z.enum(["in-addition"]);
20995
- zod.z.object({
21972
+ var CameraSensorsConfigSchema = zod.z.object({
20996
21973
  mode: CameraSensorsModeSchema,
20997
21974
  sensors: zod.z.array(CameraSensorRefSchema).min(1).max(8)
20998
21975
  }).superRefine((value, ctx) => {
@@ -23162,6 +24139,25 @@ var pipelineAnalyticsCapability = {
23162
24139
  */
23163
24140
  listArchivedDebugNotes: method(ArchivedDebugNoteQueryInputSchema, zod.z.array(ArchivedDebugNoteSchema).readonly(), { kind: "query" }),
23164
24141
  /**
24142
+ * Tracking-bench ground truth (D681): "this track and that one are the
24143
+ * same subject" / "different subjects". One row per unordered pair —
24144
+ * re-marking replaces it — and a mark that contradicts an existing chain
24145
+ * is REFUSED with the chain named. Writing a mark also sets `debug` on
24146
+ * both tracks. `protected`, matching `setTrackFlags`: marking is done from
24147
+ * the viewer's track detail by an authenticated non-admin session.
24148
+ */
24149
+ setTrackRelation: method(SetTrackRelationInputSchema, TrackRelationSchema, { kind: "mutation" }),
24150
+ /** Remove the mark between two tracks. */
24151
+ clearTrackRelation: method(ClearTrackRelationInputSchema, ClearTrackRelationResultSchema, { kind: "mutation" }),
24152
+ /**
24153
+ * A track that is not ONE subject: `mixed` (two subjects, optionally
24154
+ * switching at `switchAtMs`) or `not-a-subject`. `pure` clears the mark
24155
+ * and answers `{ purity: null }` (D681).
24156
+ */
24157
+ setTrackPurity: method(SetTrackPurityInputSchema, SetTrackPurityResultSchema, { kind: "mutation" }),
24158
+ /** A camera's marks, optionally one track's, windowed by track start. */
24159
+ listTrackRelations: method(ListTrackRelationsInputSchema, ListTrackRelationsResultSchema, { kind: "query" }),
24160
+ /**
23165
24161
  * Birth decisions — one row per resolved confirmation-gate verdict (D409).
23166
24162
  *
23167
24163
  * The READ half of a measurement, and only that: it gates nothing and no
@@ -26179,6 +27175,16 @@ var NodeInferenceDevicesSchema = zod.z.object({
26179
27175
  * Strictly the control plane: never sees a frame, never calls inference,
26180
27176
  * never drains queues. The runner does that work locally on each agent.
26181
27177
  */
27178
+ /**
27179
+ * The input of `setCameraSensors` (G5, D676): the ONE typed write path of a
27180
+ * camera's attached sensors (D672). `config: null` removes the attachment —
27181
+ * the camera reads `not-configured` again, never `unreadable`.
27182
+ */
27183
+ var SetCameraSensorsInputSchema = zod.z.object({
27184
+ deviceId: zod.z.number().int().nonnegative(),
27185
+ /** `null` removes the attachment: the camera goes back to `not-configured`. */
27186
+ config: CameraSensorsConfigSchema.nullable()
27187
+ });
26182
27188
  var pipelineOrchestratorCapability = {
26183
27189
  name: "pipeline-orchestrator",
26184
27190
  scope: "system",
@@ -26644,6 +27650,18 @@ var pipelineOrchestratorCapability = {
26644
27650
  */
26645
27651
  getCameraStatuses: method(zod.z.object({ deviceIds: zod.z.array(zod.z.number()).optional() }), zod.z.array(CameraStatusSchema).readonly()),
26646
27652
  /**
27653
+ * Attach sensors to a camera, or detach them with `config: null` (D672
27654
+ * configuration, G5/D676). Written to the orchestrator's per-device store
27655
+ * under the same key `applyDeviceSettingsPatch` writes, and announced as a
27656
+ * settings change so the camera-sensors bridge refreshes. Refused by name
27657
+ * — nothing written — for a device that cannot be read, does not exist,
27658
+ * is not a camera, or names itself as its own sensor.
27659
+ */
27660
+ setCameraSensors: method(SetCameraSensorsInputSchema, zod.z.object({ success: zod.z.literal(true) }), {
27661
+ kind: "mutation",
27662
+ auth: "admin"
27663
+ }),
27664
+ /**
26647
27665
  * Disk-wins fleet reconcile after a recordings wipe. Starts the walk in
26648
27666
  * the addon process and returns immediately with the job snapshot — the
26649
27667
  * work outlives the tRPC/UDS 60s timeout. Poll `getReconcileFromDiskStatus`.
@@ -42641,978 +43659,736 @@ var WeatherStatusSchema = zod.z.object({
42641
43659
  /** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
42642
43660
  pressureUnit: zod.z.string().nullable(),
42643
43661
  /** Wind speed in the reported unit. Null when not provided. */
42644
- windSpeed: zod.z.number().nullable(),
42645
- /** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
42646
- windSpeedUnit: zod.z.string().nullable(),
42647
- /** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
42648
- windBearing: zod.z.number().nullable(),
42649
- /** Ms epoch when the slice was last updated. */
42650
- lastFetchedAt: zod.z.number()
42651
- });
42652
- var weatherCapability = {
42653
- name: "weather",
42654
- scope: "device",
42655
- deviceNative: true,
42656
- mode: "singleton",
42657
- deviceTypes: [DeviceType.Weather],
42658
- methods: {},
42659
- status: {
42660
- schema: WeatherStatusSchema,
42661
- kind: "push"
42662
- },
42663
- /**
42664
- * Runtime-state slice — mirrored by the kernel. The UI reads the
42665
- * current conditions directly from the slice on each weather push.
42666
- */
42667
- runtimeState: WeatherStatusSchema,
42668
- /**
42669
- * Runtime-state durability: **session** — a forecast is stale the moment the hub is down; the provider re-fetches on connect.
42670
- *
42671
- * See `RuntimeStateDurability`. Enforced by
42672
- * `scripts/check-runtime-state-durability.ts`.
42673
- */
42674
- durability: "session"
42675
- };
42676
- /**
42677
- * Per-zone occupancy aggregation produced by the analytics frame
42678
- * processor on every inference result. Covers the full combinatorial
42679
- * matrix the operator UI needs: total objects everywhere, total
42680
- * objects per zone, single class everywhere, single class per zone,
42681
- * objects outside any zone.
42682
- *
42683
- * Counts are derived from the analytics tracker (tracked detections
42684
- * with stable trackIds), not raw detector hits — this filters out
42685
- * one-off detector flickers and gives counts that match what the user
42686
- * sees on the live overlay.
42687
- *
42688
- * Overlap policy: a detection that intersects two zones counts in
42689
- * BOTH zones' `byClass` and `totalObjects` (count-in-each). The
42690
- * `frame` aggregate de-duplicates trivially since it's frame-wide.
42691
- * `unzoned` counts only detections that landed in zero zones.
42692
- */
42693
- var PerScopeBreakdownSchema = zod.z.object({
42694
- /** Total tracked objects in this scope (frame / zone / unzoned). */
42695
- totalObjects: zod.z.number().int().nonnegative(),
42696
- /** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
42697
- byClass: zod.z.record(zod.z.string(), zod.z.number().int().nonnegative()),
42698
- /**
42699
- * Of `totalObjects`, how many are STANDING — objects the census holds,
42700
- * present but not going anywhere (a parked car, a bin, a statue).
42701
- *
42702
- * The operator's question about a scene has two halves and they answer
42703
- * different things: "what is parked here" is stable for hours and must not
42704
- * flap, while "who is walking through" is the thing an alert is about. One
42705
- * number could only serve one of them, and `totalObjects` served the first
42706
- * badly — a passer-by moved it and every occupancy rule saw a change.
42707
- *
42708
- * `standing + transient === totalObjects`, always. The total keeps its old
42709
- * meaning exactly, so no rule written against it changes behaviour.
42710
- *
42711
- * ABSENT means UNKNOWN, never zero (D393): a snapshot from a node older than
42712
- * this field cannot say, and a reader that folds absence in as 0 reports "no
42713
- * parked objects here" about a camera whose lot is full.
42714
- */
42715
- standingObjects: zod.z.number().int().nonnegative().optional(),
42716
- /** Of `totalObjects`, how many are passing through. See `standingObjects` —
42717
- * same absence rule. */
42718
- transientObjects: zod.z.number().int().nonnegative().optional()
42719
- });
42720
- var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
42721
- zoneId: zod.z.string(),
42722
- zoneName: zod.z.string(),
42723
- /** TrackIds of objects currently inside this zone — for cross-reference
42724
- * with the per-track detail panel and live overlay. */
42725
- trackIds: zod.z.array(zod.z.string()).readonly()
42726
- });
42727
- /**
42728
- * A parked ("stationary") object surfaced alongside occupancy — an object that
42729
- * settled and stopped moving. It is NO LONGER a tracked object (the tracker was
42730
- * told to forget it so it stops re-spawning tracks/events), but it IS still
42731
- * physically present, so it keeps counting toward `frame` occupancy and is
42732
- * listed here so the UI can show it in a dedicated "Stationary" section instead
42733
- * of flooding the live event feed.
42734
- */
42735
- var StationaryObjectSchema = zod.z.object({
42736
- id: zod.z.string(),
42737
- className: zod.z.string(),
42738
- bbox: zod.z.object({
42739
- x: zod.z.number(),
42740
- y: zod.z.number(),
42741
- w: zod.z.number(),
42742
- h: zod.z.number()
42743
- }),
42744
- frameWidth: zod.z.number().int().nonnegative(),
42745
- frameHeight: zod.z.number().int().nonnegative(),
42746
- /** When the source track was first seen. */
42747
- firstSeenAt: zod.z.number().int(),
42748
- /** When the object was recognised as parked (promotion time). */
42749
- becameStationaryAt: zod.z.number().int(),
42750
- /** Last frame a detection confirmed the object is still there. */
42751
- lastConfirmedAt: zod.z.number().int(),
42752
- /** Enrichment label carried from the source track (identity / plate). */
42753
- label: zod.z.string().optional(),
42754
- /** Native-resolution key-frame media key for the parked object's best image. */
42755
- keyFrameMediaId: zod.z.number().int().optional()
42756
- });
42757
- var CameraOccupancySnapshotSchema = zod.z.object({
42758
- /** Frame timestamp of the inference result that produced this snapshot. */
42759
- ts: zod.z.number().int(),
42760
- /** Frame width/height in pixels — let the UI normalize bbox coords. */
42761
- frameWidth: zod.z.number().int().nonnegative(),
42762
- frameHeight: zod.z.number().int().nonnegative(),
42763
- /** Per-zone breakdown — one entry per defined zone (user + onboard). */
42764
- zones: zod.z.array(ZoneScopeBreakdownSchema).readonly(),
42765
- /** Frame-wide aggregate (everywhere, regardless of zone membership).
42766
- * INCLUDES currently-confirmed stationary objects (they are still present). */
42767
- frame: PerScopeBreakdownSchema,
42768
- /** Detections that landed outside every zone. Empty when no zones defined. */
42769
- unzoned: PerScopeBreakdownSchema,
42770
- /** Parked objects on this camera (additive — absent on legacy snapshots).
42771
- * Surfaced separately so the UI shows them in a dedicated section rather
42772
- * than as repeated tracks/events. */
42773
- stationaryObjects: zod.z.array(StationaryObjectSchema).readonly().optional()
42774
- });
42775
- /**
42776
- * One camera's row in a `getCurrentSnapshotBatch` answer.
42777
- *
42778
- * THREE outcomes, and the single-camera method could only express two of them
42779
- * because `snapshot: null` was already spoken for:
42780
- *
42781
- * - `read: 'read'`, `snapshot` present — the live occupancy reading.
42782
- * - `read: 'read'`, `snapshot: null` — read, and this camera has no reading
42783
- * yet: no frame since boot, and no parked-object registry to hydrate from.
42784
- * - `read: 'unreadable'` — the owner could not answer for this
42785
- * camera. `snapshot` is null, and it does NOT mean "nothing parked here".
42786
- *
42787
- * Collapsing the last two is the failure this field exists to prevent: a
42788
- * hydration that threw would otherwise render as an empty Stationary section,
42789
- * which is a definite claim about a camera nobody could read.
42790
- */
42791
- var CameraOccupancySnapshotForDeviceSchema = zod.z.object({
42792
- deviceId: zod.z.number(),
42793
- read: zod.z.enum(["read", "unreadable"]),
42794
- snapshot: CameraOccupancySnapshotSchema.nullable()
42795
- });
42796
- /**
42797
- * Time-series resolution. The history methods return one bucket per
42798
- * step over the requested range. Smaller resolutions cost more
42799
- * memory + bandwidth; bound to discrete steps so caller cannot ask
42800
- * for arbitrary fractional buckets.
42801
- */
42802
- var HistoryResolutionEnum = zod.z.enum([
42803
- "minute",
42804
- "5min",
42805
- "hour"
42806
- ]);
42807
- var HistoryRangeSchema = zod.z.object({
42808
- /** Range start (epoch ms, inclusive). */
42809
- from: zod.z.number().int(),
42810
- /** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
42811
- to: zod.z.number().int(),
42812
- resolution: HistoryResolutionEnum
42813
- });
42814
- var HistoryPointSchema = zod.z.object({
42815
- /** Bucket midpoint (epoch ms). */
42816
- ts: zod.z.number().int(),
42817
- /** Object count averaged over the bucket (rounded to nearest integer). */
42818
- count: zod.z.number().int().nonnegative()
42819
- });
42820
- /**
42821
- * Zone Analytics capability — spatial occupancy aggregations over
42822
- * tracked detections. Hosted by `addon-pipeline-analytics`.
42823
- *
42824
- * Live snapshot is mirrored into the `zone-occupancy` device-state
42825
- * slice on every frame; the cap method `getCurrentSnapshot` is a
42826
- * direct read for callers that don't want to subscribe.
42827
- *
42828
- * History methods are sourced from a 60-minute rolling in-memory
42829
- * ring of frame-level samples (sub-second resolution). Queries
42830
- * outside the ring window return empty arrays — durable storage
42831
- * is a follow-up (Step 4b: persisted samples → SQL store).
42832
- *
42833
- * Every history method takes an optional `className` filter:
42834
- * - omitted → bucket count includes ALL classes in scope
42835
- * - present → bucket count restricted to that class
42836
- *
42837
- * The `getZoneHistory` / `getCameraHistory` / `getUnzonedHistory`
42838
- * triple covers the three spatial scopes (per-zone, frame-wide,
42839
- * outside-any-zone). Crossing them with `className?` gives the full
42840
- * combinatorial coverage the operator UI requested.
42841
- *
42842
- * ## ⚠️ The three history methods are DEPRECATED
42843
- *
42844
- * They are served from a 60-MINUTE in-memory ring. It dies with the process,
42845
- * so after a restart they report nothing about a night that did happen, and any
42846
- * window past an hour is silently clamped — a caller cannot tell a clamp from
42847
- * an empty scene. The presets in the occupancy chart existed to hide that
42848
- * horizon rather than to answer a question.
42849
- *
42850
- * Use `pipelineAnalytics.readLongTermSeries({ series: 'occupancy', scope })`
42851
- * instead. Those rows are already written for every camera, ~60 bytes per
42852
- * (camera, scope, 5-minute bucket), kept for a year, and survive a restart.
42853
- * Spell the scope with `occupancyScope(zoneId?, className?)` — the same
42854
- * function the writer uses, so the two cannot drift.
42855
- *
42856
- * The ring stays until these three go, and it is the only thing left reading
42857
- * it. Do not add a fourth caller.
42858
- */
42859
- var zoneAnalyticsCapability = {
42860
- name: "zone-analytics",
42861
- scope: "device",
42862
- mode: "singleton",
42863
- deviceTypes: [DeviceType.Camera],
42864
- methods: {
42865
- /** Latest computed occupancy snapshot for this camera. Null when
42866
- * the analytics pipeline hasn't seen a frame for this device yet
42867
- * (no inference result emitted since boot or since binding was
42868
- * activated). */
42869
- getCurrentSnapshot: method(zod.z.object({ deviceId: zod.z.number() }), CameraOccupancySnapshotSchema.nullable()),
42870
- /**
42871
- * The same snapshot, for a SET of cameras, in one round trip.
42872
- *
42873
- * The Events page's Stationary section polls this every 15s for every
42874
- * selected camera. Fanned out client-side that is one query per camera to
42875
- * read a `Map.get` at the owner — the answer costs nothing, the round trip
42876
- * costs everything. Batched, N transports become one and the per-device
42877
- * work is unchanged.
42878
- *
42879
- * Every requested deviceId gets a row, tagged `read` — see
42880
- * {@link CameraOccupancySnapshotForDeviceSchema}. A camera the owner could
42881
- * not answer for is `'unreadable'`, never an empty reading.
42882
- */
42883
- getCurrentSnapshotBatch: method(zod.z.object({ deviceIds: zod.z.array(zod.z.number()).min(1).max(200) }), zod.z.array(CameraOccupancySnapshotForDeviceSchema).readonly()),
42884
- /** Time-series object count inside one zone. `className` optional —
42885
- * omit to count every class in the zone. */
42886
- getZoneHistory: method(zod.z.object({
42887
- deviceId: zod.z.number(),
42888
- zoneId: zod.z.string(),
42889
- className: zod.z.string().optional()
42890
- }).extend(HistoryRangeSchema.shape), zod.z.array(HistoryPointSchema).readonly()),
42891
- /** Time-series frame-wide object count (everywhere). */
42892
- getCameraHistory: method(zod.z.object({
42893
- deviceId: zod.z.number(),
42894
- className: zod.z.string().optional()
42895
- }).extend(HistoryRangeSchema.shape), zod.z.array(HistoryPointSchema).readonly()),
42896
- /** Time-series count of objects outside every zone. */
42897
- getUnzonedHistory: method(zod.z.object({
42898
- deviceId: zod.z.number(),
42899
- className: zod.z.string().optional()
42900
- }).extend(HistoryRangeSchema.shape), zod.z.array(HistoryPointSchema).readonly())
42901
- },
42902
- /**
42903
- * Runtime-state slice — the latest occupancy snapshot mirrored by
42904
- * the analytics frame processor on every inference result. Consumers
42905
- * read via `device.state.zoneAnalytics.value` and stay in sync
42906
- * automatically; the explicit `getCurrentSnapshot` cap method is
42907
- * still useful for one-off polls without a subscription.
42908
- */
42909
- runtimeState: CameraOccupancySnapshotSchema,
42910
- /**
42911
- * Runtime-state durability: **session** — per-frame analytics; with `audio-metrics` it is ~90 % of the offered write rate. Re-derived on the next frame.
42912
- *
42913
- * See `RuntimeStateDurability`. Enforced by
42914
- * `scripts/check-runtime-state-durability.ts`.
42915
- */
42916
- durability: "session"
42917
- };
42918
- /**
42919
- * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
42920
- * cap so a single CRUD surface backs every consumer; each stage has
42921
- * its own dev-state mirror slice (`motion-zone-rules`,
42922
- * `detection-zone-rules`, …) so consumer addons subscribe independently.
42923
- *
42924
- * Extend the enum here when a new gating consumer comes online (audio
42925
- * gating, alert filtering, …) — no other surface needs to change.
42926
- */
42927
- var ZoneRuleStageEnum = zod.z.enum([
42928
- "motion",
42929
- "detection",
42930
- "package"
42931
- ]);
42932
- /**
42933
- * Zone rules capability — per-camera CRUD over the {@link ZoneRule}
42934
- * arrays that decide how each pipeline stage uses the polygon zones.
42935
- *
42936
- * Hosted by `addon-pipeline-orchestrator` alongside the zones provider
42937
- * so the operator has a single hub-side source of truth for both
42938
- * geometry and behaviour. Per-stage rules are stored under the
42939
- * `zoneRules.<stage>` key in the orchestrator's per-device store and
42940
- * mirrored to the device-state slice `<stage>-zone-rules` on every
42941
- * mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
42942
- * subscribe to that slice and refresh their gating without
42943
- * round-tripping the cap.
42944
- *
42945
- * Sets are bulk-replace — the operator UI sends the new rule list
42946
- * wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
42947
- * naturally in the rule editor without per-rule mutation chatter.
42948
- */
42949
- var zoneRulesCapability = {
42950
- name: "zone-rules",
42951
- scope: "device",
42952
- mode: "singleton",
42953
- deviceTypes: [DeviceType.Camera],
42954
- methods: {
42955
- /** Read the full rule list for a given stage (empty when no rules
42956
- * are defined yet). */
42957
- listRules: method(zod.z.object({
42958
- deviceId: zod.z.number(),
42959
- stage: ZoneRuleStageEnum
42960
- }), zod.z.array(ZoneRuleSchema).readonly()),
42961
- /** Bulk-replace the rule list for one stage. The provider validates
42962
- * each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
42963
- * thresholds in range) and rejects the whole patch if any entry
42964
- * is invalid — partial writes are a configuration footgun. */
42965
- setRules: method(zod.z.object({
42966
- deviceId: zod.z.number(),
42967
- stage: ZoneRuleStageEnum,
42968
- rules: zod.z.array(ZoneRuleSchema).readonly()
42969
- }), zod.z.void(), {
42970
- kind: "mutation",
42971
- auth: "admin"
42972
- })
43662
+ windSpeed: zod.z.number().nullable(),
43663
+ /** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
43664
+ windSpeedUnit: zod.z.string().nullable(),
43665
+ /** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
43666
+ windBearing: zod.z.number().nullable(),
43667
+ /** Ms epoch when the slice was last updated. */
43668
+ lastFetchedAt: zod.z.number()
43669
+ });
43670
+ var weatherCapability = {
43671
+ name: "weather",
43672
+ scope: "device",
43673
+ deviceNative: true,
43674
+ mode: "singleton",
43675
+ deviceTypes: [DeviceType.Weather],
43676
+ methods: {},
43677
+ status: {
43678
+ schema: WeatherStatusSchema,
43679
+ kind: "push"
42973
43680
  },
42974
43681
  /**
42975
- * Runtime-state slice — every stage mirrored together so consumers
42976
- * see one reactive handle (`device.state.zoneRules.value`) instead
42977
- * of one per stage. Bulk-replace mutations on any stage write the full
42978
- * `{motion, detection, package}` shape, so subscribers always get the
42979
- * complete current set. Consumers that only care about one stage
42980
- * just read the matching property.
42981
- *
42982
- * `package` backs the package-drop detector — a package zone is a
42983
- * `ZoneRule` on the `'package'` stage referencing drawn polygons
42984
- * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
42985
- * The orchestrator provider writes this stage as a first-class slice
42986
- * (Phase 4): every mutation mirrors the full `{motion, detection,
42987
- * package}` shape, so consumers read the current package rules directly
42988
- * off `device.state.zoneRules.value.package`.
43682
+ * Runtime-state slice — mirrored by the kernel. The UI reads the
43683
+ * current conditions directly from the slice on each weather push.
42989
43684
  */
42990
- runtimeState: zod.z.object({
42991
- motion: zod.z.array(ZoneRuleSchema).readonly(),
42992
- detection: zod.z.array(ZoneRuleSchema).readonly(),
42993
- package: zod.z.array(ZoneRuleSchema).readonly()
42994
- }),
43685
+ runtimeState: WeatherStatusSchema,
42995
43686
  /**
42996
- * Runtime-state durability: **restored** — operator intent, mutation-only, same argument as `zones`.
43687
+ * Runtime-state durability: **session** — a forecast is stale the moment the hub is down; the provider re-fetches on connect.
42997
43688
  *
42998
43689
  * See `RuntimeStateDurability`. Enforced by
42999
43690
  * `scripts/check-runtime-state-durability.ts`.
43000
43691
  */
43001
- durability: "restored"
43692
+ durability: "session"
43002
43693
  };
43003
- function isValueOf(slot, value) {
43004
- return value.slot === slot;
43005
- }
43006
- /** Bind slot `slot` to its owner's functions. See the module docblock. */
43007
- function defineExtensionSlotProvider(slot, impl) {
43008
- return {
43009
- slot,
43010
- list: async (deviceId) => {
43011
- const listing = await impl.list(deviceId);
43012
- if (listing === null) return null;
43013
- const value = listing.value;
43014
- return {
43015
- ...listing,
43016
- value
43017
- };
43018
- },
43019
- set: async (deviceId, value) => {
43020
- if (!isValueOf(slot, value)) throw new Error(`device-extension: a "${value.slot}" value was sent to slot "${slot}" — refused`);
43021
- return await impl.set(deviceId, value);
43022
- },
43023
- candidates: (deviceId) => impl.candidates(deviceId)
43024
- };
43025
- }
43026
- /** `<source>:<slotId>`. */
43027
- function extensionSlotRef(source, slot) {
43028
- return `${source}:${slot}`;
43029
- }
43030
- /** Build the `device-extension` provider one owner registers. */
43031
- function createDeviceExtensionProvider(options) {
43032
- const { source, label, slots, hostTypeOf, logger } = options;
43033
- const offered = (slot, hostType) => hostType !== null && EXTENSION_SLOTS[slot].hostDeviceTypes.some((t) => t === hostType);
43034
- const resolve = async (deviceId, slotRef) => {
43035
- const prefix = `${source}:`;
43036
- const slot = slotRef.startsWith(prefix) ? slots.find((s) => s.slot === slotRef.slice(prefix.length)) : void 0;
43037
- if (slot === void 0) throw new Error(`device-extension: "${slotRef}" is not a slot of "${source}" — it serves ${slots.map((s) => extensionSlotRef(source, s.slot)).join(", ") || "(none)"}`);
43038
- const hostType = await hostTypeOf(deviceId);
43039
- if (!offered(slot.slot, hostType)) throw new Error(`device-extension: slot "${slot.slot}" is not offered on device ${deviceId} (type ${hostType ?? "unknown"}; offered on ${EXTENSION_SLOTS[slot.slot].hostDeviceTypes.join(", ")})`);
43040
- return slot;
43041
- };
43042
- return {
43043
- listSources: async () => [{
43044
- source,
43045
- label,
43046
- availability: { state: "ok" }
43047
- }],
43048
- listSlots: async (input) => {
43049
- const hostType = await hostTypeOf(input.deviceId);
43050
- const rows = [];
43051
- for (const slot of slots) {
43052
- if (!offered(slot.slot, hostType)) continue;
43053
- const listing = await slot.list(input.deviceId);
43054
- if (listing === null) continue;
43055
- rows.push({
43056
- state: "ok",
43057
- slotRef: extensionSlotRef(source, slot.slot),
43058
- slot: slot.slot,
43059
- authority: listing.authority,
43060
- activation: listing.activation,
43061
- value: listing.value,
43062
- sources: [...listing.sources],
43063
- ...listing.impliedSources !== void 0 ? { impliedSources: [...listing.impliedSources] } : {}
43064
- });
43065
- }
43066
- return rows;
43067
- },
43068
- listCandidates: async (input) => {
43069
- return { candidates: [...await (await resolve(input.deviceId, input.slotRef)).candidates(input.deviceId)] };
43070
- },
43071
- setSlot: async (input) => {
43072
- const result = await (await resolve(input.deviceId, input.slotRef)).set(input.deviceId, input.value);
43073
- logger.info("device-extension: slot written", {
43074
- tags: { deviceId: input.deviceId },
43075
- meta: {
43076
- slotRef: input.slotRef,
43077
- reload: result.reload
43078
- }
43079
- });
43080
- return result;
43081
- }
43082
- };
43083
- }
43084
43694
  /**
43085
- * The field graph across every composition, and the cycles in it (D659).
43086
- *
43087
- * A node is ONE field of ONE device (`<addon>/<stableId>#<cap>.<field>`); an
43088
- * edge points from a composed field to each field it reads. A cycle, direct or
43089
- * through another composition, is refused at save by name (spec §2).
43090
- *
43091
- * The graph is built for ONE save: the CANDIDATE composition (the one being
43092
- * validated) plus every STORED composition. The candidate replaces its own old
43093
- * version, identified BY BLOCK ID wherever it sits in `stored` — whether a save
43094
- * introduces or removes a cycle must never depend on array order. A DIFFERENT
43095
- * block targeting the candidate's device is refused by name: one composition
43096
- * per device (D663). Two different stored peers that (wrongly) target the same
43097
- * device are corrupted state, not a cycle to resolve by picking one silently;
43098
- * that is refused by name too (D391).
43099
- *
43100
- * A customization of an EXISTING device reads that device's NATIVE value for a
43101
- * field it replaces or does not write ("Invert state" reads the `on` it
43102
- * replaces): such a self-read is no edge, so it is never a cycle (D663).
43103
- * Another composition reading that field reads the customizer's output.
43695
+ * Per-zone occupancy aggregation produced by the analytics frame
43696
+ * processor on every inference result. Covers the full combinatorial
43697
+ * matrix the operator UI needs: total objects everywhere, total
43698
+ * objects per zone, single class everywhere, single class per zone,
43699
+ * objects outside any zone.
43104
43700
  *
43105
- * `findCompositionCycles` reports at least one cycle per cycle-SET (the DFS
43106
- * marks a node `done` after its first expansion) — not every elementary cycle
43107
- * through a shared node, so the path it names may AVOID the candidate while
43108
- * one through it goes unreported. What a save refuses is therefore decided per
43109
- * strongly connected component (`findCandidateCycles`, C-1). A dotted
43110
- * `fieldPath` is matched only as an exact string: a write to `state.value` and
43111
- * a read of `state` would not connect (no nested field writes exist yet).
43701
+ * Counts are derived from the analytics tracker (tracked detections
43702
+ * with stable trackIds), not raw detector hits — this filters out
43703
+ * one-off detector flickers and gives counts that match what the user
43704
+ * sees on the live overlay.
43112
43705
  *
43113
- * An item leaf is its own node, `…#consumables.items[desiccant].status`
43114
- * (`featureFieldEntries`), so two items sharing an inner path never merge. No
43115
- * READ can name an array element (`describeStateField` never describes one),
43116
- * so an item node is write-only: its edges to sources are what the check needs.
43117
- */
43118
- /** Two blocks' compositions target the same device — refused by name, never silently resolved. */
43119
- var CompositionGraphError = class extends Error {};
43120
- /** `"cap.field"` of every field a peer writes — the same spelling as `compositionFieldKey`. */
43121
- function writtenFields(peer) {
43122
- return new Set(peer.composition.features.flatMap((feature) => !compositionFeatureEnabled(feature) || feature.kind !== "fields" ? [] : featureFieldEntries(feature).map(({ fieldPath }) => `${feature.cap}.${fieldPath}`)));
43123
- }
43124
- /** A read of the peer's OWN device is an edge only when it reads the peer's own output (rule 5, D663). */
43125
- function readsComposedValue(peer, written, read) {
43126
- if (compositionSourceKey(read.source) !== compositionSourceKey(peer.target)) return true;
43127
- const field = `${read.cap}.${read.fieldPath}`;
43128
- return written.has(field) && peer.replaced?.has(field) !== true;
43129
- }
43130
- function peerEdges(peer) {
43131
- const written = writtenFields(peer);
43132
- return peer.composition.features.flatMap((feature) => !compositionFeatureEnabled(feature) || feature.kind !== "fields" ? [] : featureFieldEntries(feature).map(({ fieldPath, source }) => [compositionReadKey({
43133
- source: peer.target,
43134
- cap: feature.cap,
43135
- fieldPath
43136
- }), fieldReads(source).filter((read) => readsComposedValue(peer, written, read)).map(compositionReadKey)]));
43137
- }
43138
- /**
43139
- * Every peer that actually takes part in the graph: each stored peer minus the
43140
- * candidate's own old version (same block id — the drop happens before any
43141
- * edge is built, so its old edges never enter the map), plus the candidate
43142
- * itself, exactly once. Throws {@link CompositionGraphError} when a DIFFERENT
43143
- * block targets the candidate's device, or two stored peers share a device.
43706
+ * Overlap policy: a detection that intersects two zones counts in
43707
+ * BOTH zones' `byClass` and `totalObjects` (count-in-each). The
43708
+ * `frame` aggregate de-duplicates trivially since it's frame-wide.
43709
+ * `unzoned` counts only detections that landed in zero zones.
43144
43710
  */
43145
- function resolvedPeers(candidate, stored) {
43146
- const candidateKey = compositionSourceKey(candidate.target);
43147
- const survivors = /* @__PURE__ */ new Map();
43148
- for (const peer of stored) {
43149
- if (peer.blockId === candidate.blockId) continue;
43150
- const key = compositionSourceKey(peer.target);
43151
- if (key === candidateKey) throw new CompositionGraphError(`block \`${peer.blockId}\` and block \`${candidate.blockId}\` both target device \`${key}\` — one composition per device; delete or edit the existing one`);
43152
- const clash = survivors.get(key);
43153
- if (clash !== void 0) throw new CompositionGraphError(`two stored compositions (\`${clash.blockId}\`, \`${peer.blockId}\`) both target device \`${key}\` — refusing to build the field graph`);
43154
- survivors.set(key, peer);
43155
- }
43156
- return [...survivors.values(), candidate];
43157
- }
43158
- function compositionEdges(candidate, stored) {
43159
- const edges = resolvedPeers(candidate, stored).flatMap(peerEdges);
43160
- return new Map(edges);
43161
- }
43162
- /** Tarjan's strongly connected components over every node of the graph (sources included). */
43163
- function stronglyConnected(edges) {
43164
- const index = /* @__PURE__ */ new Map();
43165
- const low = /* @__PURE__ */ new Map();
43166
- const onStack = /* @__PURE__ */ new Set();
43167
- const stack = [];
43168
- const components = [];
43169
- const connect = (node) => {
43170
- index.set(node, index.size);
43171
- low.set(node, index.get(node) ?? 0);
43172
- stack.push(node);
43173
- onStack.add(node);
43174
- for (const next of edges.get(node) ?? []) if (!index.has(next)) {
43175
- connect(next);
43176
- low.set(node, Math.min(low.get(node) ?? 0, low.get(next) ?? 0));
43177
- } else if (onStack.has(next)) low.set(node, Math.min(low.get(node) ?? 0, index.get(next) ?? 0));
43178
- if (low.get(node) !== index.get(node)) return;
43179
- const component = [];
43180
- for (let member = stack.pop(); member !== void 0; member = stack.pop()) {
43181
- onStack.delete(member);
43182
- component.push(member);
43183
- if (member === node) break;
43184
- }
43185
- components.push(component);
43186
- };
43187
- for (const node of edges.keys()) if (!index.has(node)) connect(node);
43188
- return components;
43189
- }
43190
- /** The shortest walk from `start` back to itself inside `component` (one exists: the component has a cycle). */
43191
- function cycleThrough(start, component, edges) {
43192
- const parent = /* @__PURE__ */ new Map();
43193
- const queue = [start];
43194
- for (let head = 0; head < queue.length; head += 1) {
43195
- const node = queue[head];
43196
- if (node === void 0) break;
43197
- for (const next of edges.get(node) ?? []) {
43198
- if (next === start) {
43199
- const chain = [];
43200
- for (let at = node; at !== void 0 && at !== start; at = parent.get(at)) chain.push(at);
43201
- return [
43202
- start,
43203
- ...chain.reverse(),
43204
- start
43205
- ];
43206
- }
43207
- if (!component.has(next) || parent.has(next)) continue;
43208
- parent.set(next, node);
43209
- queue.push(next);
43210
- }
43211
- }
43212
- return [start, start];
43213
- }
43711
+ var PerScopeBreakdownSchema = zod.z.object({
43712
+ /** Total tracked objects in this scope (frame / zone / unzoned). */
43713
+ totalObjects: zod.z.number().int().nonnegative(),
43714
+ /** Per-class count. Keys are macro class names (e.g. `person`, `car`). */
43715
+ byClass: zod.z.record(zod.z.string(), zod.z.number().int().nonnegative()),
43716
+ /**
43717
+ * Of `totalObjects`, how many are STANDING — objects the census holds,
43718
+ * present but not going anywhere (a parked car, a bin, a statue).
43719
+ *
43720
+ * The operator's question about a scene has two halves and they answer
43721
+ * different things: "what is parked here" is stable for hours and must not
43722
+ * flap, while "who is walking through" is the thing an alert is about. One
43723
+ * number could only serve one of them, and `totalObjects` served the first
43724
+ * badly — a passer-by moved it and every occupancy rule saw a change.
43725
+ *
43726
+ * `standing + transient === totalObjects`, always. The total keeps its old
43727
+ * meaning exactly, so no rule written against it changes behaviour.
43728
+ *
43729
+ * ABSENT means UNKNOWN, never zero (D393): a snapshot from a node older than
43730
+ * this field cannot say, and a reader that folds absence in as 0 reports "no
43731
+ * parked objects here" about a camera whose lot is full.
43732
+ */
43733
+ standingObjects: zod.z.number().int().nonnegative().optional(),
43734
+ /** Of `totalObjects`, how many are passing through. See `standingObjects` —
43735
+ * same absence rule. */
43736
+ transientObjects: zod.z.number().int().nonnegative().optional()
43737
+ });
43738
+ var ZoneScopeBreakdownSchema = PerScopeBreakdownSchema.extend({
43739
+ zoneId: zod.z.string(),
43740
+ zoneName: zod.z.string(),
43741
+ /** TrackIds of objects currently inside this zone — for cross-reference
43742
+ * with the per-track detail panel and live overlay. */
43743
+ trackIds: zod.z.array(zod.z.string()).readonly()
43744
+ });
43214
43745
  /**
43215
- * The cycles THIS save closes, decided per strongly connected component — never
43216
- * per reported DFS path, which names one back-edge per revisit and can report a
43217
- * component's cycle by a path that avoids the candidate while the one through it
43218
- * goes unreported. A component refuses when it holds a candidate node and a
43219
- * cycle (more than one node, or a self-loop). Components without a candidate
43220
- * node are not this save's cycles.
43746
+ * A parked ("stationary") object surfaced alongside occupancy — an object that
43747
+ * settled and stopped moving. It is NO LONGER a tracked object (the tracker was
43748
+ * told to forget it so it stops re-spawning tracks/events), but it IS still
43749
+ * physically present, so it keeps counting toward `frame` occupancy and is
43750
+ * listed here so the UI can show it in a dedicated "Stationary" section instead
43751
+ * of flooding the live event feed.
43221
43752
  */
43222
- function findCandidateCycles(candidate, stored) {
43223
- const edges = compositionEdges(candidate, stored);
43224
- const prefix = `${compositionSourceKey(candidate.target)}#`;
43225
- return stronglyConnected(edges).flatMap((members) => {
43226
- const start = members.find((n) => n.startsWith(prefix));
43227
- if (start === void 0) return [];
43228
- const component = new Set(members);
43229
- const selfLoop = members.length === 1 && (edges.get(start) ?? []).includes(start);
43230
- if (members.length === 1 && !selfLoop) return [];
43231
- return [{
43232
- path: cycleThrough(start, component, edges),
43233
- component
43234
- }];
43235
- });
43236
- }
43237
- function problem(code, path, message) {
43238
- return {
43239
- code,
43240
- path,
43241
- message
43242
- };
43243
- }
43244
- function refused(...problems) {
43245
- return {
43246
- problems,
43247
- fields: [],
43248
- features: [],
43249
- events: []
43250
- };
43251
- }
43252
- /** Where a planned field sits in the composition: `features[N].fields.<path>` or `features[N].items.<key>.fields.<path>`. */
43253
- function compositionFieldAt(field) {
43254
- const inner = field.item === void 0 ? "fields" : `items.${field.item.key}.fields`;
43255
- return `features[${field.featureIndex}].${inner}.${field.path}`;
43256
- }
43257
- var NUMBER = {
43258
- kind: "fixed",
43259
- result: ["number"]
43260
- };
43261
- var BOOLEAN = {
43262
- kind: "fixed",
43263
- result: ["boolean"]
43264
- };
43265
- /** One rule per builtin. `expression-kinds.spec.ts` fails for a builtin missing here. */
43266
- var BUILTIN_RESULT_KINDS = Object.freeze({
43267
- min: NUMBER,
43268
- max: NUMBER,
43269
- abs: NUMBER,
43270
- floor: NUMBER,
43271
- ceil: NUMBER,
43272
- sqrt: NUMBER,
43273
- round: NUMBER,
43274
- pow: NUMBER,
43275
- clamp: NUMBER,
43276
- avg: NUMBER,
43277
- sum: NUMBER,
43278
- age: NUMBER,
43279
- convert: NUMBER,
43280
- coalesce: { kind: "coalesce" },
43281
- any: BOOLEAN,
43282
- all: BOOLEAN,
43283
- within: BOOLEAN,
43284
- latest: {
43285
- kind: "fixed",
43286
- result: ["number", "null"]
43287
- },
43288
- number: {
43289
- kind: "fixed",
43290
- result: ["number", "null"]
43291
- },
43292
- rose: {
43293
- kind: "fixed",
43294
- result: ["number", "null"]
43295
- },
43296
- count: NUMBER
43753
+ var StationaryObjectSchema = zod.z.object({
43754
+ id: zod.z.string(),
43755
+ className: zod.z.string(),
43756
+ bbox: zod.z.object({
43757
+ x: zod.z.number(),
43758
+ y: zod.z.number(),
43759
+ w: zod.z.number(),
43760
+ h: zod.z.number()
43761
+ }),
43762
+ frameWidth: zod.z.number().int().nonnegative(),
43763
+ frameHeight: zod.z.number().int().nonnegative(),
43764
+ /** When the source track was first seen. */
43765
+ firstSeenAt: zod.z.number().int(),
43766
+ /** When the object was recognised as parked (promotion time). */
43767
+ becameStationaryAt: zod.z.number().int(),
43768
+ /** Last frame a detection confirmed the object is still there. */
43769
+ lastConfirmedAt: zod.z.number().int(),
43770
+ /** Enrichment label carried from the source track (identity / plate). */
43771
+ label: zod.z.string().optional(),
43772
+ /** Native-resolution key-frame media key for the parked object's best image. */
43773
+ keyFrameMediaId: zod.z.number().int().optional()
43774
+ });
43775
+ var CameraOccupancySnapshotSchema = zod.z.object({
43776
+ /** Frame timestamp of the inference result that produced this snapshot. */
43777
+ ts: zod.z.number().int(),
43778
+ /** Frame width/height in pixels — let the UI normalize bbox coords. */
43779
+ frameWidth: zod.z.number().int().nonnegative(),
43780
+ frameHeight: zod.z.number().int().nonnegative(),
43781
+ /** Per-zone breakdown — one entry per defined zone (user + onboard). */
43782
+ zones: zod.z.array(ZoneScopeBreakdownSchema).readonly(),
43783
+ /** Frame-wide aggregate (everywhere, regardless of zone membership).
43784
+ * INCLUDES currently-confirmed stationary objects (they are still present). */
43785
+ frame: PerScopeBreakdownSchema,
43786
+ /** Detections that landed outside every zone. Empty when no zones defined. */
43787
+ unzoned: PerScopeBreakdownSchema,
43788
+ /** Parked objects on this camera (additive — absent on legacy snapshots).
43789
+ * Surfaced separately so the UI shows them in a dedicated section rather
43790
+ * than as repeated tracks/events. */
43791
+ stationaryObjects: zod.z.array(StationaryObjectSchema).readonly().optional()
43297
43792
  });
43298
- var KindInferenceError = class extends Error {};
43299
- function kindOfValue(value) {
43300
- if (value === null) return "null";
43301
- if (typeof value === "number") return "number";
43302
- if (typeof value === "string") return "string";
43303
- return "boolean";
43304
- }
43305
- function only(kind) {
43306
- return new Set([kind]);
43307
- }
43308
- function union(...sets) {
43309
- return new Set(sets.flatMap((s) => [...s]));
43310
- }
43311
- function withoutNull(set) {
43312
- return new Set([...set].filter((k) => k !== "null"));
43313
- }
43314
- function binaryKinds(op, left, right) {
43315
- if (op === "+") {
43316
- const all = [...left, ...right];
43317
- if (all.every((k) => k === "string")) return only("string");
43318
- if (all.every((k) => k === "number")) return only("number");
43319
- return new Set(["number", "string"]);
43320
- }
43321
- if (op === "-" || op === "*" || op === "/" || op === "%") return only("number");
43322
- return only("boolean");
43323
- }
43324
- /** "exactly N" / "at least N" / "N–M", for an arity refusal that says what IS allowed. */
43325
- function arityRange(builtin) {
43326
- if (builtin.minArgs === builtin.maxArgs) return `exactly ${builtin.minArgs}`;
43327
- if (builtin.maxArgs === Number.POSITIVE_INFINITY) return `at least ${builtin.minArgs}`;
43328
- return `${builtin.minArgs}–${builtin.maxArgs}`;
43329
- }
43330
- function callKinds(callee, args) {
43331
- const builtin = EXPRESSION_BUILTINS[callee];
43332
- if (builtin !== void 0 && (args.length < builtin.minArgs || args.length > builtin.maxArgs)) throw new KindInferenceError(`${callee}: wrong number of arguments (${args.length}); expected ${arityRange(builtin)}`);
43333
- const rule = BUILTIN_RESULT_KINDS[callee];
43334
- if (rule === void 0) throw new KindInferenceError(`no result-kind rule for builtin '${callee}'`);
43335
- if (rule.kind === "fixed") return new Set(rule.result);
43336
- const nonNull = union(...args.map(withoutNull));
43337
- return args.every((a) => a.has("null")) ? union(nonNull, only("null")) : nonNull;
43338
- }
43339
- function kindsOf(node, bindings) {
43340
- switch (node.kind) {
43341
- case "literal": return only(kindOfValue(node.value));
43342
- case "identifier": {
43343
- if (node.name === "now") return only("number");
43344
- const bound = bindings.get(node.name);
43345
- if (bound === void 0) throw new KindInferenceError(`unbound identifier '${node.name}'`);
43346
- return bound;
43347
- }
43348
- case "unary": return only(node.op === "!" ? "boolean" : "number");
43349
- case "binary": return binaryKinds(node.op, kindsOf(node.left, bindings), kindsOf(node.right, bindings));
43350
- case "logical": return union(kindsOf(node.left, bindings), kindsOf(node.right, bindings));
43351
- case "conditional": return union(kindsOf(node.consequent, bindings), kindsOf(node.alternate, bindings));
43352
- case "call": return callKinds(node.callee, node.args.map((a) => kindsOf(a, bindings)));
43353
- }
43354
- }
43355
- function nodeContainsCall(node, callee) {
43356
- switch (node.kind) {
43357
- case "literal":
43358
- case "identifier": return false;
43359
- case "unary": return nodeContainsCall(node.operand, callee);
43360
- case "binary":
43361
- case "logical": return nodeContainsCall(node.left, callee) || nodeContainsCall(node.right, callee);
43362
- case "conditional": return nodeContainsCall(node.test, callee) || nodeContainsCall(node.consequent, callee) || nodeContainsCall(node.alternate, callee);
43363
- case "call": return node.callee === callee || node.args.some((arg) => nodeContainsCall(arg, callee));
43364
- }
43365
- }
43366
- function expressionContainsBuiltinCall(expr, callee) {
43367
- const compiled = compileExpressionSafe(expr);
43368
- return compiled.ok && nodeContainsCall(compiled.parsed.ast, callee);
43369
- }
43370
- function inferExpressionKinds(input) {
43371
- const compiled = compileExpressionSafe(input.expr);
43372
- if (!compiled.ok) return {
43373
- ok: false,
43374
- error: compiled.error
43375
- };
43376
- try {
43377
- return {
43378
- ok: true,
43379
- kinds: kindsOf(compiled.parsed.ast, input.bindingKinds)
43380
- };
43381
- } catch (err) {
43382
- if (err instanceof KindInferenceError) return {
43383
- ok: false,
43384
- error: err.message
43385
- };
43386
- throw err;
43387
- }
43388
- }
43389
43793
  /**
43390
- * The kind half of planning a composition field (D659): which kinds a source
43391
- * can produce, whether they fit a target field, and the field's role.
43392
- * Shared by the top-level planner (`validate-composition.ts`) and the item
43393
- * planner (`composition-items.ts`). Internal to the composition module — NOT
43394
- * in the barrel: these are planner internals, not a published contract.
43794
+ * One camera's row in a `getCurrentSnapshotBatch` answer.
43795
+ *
43796
+ * THREE outcomes, and the single-camera method could only express two of them
43797
+ * because `snapshot: null` was already spoken for:
43798
+ *
43799
+ * - `read: 'read'`, `snapshot` present — the live occupancy reading.
43800
+ * - `read: 'read'`, `snapshot: null` — read, and this camera has no reading
43801
+ * yet: no frame since boot, and no parked-object registry to hydrate from.
43802
+ * - `read: 'unreadable'` — the owner could not answer for this
43803
+ * camera. `snapshot` is null, and it does NOT mean "nothing parked here".
43804
+ *
43805
+ * Collapsing the last two is the failure this field exists to prevent: a
43806
+ * hydration that threw would otherwise render as an empty Stationary section,
43807
+ * which is a definite claim about a camera nobody could read.
43395
43808
  */
43396
- function readKinds(read, lookupCap) {
43397
- const def = lookupCap(read.cap);
43398
- const schema = def?.runtimeState ?? def?.status?.schema;
43399
- if (def === null || schema === void 0) return {
43400
- ok: false,
43401
- code: "source-field-unknown",
43402
- message: `no capability \`${read.cap}\` with state to read`
43403
- };
43404
- const field = describeStateField(schema, read.fieldPath);
43405
- if (field === null) return {
43406
- ok: false,
43407
- code: "source-field-unknown",
43408
- message: `\`${read.cap}\` has no field \`${read.fieldPath}\``
43409
- };
43410
- const base = field.kind === "enum" ? "string" : field.kind;
43411
- return {
43412
- ok: true,
43413
- kinds: new Set(field.nullable ? [base, "null"] : [base])
43414
- };
43415
- }
43416
- function sourceKinds(source, lookupCap) {
43417
- if (source.kind === "from") return readKinds(source, lookupCap);
43418
- if (source.kind === "code") return {
43419
- ok: false,
43420
- code: "reserved-variant",
43421
- message: "code fields arrive in slice 4"
43422
- };
43423
- const bindingKinds = /* @__PURE__ */ new Map();
43424
- for (const [name, binding] of Object.entries(source.bindings)) {
43425
- if (binding.kind === "literal") {
43426
- bindingKinds.set(name, new Set([kindOfValue(binding.value)]));
43427
- continue;
43428
- }
43429
- const kinds = readKinds(binding, lookupCap);
43430
- if (!kinds.ok) return {
43431
- ...kinds,
43432
- message: `binding \`${name}\`: ${kinds.message}`
43433
- };
43434
- bindingKinds.set(name, kinds.kinds);
43435
- }
43436
- const inferred = inferExpressionKinds({
43437
- expr: source.expr,
43438
- bindingKinds
43439
- });
43440
- return inferred.ok ? {
43441
- ok: true,
43442
- kinds: inferred.kinds
43443
- } : {
43444
- ok: false,
43445
- code: "expression-invalid",
43446
- message: inferred.error
43447
- };
43448
- }
43449
- function compatibility(cap, target, kinds, at) {
43450
- const allowed = target.kind === "enum" ? "string" : target.kind;
43451
- const offending = [...kinds].filter((k) => k !== "null" && k !== allowed);
43452
- const problems = [];
43453
- if (offending.length > 0) problems.push(problem("kind-mismatch", at, `\`${cap}.${target.path}\` is a ${target.kind}; this source can produce ${offending.join(" or ")}`));
43454
- if (kinds.has("null") && !target.nullable) problems.push(problem("may-be-null", at, `\`${cap}.${target.path}\` cannot be null and this source can produce null — wrap it in coalesce(…) or pick a non-null source`));
43455
- return problems;
43456
- }
43809
+ var CameraOccupancySnapshotForDeviceSchema = zod.z.object({
43810
+ deviceId: zod.z.number(),
43811
+ read: zod.z.enum(["read", "unreadable"]),
43812
+ snapshot: CameraOccupancySnapshotSchema.nullable()
43813
+ });
43457
43814
  /**
43458
- * A string a source produces WITHOUT reading any device — a field-level
43459
- * `expression` source whose compiled AST is a bare string literal (`"red"`,
43460
- * never `a` or `a + "x"`). Anything else that can only produce `'string'` at
43461
- * the kind level (a `from` read of an enum-typed source field, a computed
43462
- * expression) is NOT checked against the target's `enumValues` here: the
43463
- * kind inference already collapsed it to the coarse `'string'` kind, losing
43464
- * whichever enum it actually came from, so membership can only be judged at
43465
- * write time against the runtime-state schema. Known limitation — see the
43466
- * fix report for Task 6 round 1.
43815
+ * Time-series resolution. The history methods return one bucket per
43816
+ * step over the requested range. Smaller resolutions cost more
43817
+ * memory + bandwidth; bound to discrete steps so caller cannot ask
43818
+ * for arbitrary fractional buckets.
43467
43819
  */
43468
- function literalStringValue(source) {
43469
- if (source.kind !== "expression") return null;
43470
- const compiled = compileExpressionSafe(source.expr);
43471
- if (!compiled.ok) return null;
43472
- const ast = compiled.parsed.ast;
43473
- return ast.kind === "literal" && typeof ast.value === "string" ? ast.value : null;
43474
- }
43820
+ var HistoryResolutionEnum = zod.z.enum([
43821
+ "minute",
43822
+ "5min",
43823
+ "hour"
43824
+ ]);
43825
+ var HistoryRangeSchema = zod.z.object({
43826
+ /** Range start (epoch ms, inclusive). */
43827
+ from: zod.z.number().int(),
43828
+ /** Range end (epoch ms, inclusive). Defaults to "now" at query time. */
43829
+ to: zod.z.number().int(),
43830
+ resolution: HistoryResolutionEnum
43831
+ });
43832
+ var HistoryPointSchema = zod.z.object({
43833
+ /** Bucket midpoint (epoch ms). */
43834
+ ts: zod.z.number().int(),
43835
+ /** Object count averaged over the bucket (rounded to nearest integer). */
43836
+ count: zod.z.number().int().nonnegative()
43837
+ });
43475
43838
  /**
43476
- * An enum target whose source is a save-time-KNOWN string literal outside its
43477
- * `enumValues` is refused now rather than left to fail the write later. A
43478
- * dynamic source (a `from` read, a non-literal expression) is left to
43479
- * runtime — see {@link literalStringValue}.
43839
+ * Zone Analytics capability — spatial occupancy aggregations over
43840
+ * tracked detections. Hosted by `addon-pipeline-analytics`.
43841
+ *
43842
+ * Live snapshot is mirrored into the `zone-occupancy` device-state
43843
+ * slice on every frame; the cap method `getCurrentSnapshot` is a
43844
+ * direct read for callers that don't want to subscribe.
43845
+ *
43846
+ * History methods are sourced from a 60-minute rolling in-memory
43847
+ * ring of frame-level samples (sub-second resolution). Queries
43848
+ * outside the ring window return empty arrays — durable storage
43849
+ * is a follow-up (Step 4b: persisted samples → SQL store).
43850
+ *
43851
+ * Every history method takes an optional `className` filter:
43852
+ * - omitted → bucket count includes ALL classes in scope
43853
+ * - present → bucket count restricted to that class
43854
+ *
43855
+ * The `getZoneHistory` / `getCameraHistory` / `getUnzonedHistory`
43856
+ * triple covers the three spatial scopes (per-zone, frame-wide,
43857
+ * outside-any-zone). Crossing them with `className?` gives the full
43858
+ * combinatorial coverage the operator UI requested.
43859
+ *
43860
+ * ## ⚠️ The three history methods are DEPRECATED
43861
+ *
43862
+ * They are served from a 60-MINUTE in-memory ring. It dies with the process,
43863
+ * so after a restart they report nothing about a night that did happen, and any
43864
+ * window past an hour is silently clamped — a caller cannot tell a clamp from
43865
+ * an empty scene. The presets in the occupancy chart existed to hide that
43866
+ * horizon rather than to answer a question.
43867
+ *
43868
+ * Use `pipelineAnalytics.readLongTermSeries({ series: 'occupancy', scope })`
43869
+ * instead. Those rows are already written for every camera, ~60 bytes per
43870
+ * (camera, scope, 5-minute bucket), kept for a year, and survive a restart.
43871
+ * Spell the scope with `occupancyScope(zoneId?, className?)` — the same
43872
+ * function the writer uses, so the two cannot drift.
43873
+ *
43874
+ * The ring stays until these three go, and it is the only thing left reading
43875
+ * it. Do not add a fourth caller.
43480
43876
  */
43481
- function enumLiteralProblem(cap, target, source, at) {
43482
- if (target.kind !== "enum" || target.enumValues === void 0) return null;
43483
- const value = literalStringValue(source);
43484
- if (value === null || target.enumValues.includes(value)) return null;
43485
- const allowed = target.enumValues.map((v) => `\`${v}\``).join(", ");
43486
- return problem("kind-mismatch", at, `\`${cap}.${target.path}\` accepts only ${allowed}; this source is the literal \`${value}\``);
43487
- }
43488
- function roleOf(source) {
43489
- if (source.kind !== "expression") return "derived";
43490
- if (expressionContainsBuiltinCall(source.expr, "count")) return "accumulator";
43491
- if (fieldReads(source).length > 0) return "derived";
43492
- const compiled = compileExpressionSafe(source.expr);
43493
- return compiled.ok && compiled.parsed.identifiers.has("now") ? "clock" : "constant";
43494
- }
43877
+ var zoneAnalyticsCapability = {
43878
+ name: "zone-analytics",
43879
+ scope: "device",
43880
+ mode: "singleton",
43881
+ deviceTypes: [DeviceType.Camera],
43882
+ methods: {
43883
+ /** Latest computed occupancy snapshot for this camera. Null when
43884
+ * the analytics pipeline hasn't seen a frame for this device yet
43885
+ * (no inference result emitted since boot or since binding was
43886
+ * activated). */
43887
+ getCurrentSnapshot: method(zod.z.object({ deviceId: zod.z.number() }), CameraOccupancySnapshotSchema.nullable()),
43888
+ /**
43889
+ * The same snapshot, for a SET of cameras, in one round trip.
43890
+ *
43891
+ * The Events page's Stationary section polls this every 15s for every
43892
+ * selected camera. Fanned out client-side that is one query per camera to
43893
+ * read a `Map.get` at the owner — the answer costs nothing, the round trip
43894
+ * costs everything. Batched, N transports become one and the per-device
43895
+ * work is unchanged.
43896
+ *
43897
+ * Every requested deviceId gets a row, tagged `read` — see
43898
+ * {@link CameraOccupancySnapshotForDeviceSchema}. A camera the owner could
43899
+ * not answer for is `'unreadable'`, never an empty reading.
43900
+ */
43901
+ getCurrentSnapshotBatch: method(zod.z.object({ deviceIds: zod.z.array(zod.z.number()).min(1).max(200) }), zod.z.array(CameraOccupancySnapshotForDeviceSchema).readonly()),
43902
+ /** Time-series object count inside one zone. `className` optional —
43903
+ * omit to count every class in the zone. */
43904
+ getZoneHistory: method(zod.z.object({
43905
+ deviceId: zod.z.number(),
43906
+ zoneId: zod.z.string(),
43907
+ className: zod.z.string().optional()
43908
+ }).extend(HistoryRangeSchema.shape), zod.z.array(HistoryPointSchema).readonly()),
43909
+ /** Time-series frame-wide object count (everywhere). */
43910
+ getCameraHistory: method(zod.z.object({
43911
+ deviceId: zod.z.number(),
43912
+ className: zod.z.string().optional()
43913
+ }).extend(HistoryRangeSchema.shape), zod.z.array(HistoryPointSchema).readonly()),
43914
+ /** Time-series count of objects outside every zone. */
43915
+ getUnzonedHistory: method(zod.z.object({
43916
+ deviceId: zod.z.number(),
43917
+ className: zod.z.string().optional()
43918
+ }).extend(HistoryRangeSchema.shape), zod.z.array(HistoryPointSchema).readonly())
43919
+ },
43920
+ /**
43921
+ * Runtime-state slice — the latest occupancy snapshot mirrored by
43922
+ * the analytics frame processor on every inference result. Consumers
43923
+ * read via `device.state.zoneAnalytics.value` and stay in sync
43924
+ * automatically; the explicit `getCurrentSnapshot` cap method is
43925
+ * still useful for one-off polls without a subscription.
43926
+ */
43927
+ runtimeState: CameraOccupancySnapshotSchema,
43928
+ /**
43929
+ * Runtime-state durability: **session** — per-frame analytics; with `audio-metrics` it is ~90 % of the offered write rate. Re-derived on the next frame.
43930
+ *
43931
+ * See `RuntimeStateDurability`. Enforced by
43932
+ * `scripts/check-runtime-state-durability.ts`.
43933
+ */
43934
+ durability: "session"
43935
+ };
43495
43936
  /**
43496
- * How a planned field says "unavailable", per feature mode and AFTER `roleOf`
43497
- * (D393, D663 rulings S1 + S1b). The composer cannot take a device it does not
43498
- * own offline, and D393 forbids a stale number, so on an existing target a
43499
- * field that READS a source never gets `offline`.
43937
+ * Stages a {@link ZoneRule} can apply to. Discriminator on the rules
43938
+ * cap so a single CRUD surface backs every consumer; each stage has
43939
+ * its own dev-state mirror slice (`motion-zone-rules`,
43940
+ * `detection-zone-rules`, …) so consumer addons subscribe independently.
43941
+ *
43942
+ * Extend the enum here when a new gating consumer comes online (audio
43943
+ * gating, alert filtering, …) — no other surface needs to change.
43500
43944
  */
43501
- function unavailableFor(tf, role, mode, cap, at) {
43502
- if (mode === "replace" && (role === "derived" || role === "accumulator")) return {
43503
- ok: true,
43504
- unavailable: "release"
43505
- };
43506
- if (mode === "new" || role !== "derived") return {
43507
- ok: true,
43508
- unavailable: tf.nullable ? "null" : "offline"
43509
- };
43510
- if (tf.nullable) return {
43511
- ok: true,
43512
- unavailable: "null"
43513
- };
43514
- if (tf.enumValues?.includes("unknown") === true) return {
43515
- ok: true,
43516
- unavailable: "unknown"
43517
- };
43518
- return {
43519
- ok: false,
43520
- problem: problem("not-replaceable", at, `\`${cap}.${tf.path}\` reads a source but can say neither null nor 'unknown', and an ADDED capability has no native reading to fall back to (D393, D663)`)
43521
- };
43522
- }
43523
- /** Item fields the composer writes itself, with the value it writes. */
43524
- var COMPOSED_ITEM_FIXED_FIELDS = { resettable: false };
43525
- function reservedItemFields(itemArray) {
43526
- return new Set([
43527
- itemArray.keyField,
43528
- ...itemArray.labelField === void 0 ? [] : [itemArray.labelField],
43529
- ...Object.keys(COMPOSED_ITEM_FIXED_FIELDS)
43530
- ]);
43531
- }
43532
- /** A required leaf under an optional parent is required only once something writes that parent. */
43533
- function leafRequired(itemArray, path, written) {
43534
- const described = describeStateField(itemArray.itemSchema, path);
43535
- if (described === null || !described.required) return false;
43536
- return optionalAncestors(itemArray.itemSchema, path).every((parent) => written.some((w) => w.startsWith(`${parent}.`)));
43537
- }
43538
- function missingLeaves({ capName, itemArray, reserved }, item, at) {
43539
- const written = Object.keys(item.fields);
43540
- return enumerateSchemaFields(itemArray.itemSchema).flatMap((leaf) => {
43541
- if (reserved.has(leaf.path) || leaf.path in item.fields) return [];
43542
- if (!leafRequired(itemArray, leaf.path, written)) return [];
43543
- const described = describeStateField(itemArray.itemSchema, leaf.path);
43544
- if (described === null || described.nullable) return [];
43545
- return [problem("missing-field", `${at}.fields.${leaf.path}`, `\`${capName}.${itemArray.path}[].${leaf.path}\` is required and has no source`)];
43546
- });
43945
+ var ZoneRuleStageEnum = zod.z.enum([
43946
+ "motion",
43947
+ "detection",
43948
+ "package"
43949
+ ]);
43950
+ /**
43951
+ * Zone rules capability — per-camera CRUD over the {@link ZoneRule}
43952
+ * arrays that decide how each pipeline stage uses the polygon zones.
43953
+ *
43954
+ * Hosted by `addon-pipeline-orchestrator` alongside the zones provider
43955
+ * so the operator has a single hub-side source of truth for both
43956
+ * geometry and behaviour. Per-stage rules are stored under the
43957
+ * `zoneRules.<stage>` key in the orchestrator's per-device store and
43958
+ * mirrored to the device-state slice `<stage>-zone-rules` on every
43959
+ * mutation; consumer addons (analytics, motion-wasm, pipeline-executor)
43960
+ * subscribe to that slice and refresh their gating without
43961
+ * round-tripping the cap.
43962
+ *
43963
+ * Sets are bulk-replace — the operator UI sends the new rule list
43964
+ * wholesale, so reordering / batch enable-toggle / drag-drop CRUD lives
43965
+ * naturally in the rule editor without per-rule mutation chatter.
43966
+ */
43967
+ var zoneRulesCapability = {
43968
+ name: "zone-rules",
43969
+ scope: "device",
43970
+ mode: "singleton",
43971
+ deviceTypes: [DeviceType.Camera],
43972
+ methods: {
43973
+ /** Read the full rule list for a given stage (empty when no rules
43974
+ * are defined yet). */
43975
+ listRules: method(zod.z.object({
43976
+ deviceId: zod.z.number(),
43977
+ stage: ZoneRuleStageEnum
43978
+ }), zod.z.array(ZoneRuleSchema).readonly()),
43979
+ /** Bulk-replace the rule list for one stage. The provider validates
43980
+ * each entry against {@link ZoneRuleSchema} (zoneIds non-empty,
43981
+ * thresholds in range) and rejects the whole patch if any entry
43982
+ * is invalid — partial writes are a configuration footgun. */
43983
+ setRules: method(zod.z.object({
43984
+ deviceId: zod.z.number(),
43985
+ stage: ZoneRuleStageEnum,
43986
+ rules: zod.z.array(ZoneRuleSchema).readonly()
43987
+ }), zod.z.void(), {
43988
+ kind: "mutation",
43989
+ auth: "admin"
43990
+ })
43991
+ },
43992
+ /**
43993
+ * Runtime-state slice — every stage mirrored together so consumers
43994
+ * see one reactive handle (`device.state.zoneRules.value`) instead
43995
+ * of one per stage. Bulk-replace mutations on any stage write the full
43996
+ * `{motion, detection, package}` shape, so subscribers always get the
43997
+ * complete current set. Consumers that only care about one stage
43998
+ * just read the matching property.
43999
+ *
44000
+ * `package` backs the package-drop detector — a package zone is a
44001
+ * `ZoneRule` on the `'package'` stage referencing drawn polygons
44002
+ * (see docs/superpowers/specs/2026-07-17-package-zones-design.md §3.1).
44003
+ * The orchestrator provider writes this stage as a first-class slice
44004
+ * (Phase 4): every mutation mirrors the full `{motion, detection,
44005
+ * package}` shape, so consumers read the current package rules directly
44006
+ * off `device.state.zoneRules.value.package`.
44007
+ */
44008
+ runtimeState: zod.z.object({
44009
+ motion: zod.z.array(ZoneRuleSchema).readonly(),
44010
+ detection: zod.z.array(ZoneRuleSchema).readonly(),
44011
+ package: zod.z.array(ZoneRuleSchema).readonly()
44012
+ }),
44013
+ /**
44014
+ * Runtime-state durability: **restored** — operator intent, mutation-only, same argument as `zones`.
44015
+ *
44016
+ * See `RuntimeStateDurability`. Enforced by
44017
+ * `scripts/check-runtime-state-durability.ts`.
44018
+ */
44019
+ durability: "restored"
44020
+ };
44021
+ function isValueOf(slot, value) {
44022
+ return value.slot === slot;
43547
44023
  }
43548
- function planItemField(ctx, leaf) {
43549
- const { capName, itemArray, featureIndex, lookupCap } = ctx;
43550
- const { key, label, path, source } = leaf;
43551
- const at = `features[${featureIndex}].items.${key}.fields.${path}`;
43552
- if (ctx.reserved.has(path)) return refused(problem("item-field-reserved", at, `\`${capName}.${itemArray.path}[].${path}\` is set by the composer itself — it takes no source`));
43553
- const tf = describeStateField(itemArray.itemSchema, path);
43554
- if (tf === null) return refused(problem("unknown-field", at, `a \`${capName}\` item has no field \`${path}\``));
43555
- const kinds = sourceKinds(source, lookupCap);
43556
- if (!kinds.ok) return refused(problem(kinds.code, at, kinds.message));
43557
- const role = roleOf(source);
43558
- const unavailable = unavailableFor(tf, role, ctx.mode, capName, at);
43559
- if (!unavailable.ok) return refused(unavailable.problem);
43560
- const plan = {
43561
- cap: capName,
43562
- path,
43563
- item: {
43564
- arrayPath: itemArray.path,
43565
- key,
43566
- label
43567
- },
43568
- featureIndex,
43569
- kind: tf.kind,
43570
- nullable: tf.nullable,
43571
- required: tf.required,
43572
- unavailable: unavailable.unavailable,
43573
- role,
43574
- source,
43575
- reads: [...fieldReads(source)]
43576
- };
43577
- const enumProblem = enumLiteralProblem(capName, tf, source, at);
44024
+ /** Bind slot `slot` to its owner's functions. See the module docblock. */
44025
+ function defineExtensionSlotProvider(slot, impl) {
43578
44026
  return {
43579
- problems: [...compatibility(capName, tf, kinds.kinds, at), ...enumProblem === null ? [] : [enumProblem]],
43580
- fields: [plan],
43581
- features: [],
43582
- events: []
44027
+ slot,
44028
+ list: async (deviceId) => {
44029
+ const listing = await impl.list(deviceId);
44030
+ if (listing === null) return null;
44031
+ const value = listing.value;
44032
+ return {
44033
+ ...listing,
44034
+ value
44035
+ };
44036
+ },
44037
+ set: async (deviceId, value) => {
44038
+ if (!isValueOf(slot, value)) throw new Error(`device-extension: a "${value.slot}" value was sent to slot "${slot}" — refused`);
44039
+ return await impl.set(deviceId, value);
44040
+ },
44041
+ candidates: (deviceId) => impl.candidates(deviceId)
43583
44042
  };
43584
44043
  }
43585
- function planItems(def, feature, featureIndex, lookupCap, mode = "new") {
43586
- const at = `features[${featureIndex}].items`;
43587
- const itemArray = def.status?.itemArray;
43588
- if (itemArray === void 0) return refused(problem("not-item-array", at, `\`${def.name}\` has no item array — it takes no \`items\``));
43589
- if (itemArray.path !== "items") return refused(problem("not-item-array", at, `\`${def.name}\`'s item array is at \`${itemArray.path}\`, not \`${COMPOSITION_ITEM_ARRAY_PATH}\` — not composable yet`));
43590
- const ctx = {
43591
- capName: def.name,
43592
- itemArray,
43593
- reserved: reservedItemFields(itemArray),
43594
- featureIndex,
43595
- lookupCap,
43596
- mode
44044
+ /** `<source>:<slotId>`. */
44045
+ function extensionSlotRef(source, slot) {
44046
+ return `${source}:${slot}`;
44047
+ }
44048
+ /** Build the `device-extension` provider one owner registers. */
44049
+ function createDeviceExtensionProvider(options) {
44050
+ const { source, label, slots, hostTypeOf, logger } = options;
44051
+ const offered = (slot, hostType) => hostType !== null && EXTENSION_SLOTS[slot].hostDeviceTypes.some((t) => t === hostType);
44052
+ const resolve = async (deviceId, slotRef) => {
44053
+ const prefix = `${source}:`;
44054
+ const slot = slotRef.startsWith(prefix) ? slots.find((s) => s.slot === slotRef.slice(prefix.length)) : void 0;
44055
+ if (slot === void 0) throw new Error(`device-extension: "${slotRef}" is not a slot of "${source}" — it serves ${slots.map((s) => extensionSlotRef(source, s.slot)).join(", ") || "(none)"}`);
44056
+ const hostType = await hostTypeOf(deviceId);
44057
+ if (!offered(slot.slot, hostType)) throw new Error(`device-extension: slot "${slot.slot}" is not offered on device ${deviceId} (type ${hostType ?? "unknown"}; offered on ${EXTENSION_SLOTS[slot.slot].hostDeviceTypes.join(", ")})`);
44058
+ return slot;
43597
44059
  };
43598
- const results = Object.entries(feature.items ?? {}).flatMap(([key, item]) => [{
43599
- problems: missingLeaves(ctx, item, `${at}.${key}`),
43600
- fields: [],
43601
- features: [],
43602
- events: []
43603
- }, ...Object.entries(item.fields).map(([path, source]) => planItemField(ctx, {
43604
- key,
43605
- label: item.label,
43606
- path,
43607
- source
43608
- }))]);
43609
44060
  return {
43610
- problems: results.flatMap((r) => r.problems),
43611
- fields: results.flatMap((r) => r.fields),
43612
- features: [],
43613
- events: []
44061
+ listSources: async () => [{
44062
+ source,
44063
+ label,
44064
+ availability: { state: "ok" }
44065
+ }],
44066
+ listSlots: async (input) => {
44067
+ const hostType = await hostTypeOf(input.deviceId);
44068
+ const rows = [];
44069
+ for (const slot of slots) {
44070
+ if (!offered(slot.slot, hostType)) continue;
44071
+ const listing = await slot.list(input.deviceId);
44072
+ if (listing === null) continue;
44073
+ rows.push({
44074
+ state: "ok",
44075
+ slotRef: extensionSlotRef(source, slot.slot),
44076
+ slot: slot.slot,
44077
+ authority: listing.authority,
44078
+ activation: listing.activation,
44079
+ value: listing.value,
44080
+ sources: [...listing.sources],
44081
+ ...listing.impliedSources !== void 0 ? { impliedSources: [...listing.impliedSources] } : {}
44082
+ });
44083
+ }
44084
+ return rows;
44085
+ },
44086
+ listCandidates: async (input) => {
44087
+ return { candidates: [...await (await resolve(input.deviceId, input.slotRef)).candidates(input.deviceId)] };
44088
+ },
44089
+ setSlot: async (input) => {
44090
+ const result = await (await resolve(input.deviceId, input.slotRef)).set(input.deviceId, input.value);
44091
+ logger.info("device-extension: slot written", {
44092
+ tags: { deviceId: input.deviceId },
44093
+ meta: {
44094
+ slotRef: input.slotRef,
44095
+ reload: result.reload
44096
+ }
44097
+ });
44098
+ return result;
44099
+ }
43614
44100
  };
43615
44101
  }
44102
+ /**
44103
+ * Frozen unit → spec table. `•` in the doc marks each dimension's canonical
44104
+ * unit (`factor: 1, offset: 0`). °F derives from `°C = (°F − 32) × 5/9` ⇒
44105
+ * `factor = 5/9`, `offset = -160/9` (kept as exact expressions, not rounded
44106
+ * decimals). K derives from `°C = K − 273.15`.
44107
+ */
44108
+ var UNIT_TABLE = {
44109
+ "°C": {
44110
+ dimension: "temperature",
44111
+ factor: 1,
44112
+ offset: 0
44113
+ },
44114
+ "°F": {
44115
+ dimension: "temperature",
44116
+ factor: 5 / 9,
44117
+ offset: -160 / 9
44118
+ },
44119
+ K: {
44120
+ dimension: "temperature",
44121
+ factor: 1,
44122
+ offset: -273.15
44123
+ },
44124
+ hPa: {
44125
+ dimension: "pressure",
44126
+ factor: 1,
44127
+ offset: 0
44128
+ },
44129
+ kPa: {
44130
+ dimension: "pressure",
44131
+ factor: 10,
44132
+ offset: 0
44133
+ },
44134
+ Pa: {
44135
+ dimension: "pressure",
44136
+ factor: .01,
44137
+ offset: 0
44138
+ },
44139
+ mbar: {
44140
+ dimension: "pressure",
44141
+ factor: 1,
44142
+ offset: 0
44143
+ },
44144
+ bar: {
44145
+ dimension: "pressure",
44146
+ factor: 1e3,
44147
+ offset: 0
44148
+ },
44149
+ inHg: {
44150
+ dimension: "pressure",
44151
+ factor: 33.8639,
44152
+ offset: 0
44153
+ },
44154
+ mmHg: {
44155
+ dimension: "pressure",
44156
+ factor: 1.33322,
44157
+ offset: 0
44158
+ },
44159
+ psi: {
44160
+ dimension: "pressure",
44161
+ factor: 68.9476,
44162
+ offset: 0
44163
+ },
44164
+ "m/s": {
44165
+ dimension: "speed",
44166
+ factor: 1,
44167
+ offset: 0
44168
+ },
44169
+ "km/h": {
44170
+ dimension: "speed",
44171
+ factor: 1 / 3.6,
44172
+ offset: 0
44173
+ },
44174
+ mph: {
44175
+ dimension: "speed",
44176
+ factor: .44704,
44177
+ offset: 0
44178
+ },
44179
+ kn: {
44180
+ dimension: "speed",
44181
+ factor: .514444,
44182
+ offset: 0
44183
+ },
44184
+ "mm/h": {
44185
+ dimension: "precipitation-rate",
44186
+ factor: 1,
44187
+ offset: 0
44188
+ },
44189
+ "in/h": {
44190
+ dimension: "precipitation-rate",
44191
+ factor: 25.4,
44192
+ offset: 0
44193
+ },
44194
+ m: {
44195
+ dimension: "length",
44196
+ factor: 1,
44197
+ offset: 0
44198
+ },
44199
+ mm: {
44200
+ dimension: "length",
44201
+ factor: .001,
44202
+ offset: 0
44203
+ },
44204
+ cm: {
44205
+ dimension: "length",
44206
+ factor: .01,
44207
+ offset: 0
44208
+ },
44209
+ km: {
44210
+ dimension: "length",
44211
+ factor: 1e3,
44212
+ offset: 0
44213
+ },
44214
+ in: {
44215
+ dimension: "length",
44216
+ factor: .0254,
44217
+ offset: 0
44218
+ },
44219
+ ft: {
44220
+ dimension: "length",
44221
+ factor: .3048,
44222
+ offset: 0
44223
+ },
44224
+ mi: {
44225
+ dimension: "length",
44226
+ factor: 1609.344,
44227
+ offset: 0
44228
+ },
44229
+ lx: {
44230
+ dimension: "illuminance",
44231
+ factor: 1,
44232
+ offset: 0
44233
+ },
44234
+ "W/m²": {
44235
+ dimension: "irradiance",
44236
+ factor: 1,
44237
+ offset: 0
44238
+ },
44239
+ W: {
44240
+ dimension: "power",
44241
+ factor: 1,
44242
+ offset: 0
44243
+ },
44244
+ kW: {
44245
+ dimension: "power",
44246
+ factor: 1e3,
44247
+ offset: 0
44248
+ },
44249
+ VA: {
44250
+ dimension: "apparent-power",
44251
+ factor: 1,
44252
+ offset: 0
44253
+ },
44254
+ Wh: {
44255
+ dimension: "energy",
44256
+ factor: 1,
44257
+ offset: 0
44258
+ },
44259
+ kWh: {
44260
+ dimension: "energy",
44261
+ factor: 1e3,
44262
+ offset: 0
44263
+ },
44264
+ MWh: {
44265
+ dimension: "energy",
44266
+ factor: 1e6,
44267
+ offset: 0
44268
+ },
44269
+ V: {
44270
+ dimension: "voltage",
44271
+ factor: 1,
44272
+ offset: 0
44273
+ },
44274
+ mV: {
44275
+ dimension: "voltage",
44276
+ factor: .001,
44277
+ offset: 0
44278
+ },
44279
+ A: {
44280
+ dimension: "current",
44281
+ factor: 1,
44282
+ offset: 0
44283
+ },
44284
+ mA: {
44285
+ dimension: "current",
44286
+ factor: .001,
44287
+ offset: 0
44288
+ },
44289
+ ppm: {
44290
+ dimension: "concentration-volume",
44291
+ factor: 1,
44292
+ offset: 0
44293
+ },
44294
+ ppb: {
44295
+ dimension: "concentration-volume",
44296
+ factor: .001,
44297
+ offset: 0
44298
+ },
44299
+ "µg/m³": {
44300
+ dimension: "concentration-mass",
44301
+ factor: 1,
44302
+ offset: 0
44303
+ },
44304
+ "mg/m³": {
44305
+ dimension: "concentration-mass",
44306
+ factor: 1e3,
44307
+ offset: 0
44308
+ },
44309
+ kg: {
44310
+ dimension: "mass",
44311
+ factor: 1,
44312
+ offset: 0
44313
+ },
44314
+ g: {
44315
+ dimension: "mass",
44316
+ factor: .001,
44317
+ offset: 0
44318
+ },
44319
+ lb: {
44320
+ dimension: "mass",
44321
+ factor: .453592,
44322
+ offset: 0
44323
+ },
44324
+ oz: {
44325
+ dimension: "mass",
44326
+ factor: .0283495,
44327
+ offset: 0
44328
+ },
44329
+ "%": {
44330
+ dimension: "percentage",
44331
+ factor: 1,
44332
+ offset: 0
44333
+ }
44334
+ };
44335
+ /** Resolve a unit's `UnitSpec`, or `undefined` when the spelling is unknown. */
44336
+ function specFor(unit) {
44337
+ return Object.hasOwn(UNIT_TABLE, unit) ? UNIT_TABLE[unit] : void 0;
44338
+ }
44339
+ /** Value in `spec`'s unit → its dimension's canonical unit. */
44340
+ function toCanonical(value, spec) {
44341
+ return value * spec.factor + spec.offset;
44342
+ }
44343
+ /** Canonical-unit value → the unit described by `spec`. */
44344
+ function fromCanonical(canonical, spec) {
44345
+ return (canonical - spec.offset) / spec.factor;
44346
+ }
44347
+ /**
44348
+ * Graceful variant for the render path: returns the converted value, or
44349
+ * `undefined` when the conversion is refused (unknown unit / cross-dimension).
44350
+ * Never throws — a render caller falls back to the raw value + source unit.
44351
+ */
44352
+ function tryConvertUnit(value, from, to) {
44353
+ if (from === to) return value;
44354
+ const fromSpec = specFor(from);
44355
+ if (!fromSpec) return void 0;
44356
+ const toSpec = specFor(to);
44357
+ if (!toSpec) return void 0;
44358
+ if (fromSpec.dimension !== toSpec.dimension) return void 0;
44359
+ return fromCanonical(toCanonical(value, fromSpec), toSpec);
44360
+ }
44361
+ /**
44362
+ * One composed field, evaluated from what its sources say NOW (D659).
44363
+ *
44364
+ * Pure. The composer calls it on every source change, and the slice-5 editor
44365
+ * will call it for its live preview, so both see the same answer.
44366
+ *
44367
+ * The rules (spec §2):
44368
+ * - a source that is offline or unreadable keeps its last value and is marked
44369
+ * STALE; the field is stale and keeps computing (D49);
44370
+ * - a source that is GONE makes the field UNAVAILABLE, `fatal`, and never a
44371
+ * plausible number (D393);
44372
+ * - a source that is unavailable but NOT gone (e.g. a cap unregistered at
44373
+ * runtime, R7b) makes the field UNAVAILABLE, non-fatal, `value: null`: it
44374
+ * may come back on its own, no block failure is raised for it;
44375
+ * - an expression that fails on a value makes the field unavailable for that
44376
+ * value, and NOT fatal: the next value may compute.
44377
+ *
44378
+ * `confirmed` (D663, D49): whether a NON-fatal unavailability is an ANSWER — a
44379
+ * cap the provider unregistered and a re-read agreed, a field the source
44380
+ * reported without, an expression that failed on a value — or only the
44381
+ * absence of one (a failed read, a first or empty read, a source not tracked
44382
+ * yet). Only a confirmed unavailability may hand a `release`-mode field back
44383
+ * to the native provider; an unconfirmed one holds it on its last value.
44384
+ * Absent means NOT confirmed. Fatal is always confirmed.
44385
+ *
44386
+ * When an expression has more than one unavailable binding, a FATAL one always
44387
+ * wins the outcome, regardless of position: every binding is scanned before
44388
+ * the field is judged, so an earlier non-fatal binding (e.g. a cap that is
44389
+ * momentarily unregistered) can never hide a later fatal one (a deleted
44390
+ * source) behind an early return.
44391
+ */
43616
44392
  function describeRead(read) {
43617
44393
  return `${compositionSourceKey(read.source)} ${read.cap}.${read.fieldPath}`;
43618
44394
  }
@@ -43679,7 +44455,10 @@ function expressionOutcome(source, read, now, stateful) {
43679
44455
  }
43680
44456
  const deadlines = [];
43681
44457
  const result = evaluateExpressionSource(source.expr, values, now, {
43682
- hooks: { noteDeadline: (at) => deadlines.push(at) },
44458
+ hooks: {
44459
+ noteDeadline: (at) => deadlines.push(at),
44460
+ convert: (value, from, to) => tryConvertUnit(value, normalizeUnit(from) ?? from, normalizeUnit(to) ?? to) ?? null
44461
+ },
43683
44462
  ...stateful === void 0 ? {} : { invokeStateful: (invocation) => evaluateStatefulExpressionCall(stateful.callPathPrefix === void 0 ? invocation : {
43684
44463
  ...invocation,
43685
44464
  path: `${stateful.callPathPrefix}@${invocation.path}`
@@ -43717,301 +44496,70 @@ function evaluateCompositionField(input) {
43717
44496
  };
43718
44497
  }
43719
44498
  }
43720
- function buildCapabilityLookup(defs) {
43721
- const byName = new Map(defs.map((d) => [d.name, d]));
43722
- return (capName) => byName.get(capName) ?? null;
43723
- }
43724
- /** Caps every `BaseDevice` registers on itself; a second registration throws. */
43725
- var BASE_DEVICE_CAPS = new Set(["device-status", "feature-probe"]);
43726
- var PENDING_SELF = {
43727
- addonId: COMPOSER_ADDON_ID,
43728
- stableId: `${COMPOSED_DEVICE_STABLE_ID_PREFIX}(unsaved)`
43729
- };
43730
- function composableCap(feature, at, lookupCap) {
43731
- const def = lookupCap(feature.cap);
43732
- if (def === null) return problem("unknown-capability", `${at}.cap`, `no capability is named \`${feature.cap}\``);
43733
- if (BASE_DEVICE_CAPS.has(def.name)) return problem("not-composable", `${at}.cap`, `\`${def.name}\` is registered by every device itself — it cannot be composed`);
43734
- const state = def.runtimeState;
43735
- if (def.scope !== "device" || state === void 0) return problem("not-composable", `${at}.cap`, `\`${def.name}\` has no per-device state to compose`);
43736
- return {
43737
- def,
43738
- state,
43739
- fields: describeTopLevelStateFields(state)
43740
- };
43741
- }
43742
44499
  /**
43743
- * A capability BUILT by the composition (a new device, or one ADDED to an
43744
- * existing device) must fit the device type, serve no command, and have every
43745
- * required field. A REPLACE needs none of it: the cap is bound (a fact), the
43746
- * native keeps serving its methods, and only the named fields are owned (D663).
44500
+ * The doorbell of a camera as a customization (D671). ONE builder: the
44501
+ * virtual-doorbell migration and the admin UI's "Use as doorbell" preset both
44502
+ * call it, so a preset can never write a shape the migration would refuse as
44503
+ * "an unrelated existing block".
43747
44504
  */
43748
- function builtCapProblems({ def, state, fields: targetFields }, feature, at, deviceType) {
43749
- const problems = [];
43750
- if (def.deviceTypes !== void 0 && !def.deviceTypes.includes(deviceType)) problems.push(problem("wrong-device-type", `${at}.cap`, `\`${def.name}\` is not offered on a \`${deviceType}\` device (it allows ${def.deviceTypes.join(", ")})`));
43751
- const served = Object.entries(def.methods).filter(([name, m]) => m.systemOnly !== true && def.composedMethods?.[name] === void 0).map(([name]) => `\`${name}\``);
43752
- if (served.length > 0) problems.push(problem("has-commands", `${at}.cap`, `\`${def.name}\` has ${served.join(", ")}, which a composed device cannot serve until commands arrive (slice 3)`));
43753
- const itemArrayPath = feature.items === void 0 ? null : def.status?.itemArray?.path ?? null;
43754
- for (const key of requiredTopLevelKeys(state)) {
43755
- if (key === itemArrayPath) continue;
43756
- if (!targetFields.has(key)) problems.push(problem("not-composable", `${at}.fields`, `\`${def.name}.${key}\` is not a scalar — nested fields arrive in a later slice`));
43757
- else if (!(key in feature.fields)) problems.push(problem("missing-field", `${at}.fields.${key}`, `\`${def.name}.${key}\` is required and has no source`));
43758
- }
43759
- return problems;
43760
- }
43761
- function planTopLevelField({ def, fields }, field, mode, lookupCap) {
43762
- const { featureIndex, path, source } = field;
43763
- const fieldAt = `features[${featureIndex}].fields.${path}`;
43764
- if (path.includes(".")) return refused(problem("nested-field", fieldAt, `\`${path}\` is nested — nested fields arrive in a later slice`));
43765
- const tf = fields.get(path);
43766
- if (tf === void 0) return refused(problem("unknown-field", fieldAt, `\`${def.name}\` has no field \`${path}\``));
43767
- const kinds = sourceKinds(source, lookupCap);
43768
- if (!kinds.ok) return refused(problem(kinds.code, fieldAt, kinds.message));
43769
- const role = roleOf(source);
43770
- const unavailable = unavailableFor(tf, role, mode, def.name, fieldAt);
43771
- if (!unavailable.ok) return refused(unavailable.problem);
43772
- const plan = {
43773
- cap: def.name,
43774
- path,
43775
- featureIndex,
43776
- kind: tf.kind,
43777
- nullable: tf.nullable,
43778
- required: tf.required,
43779
- unavailable: unavailable.unavailable,
43780
- role,
43781
- source,
43782
- reads: [...fieldReads(source)]
43783
- };
43784
- const enumProblem = enumLiteralProblem(def.name, tf, source, fieldAt);
43785
- return {
43786
- problems: [...compatibility(def.name, tf, kinds.kinds, fieldAt), ...enumProblem === null ? [] : [enumProblem]],
43787
- fields: [plan],
43788
- features: [],
43789
- events: []
43790
- };
43791
- }
43792
- function planFeature(feature, featureIndex, target, lookupCap) {
43793
- const at = `features[${featureIndex}]`;
43794
- const cap = composableCap(feature, at, lookupCap);
43795
- if ("code" in cap) return refused(cap);
43796
- const capProblems = target.mode === "replace" ? [] : [...builtCapProblems(cap, feature, at, target.deviceType)];
43797
- if (feature.commands !== void 0 && Object.keys(feature.commands).length > 0) capProblems.push(problem("reserved-variant", `${at}.commands`, "commands (`forward`) arrive in slice 3"));
43798
- const perField = Object.entries(feature.fields).map(([path, source]) => planTopLevelField(cap, {
43799
- featureIndex,
43800
- path,
43801
- source
43802
- }, target.mode, lookupCap));
43803
- const items = feature.items === void 0 ? [] : [planItems(cap.def, feature, featureIndex, lookupCap, target.mode)];
43804
- const all = [...perField, ...items];
43805
- const fields = all.flatMap((result) => result.fields);
43806
- const featurePlan = {
43807
- featureIndex,
43808
- cap: cap.def.name,
43809
- mode: target.mode
43810
- };
43811
- const eventPlan = planCompositionEvents(cap.def, featurePlan, fields);
43812
- return {
43813
- problems: [
43814
- ...capProblems,
43815
- ...all.flatMap((r) => r.problems),
43816
- ...eventPlan.problems
43817
- ],
43818
- fields,
43819
- features: [featurePlan],
43820
- events: eventPlan.events
43821
- };
43822
- }
43823
- /** A cap the device serves itself; one the composer grafted (an earlier ADD) is not native. */
43824
- function isNativeCap(target, cap) {
43825
- const owner = target.nativeCaps.get(cap);
43826
- return owner !== void 0 && owner !== "composer";
43827
- }
43828
- /** The target read, or the refusal that replaces the whole plan when it could not be (D391). */
43829
- function planTargetOf(composition, target) {
43830
- if (composition.target.kind === "new") return {
43831
- kind: "new",
43832
- type: composition.target.type
43833
- };
43834
- const key = compositionSourceKey(composition.target.device);
43835
- if (target === void 0) return problem("target-unreadable", "target", `target device \`${key}\` was not read — the save is refused rather than stored unchecked`);
43836
- if (target.kind === "absent") return problem("target-missing", "target", `target device \`${key}\` does not exist`);
43837
- if (target.kind === "unreadable") return problem("target-unreadable", "target", `could not read target device \`${key}\` (${target.error}) — the save is refused rather than stored unchecked`);
43838
- return {
43839
- kind: "existing",
43840
- found: target
43841
- };
43842
- }
43843
- function featureTargetOf(target, cap) {
43844
- if (target.kind === "new") return {
43845
- mode: "new",
43846
- deviceType: target.type
43847
- };
43848
- return {
43849
- mode: isNativeCap(target.found, cap) ? "replace" : "add",
43850
- deviceType: target.found.type
43851
- };
44505
+ /** A rise closer than this to the previous one is the same press (D671). */
44506
+ var DOORBELL_RISE_DEBOUNCE_MS = 2e3;
44507
+ /** Every source cap of `source` that `SOURCE_CAP_ACTIVE_FIELD` knows AND the device has bound. */
44508
+ function doorbellFeatureSources(source, boundCaps) {
44509
+ return Object.keys(SOURCE_CAP_ACTIVE_FIELD).filter((cap) => Object.hasOwn(SOURCE_CAP_CHANGED_AT_FIELD, cap) && boundCaps.includes(cap)).toSorted().flatMap((cap) => {
44510
+ const activeField = SOURCE_CAP_ACTIVE_FIELD[cap];
44511
+ const changedAtField = SOURCE_CAP_CHANGED_AT_FIELD[cap];
44512
+ return activeField === void 0 || changedAtField === void 0 ? [] : [{
44513
+ source,
44514
+ cap,
44515
+ activeField,
44516
+ changedAtField
44517
+ }];
44518
+ });
43852
44519
  }
43853
- /**
43854
- * The static plan. `target` describes an EXISTING target device (one read);
43855
- * it is ignored for a new one. On an existing target each feature REPLACES a
43856
- * cap the device has natively, else ADDS it (D663).
43857
- */
43858
- function planComposition(composition, lookupCap, target) {
43859
- const planTarget = planTargetOf(composition, target);
43860
- if ("code" in planTarget) return refused(planTarget);
43861
- const results = composition.features.map((feature, index) => {
43862
- const result = feature.kind === "passthrough" ? refused(problem("reserved-variant", `features[${index}]`, `whole-capability passthrough of \`${feature.cap}\` arrives in slice 3`)) : planFeature(feature, index, featureTargetOf(planTarget, feature.cap), lookupCap);
43863
- return compositionFeatureEnabled(feature) ? result : {
43864
- problems: result.problems,
43865
- fields: [],
43866
- features: [],
43867
- events: []
44520
+ /** The D671 feature, byte for byte what the migration writes. Throws on zero sources. */
44521
+ function buildDoorbellCompositionFeature(input) {
44522
+ const sources = [...input.sources].toSorted((a, b) => `${a.source.addonId}/${a.source.stableId}#${a.cap}`.localeCompare(`${b.source.addonId}/${b.source.stableId}#${b.cap}`));
44523
+ if (sources.length === 0) throw new Error("doorbell composition needs at least one source");
44524
+ const edgeBindings = {};
44525
+ sources.forEach((entry, index) => {
44526
+ edgeBindings[`active${index}`] = {
44527
+ kind: "from",
44528
+ source: entry.source,
44529
+ cap: entry.cap,
44530
+ fieldPath: entry.activeField
44531
+ };
44532
+ edgeBindings[`changed${index}`] = {
44533
+ kind: "from",
44534
+ source: entry.source,
44535
+ cap: entry.cap,
44536
+ fieldPath: entry.changedAtField
43868
44537
  };
43869
44538
  });
43870
44539
  return {
43871
- problems: results.flatMap((r) => r.problems),
43872
- fields: results.flatMap((r) => r.fields),
43873
- features: results.flatMap((r) => r.features),
43874
- events: results.flatMap((r) => r.events)
43875
- };
43876
- }
43877
- function compositionSourceRefs(composition) {
43878
- const refs = composition.features.flatMap((f) => compositionFeatureEnabled(f) && f.kind === "fields" ? featureSources(f).flatMap(fieldReads).map((r) => r.source) : []);
43879
- return [...new Map(refs.map((r) => [compositionSourceKey(r), r])).values()];
43880
- }
43881
- /**
43882
- * The candidate's device: the existing target itself, else `self`, else a
43883
- * placeholder that cannot collide with a stored target. An existing target is
43884
- * known even on create, so a second customization of it is refused on create too.
43885
- */
43886
- function candidateSelf(composition, ctx, plan) {
43887
- return {
43888
- ref: composition.target.kind === "existing" ? composition.target.device : ctx.self ?? PENDING_SELF,
43889
- native: composition.target.kind === "existing" && ctx.target?.kind === "found" ? ctx.target : null,
43890
- replaced: replacedFieldKeys(plan)
43891
- };
43892
- }
43893
- /**
43894
- * `compositionFieldKey` of every field a REPLACE feature of `plan` owns — the
43895
- * candidate's own, and a stored peer's `CompositionPeer.replaced` (D663): one
43896
- * derivation, so the graph reads both the same way.
43897
- */
43898
- function replacedFieldKeys(plan) {
43899
- const replacing = new Set(plan.features.filter((f) => f.mode === "replace").map((f) => f.featureIndex));
43900
- return new Set(plan.fields.filter((f) => replacing.has(f.featureIndex)).map(compositionFieldKey));
43901
- }
43902
- /**
43903
- * A read of the candidate's own device. Its own composed output (a new device's
43904
- * field, an ADDED field) is the slice-1 own-field rule; on an existing target
43905
- * every other field — replaced, or not composed at all — is the NATIVE value,
43906
- * valid when the device serves that cap natively (D663, rule 5).
43907
- */
43908
- function selfReadProblems(read, at, fields, self) {
43909
- const readKey = `${read.cap}.${read.fieldPath}`;
43910
- if (fields.some((f) => compositionFieldKey(f) === readKey && !self.replaced.has(readKey))) return [];
43911
- if (self.native === null) return [problem("source-field-unknown", at, `this device has no composed field \`${read.cap}.${read.fieldPath}\``)];
43912
- if (isNativeCap(self.native, read.cap)) return [];
43913
- return [problem("source-capability-missing", at, `the target device (device ${self.native.deviceId}) has no native \`${read.cap}\``)];
43914
- }
43915
- function sourceProblems(fields, ctx, self) {
43916
- const selfKey = compositionSourceKey(self.ref);
43917
- return fields.flatMap((field) => field.reads.flatMap((read) => {
43918
- const at = compositionFieldAt(field);
43919
- const key = compositionSourceKey(read.source);
43920
- if (key === selfKey) return selfReadProblems(read, at, fields, self);
43921
- const desc = ctx.sources.get(key);
43922
- if (desc === void 0 || desc.kind === "unreadable") return [problem("source-unreadable", at, `could not read source \`${key}\` (${desc === void 0 ? "it was not read" : desc.error}) — the save is refused rather than stored unchecked`)];
43923
- if (desc.kind === "absent") return [problem("source-missing", at, `source \`${key}\` does not exist`)];
43924
- if (!desc.caps.has(read.cap)) return [problem("source-capability-missing", at, `source \`${key}\` (device ${desc.deviceId}) has no \`${read.cap}\``)];
43925
- return [];
43926
- }));
43927
- }
43928
- /**
43929
- * A cycle's node keys are `compositionReadKey` strings:
43930
- * `<addonId>/<stableId>#<cap>.<fieldPath>`. When a node belongs to the
43931
- * CANDIDATE itself (this composition being saved), pull its `cap`/`fieldPath`
43932
- * back out so the refusal can point at that exact field, matching the
43933
- * documented `features[N].fields.<path>` convention. A cap name has no `.`,
43934
- * so splitting `rest` on the FIRST `.` is exact; an item leaf's remainder
43935
- * (`items[desiccant].status`) is matched through `compositionFieldKey`.
43936
- */
43937
- function candidateFieldOfNode(nodeKey, candidateKey) {
43938
- const prefix = `${candidateKey}#`;
43939
- if (!nodeKey.startsWith(prefix)) return null;
43940
- const rest = nodeKey.slice(prefix.length);
43941
- const dot = rest.indexOf(".");
43942
- if (dot === -1) return null;
43943
- return {
43944
- cap: rest.slice(0, dot),
43945
- fieldPath: rest.slice(dot + 1)
43946
- };
43947
- }
43948
- /** The first node of a cycle's path that is one of THIS composition's own
43949
- * planned fields, as a documented-convention path; `'features'` when the
43950
- * cycle runs entirely through other stored compositions. */
43951
- function cyclePath(nodePath, candidateKey, fields) {
43952
- for (const nodeKey of nodePath) {
43953
- const hit = candidateFieldOfNode(nodeKey, candidateKey);
43954
- if (hit === null) continue;
43955
- const hitKey = `${hit.cap}.${hit.fieldPath}`;
43956
- const field = fields.find((f) => compositionFieldKey(f) === hitKey);
43957
- if (field !== void 0) return compositionFieldAt(field);
43958
- }
43959
- return "features";
43960
- }
43961
- /** The first unread device any of `nodes` belongs to; null when none does. */
43962
- function unreadDeviceOf(nodes, unread) {
43963
- for (const [key, error] of unread) if (nodes.some((node) => node.startsWith(`${key}#`))) return {
43964
- key,
43965
- error
43966
- };
43967
- return null;
43968
- }
43969
- /**
43970
- * Cycle check runs against the CANDIDATE (this composition, as block
43971
- * `ctx.blockId`, writing `self.ref`) plus every OTHER stored composition,
43972
- * decided per strongly connected component (`findCandidateCycles`): a cyclic
43973
- * component holding a candidate node refuses, with one representative cycle
43974
- * through it; one without is not this save's cycle. A
43975
- * stored composition of the same block is its old version; another block on
43976
- * the same device, or two stored peers sharing a device, make
43977
- * `compositionEdges` throw `CompositionGraphError` — refused BY NAME as
43978
- * `duplicate-target` (D391, D663) instead of escaping as an uncaught throw.
43979
- */
43980
- function cycleProblems(composition, ctx, fields, self) {
43981
- const candidate = {
43982
- blockId: ctx.blockId ?? "(unsaved)",
43983
- target: self.ref,
43984
- composition,
43985
- replaced: self.replaced
43986
- };
43987
- const candidateKey = compositionSourceKey(candidate.target);
43988
- const unread = ctx.unreadTargets ?? /* @__PURE__ */ new Map();
43989
- try {
43990
- return findCandidateCycles(candidate, ctx.peers).map((c) => {
43991
- const at = cyclePath(c.path, candidateKey, fields);
43992
- const through = unreadDeviceOf([...c.component], unread);
43993
- return through === null ? problem("cycle", at, `a field reads itself: ${c.path.join(" → ")}`) : problem("cycle", at, `possible cycle through \`${through.key}\` — its state could not be read (${through.error}): ${c.path.join(" → ")}`);
43994
- });
43995
- } catch (err) {
43996
- if (err instanceof CompositionGraphError) return [problem("duplicate-target", "features", err.message)];
43997
- throw err;
43998
- }
43999
- }
44000
- function validateComposition(composition, ctx) {
44001
- const plan = planComposition(composition, ctx.lookupCap, ctx.target ?? void 0);
44002
- const self = candidateSelf(composition, ctx, plan);
44003
- const cycles = cycleProblems(composition, ctx, plan.fields, self);
44004
- const problems = [
44005
- ...plan.problems,
44006
- ...sourceProblems(plan.fields, ctx, self),
44007
- ...cycles
44008
- ];
44009
- return {
44010
- ok: problems.length === 0,
44011
- problems,
44012
- fields: [...plan.fields],
44013
- features: [...plan.features],
44014
- events: [...plan.events]
44540
+ kind: "fields",
44541
+ cap: "doorbell",
44542
+ enabled: input.enabled,
44543
+ fields: {
44544
+ lastPressedAt: {
44545
+ kind: "expression",
44546
+ expr: `latest(${sources.map((_, index) => `rose(active${index}, changed${index}, ${DOORBELL_RISE_DEBOUNCE_MS})`).join(", ")})`,
44547
+ bindings: edgeBindings
44548
+ },
44549
+ pressCountSinceStart: {
44550
+ kind: "expression",
44551
+ expr: "count(lastPressedAt)",
44552
+ bindings: { lastPressedAt: {
44553
+ kind: "from",
44554
+ source: {
44555
+ addonId: input.camera.addonId,
44556
+ stableId: input.camera.stableId
44557
+ },
44558
+ cap: "doorbell",
44559
+ fieldPath: "lastPressedAt"
44560
+ } }
44561
+ }
44562
+ }
44015
44563
  };
44016
44564
  }
44017
44565
  /**
@@ -48031,6 +48579,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
48031
48579
  addonId: null,
48032
48580
  access: "view"
48033
48581
  },
48582
+ "coreBlocks.listComposableCaps": {
48583
+ capName: "core-blocks",
48584
+ capScope: "system",
48585
+ addonId: null,
48586
+ access: "view"
48587
+ },
48034
48588
  "coreBlocks.listCompositions": {
48035
48589
  capName: "core-blocks",
48036
48590
  capScope: "system",
@@ -50845,6 +51399,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
50845
51399
  addonId: null,
50846
51400
  access: "create"
50847
51401
  },
51402
+ "pipelineAnalytics.clearTrackRelation": {
51403
+ capName: "pipeline-analytics",
51404
+ capScope: "device",
51405
+ addonId: null,
51406
+ access: "delete"
51407
+ },
50848
51408
  "pipelineAnalytics.clearTracks": {
50849
51409
  capName: "pipeline-analytics",
50850
51410
  capScope: "device",
@@ -51097,6 +51657,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
51097
51657
  addonId: null,
51098
51658
  access: "view"
51099
51659
  },
51660
+ "pipelineAnalytics.listTrackRelations": {
51661
+ capName: "pipeline-analytics",
51662
+ capScope: "device",
51663
+ addonId: null,
51664
+ access: "view"
51665
+ },
51100
51666
  "pipelineAnalytics.listTracks": {
51101
51667
  capName: "pipeline-analytics",
51102
51668
  capScope: "device",
@@ -51211,6 +51777,18 @@ var METHOD_ACCESS_MAP = Object.freeze({
51211
51777
  addonId: null,
51212
51778
  access: "create"
51213
51779
  },
51780
+ "pipelineAnalytics.setTrackPurity": {
51781
+ capName: "pipeline-analytics",
51782
+ capScope: "device",
51783
+ addonId: null,
51784
+ access: "create"
51785
+ },
51786
+ "pipelineAnalytics.setTrackRelation": {
51787
+ capName: "pipeline-analytics",
51788
+ capScope: "device",
51789
+ addonId: null,
51790
+ access: "create"
51791
+ },
51214
51792
  "pipelineAnalytics.startStorageMigrationMove": {
51215
51793
  capName: "pipeline-analytics",
51216
51794
  capScope: "device",
@@ -51715,6 +52293,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
51715
52293
  addonId: null,
51716
52294
  access: "create"
51717
52295
  },
52296
+ "pipelineOrchestrator.setCameraSensors": {
52297
+ capName: "pipeline-orchestrator",
52298
+ capScope: "system",
52299
+ addonId: null,
52300
+ access: "create"
52301
+ },
51718
52302
  "pipelineOrchestrator.setCameraStepOverride": {
51719
52303
  capName: "pipeline-orchestrator",
51720
52304
  capScope: "system",
@@ -55125,6 +55709,11 @@ Object.freeze({
55125
55709
  form: "single",
55126
55710
  optional: false
55127
55711
  }],
55712
+ "pipelineAnalytics.clearTrackRelation": [{
55713
+ name: "deviceId",
55714
+ form: "single",
55715
+ optional: false
55716
+ }],
55128
55717
  "pipelineAnalytics.clearTracks": [{
55129
55718
  name: "deviceId",
55130
55719
  form: "single",
@@ -55275,6 +55864,11 @@ Object.freeze({
55275
55864
  form: "single",
55276
55865
  optional: false
55277
55866
  }],
55867
+ "pipelineAnalytics.listTrackRelations": [{
55868
+ name: "deviceId",
55869
+ form: "single",
55870
+ optional: false
55871
+ }],
55278
55872
  "pipelineAnalytics.listTracks": [{
55279
55873
  name: "deviceId",
55280
55874
  form: "single",
@@ -55345,6 +55939,16 @@ Object.freeze({
55345
55939
  form: "single",
55346
55940
  optional: false
55347
55941
  }],
55942
+ "pipelineAnalytics.setTrackPurity": [{
55943
+ name: "deviceId",
55944
+ form: "single",
55945
+ optional: false
55946
+ }],
55947
+ "pipelineAnalytics.setTrackRelation": [{
55948
+ name: "deviceId",
55949
+ form: "single",
55950
+ optional: false
55951
+ }],
55348
55952
  "pipelineAnalytics.wipeAllAnalytics": [{
55349
55953
  name: "deviceId",
55350
55954
  form: "single",
@@ -55425,6 +56029,11 @@ Object.freeze({
55425
56029
  form: "single",
55426
56030
  optional: false
55427
56031
  }],
56032
+ "pipelineOrchestrator.setCameraSensors": [{
56033
+ name: "deviceId",
56034
+ form: "single",
56035
+ optional: false
56036
+ }],
55428
56037
  "pipelineOrchestrator.setCameraStepOverride": [{
55429
56038
  name: "deviceId",
55430
56039
  form: "single",
@@ -57780,18 +58389,6 @@ Object.defineProperty(exports, "SOURCE_CAPS", {
57780
58389
  return SOURCE_CAPS;
57781
58390
  }
57782
58391
  });
57783
- Object.defineProperty(exports, "SOURCE_CAP_ACTIVE_FIELD", {
57784
- enumerable: true,
57785
- get: function() {
57786
- return SOURCE_CAP_ACTIVE_FIELD;
57787
- }
57788
- });
57789
- Object.defineProperty(exports, "SOURCE_CAP_CHANGED_AT_FIELD", {
57790
- enumerable: true,
57791
- get: function() {
57792
- return SOURCE_CAP_CHANGED_AT_FIELD;
57793
- }
57794
- });
57795
58392
  Object.defineProperty(exports, "STORAGE_ACCESS_FALLBACK", {
57796
58393
  enumerable: true,
57797
58394
  get: function() {
@@ -57918,6 +58515,12 @@ Object.defineProperty(exports, "buildCapabilityLookup", {
57918
58515
  return buildCapabilityLookup;
57919
58516
  }
57920
58517
  });
58518
+ Object.defineProperty(exports, "buildDoorbellCompositionFeature", {
58519
+ enumerable: true,
58520
+ get: function() {
58521
+ return buildDoorbellCompositionFeature;
58522
+ }
58523
+ });
57921
58524
  Object.defineProperty(exports, "buildFfmpegArgs", {
57922
58525
  enumerable: true,
57923
58526
  get: function() {
@@ -58080,6 +58683,12 @@ Object.defineProperty(exports, "deriveBatteryPresence", {
58080
58683
  return deriveBatteryPresence;
58081
58684
  }
58082
58685
  });
58686
+ Object.defineProperty(exports, "describeComposableCaps", {
58687
+ enumerable: true,
58688
+ get: function() {
58689
+ return describeComposableCaps;
58690
+ }
58691
+ });
58083
58692
  Object.defineProperty(exports, "deviceExtensionCapability", {
58084
58693
  enumerable: true,
58085
58694
  get: function() {
@@ -58110,6 +58719,12 @@ Object.defineProperty(exports, "doorbellCapability", {
58110
58719
  return doorbellCapability;
58111
58720
  }
58112
58721
  });
58722
+ Object.defineProperty(exports, "doorbellFeatureSources", {
58723
+ enumerable: true,
58724
+ get: function() {
58725
+ return doorbellFeatureSources;
58726
+ }
58727
+ });
58113
58728
  Object.defineProperty(exports, "emitDownForOwnedCaps", {
58114
58729
  enumerable: true,
58115
58730
  get: function() {