@camstack/addon-training 0.1.39 → 0.1.40

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 (24) hide show
  1. package/dist/{MotionZonesSettings-iIiZDnpO.mjs → MotionZonesSettings-DBtAXpEm.mjs} +3 -3
  2. package/dist/{PrivacyMaskSettings-GGC5DLrH.mjs → PrivacyMaskSettings-C3tyM8s9.mjs} +4 -4
  3. package/dist/{SceneMonitorEditor-Gp7E6FmA.mjs → SceneMonitorEditor-D6Q3z17B.mjs} +3 -3
  4. package/dist/_stub.js +12 -12
  5. package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_training_page-DkawXJWO.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_training_page-CHYHBYUh.mjs} +3 -3
  6. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-NCFNuw0Q.mjs +26 -0
  7. package/dist/{dist-LW5yeOfO.js → dist-CJXTMYT2.js} +2247 -2189
  8. package/dist/{dist-B16l7qTN.mjs → dist-CbTzxq0W.mjs} +2247 -2189
  9. package/dist/{grid-2x2-NIbAf-wK.mjs → grid-2x2-tkDBCRad.mjs} +1 -1
  10. package/dist/{hostInit-C2I98JXi.mjs → hostInit-GIXFhqMx.mjs} +2 -2
  11. package/dist/index.js +1 -1
  12. package/dist/orchestrator/training.addon.js +1 -1
  13. package/dist/orchestrator/training.addon.mjs +1 -1
  14. package/dist/{player-overlays-mAUn9LHY.mjs → player-overlays-DMLlEwmn.mjs} +1 -1
  15. package/dist/providers/modal/training-modal.addon.js +1 -1
  16. package/dist/providers/modal/training-modal.addon.mjs +1 -1
  17. package/dist/remoteEntry.js +1 -1
  18. package/dist/{responsive-BmIw_DBf.mjs → responsive-C0tM8Z6S.mjs} +1 -1
  19. package/dist/{scene-monitor-copy-CCWIarlv.mjs → scene-monitor-copy-B-6xPmDm.mjs} +1 -1
  20. package/dist/{square-Cd1vvVxW.mjs → square-D49AvsjC.mjs} +1 -1
  21. package/dist/{trash-2-CrYktsbl.mjs → trash-2-BwTJ2h2p.mjs} +1 -1
  22. package/dist/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_training_page__remoteEntry_js-BS6dEUfW.mjs → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_training_page__remoteEntry_js-B3ZFopEz.mjs} +1 -1
  23. package/package.json +1 -1
  24. package/dist/_virtual_mf___mfe_internal__addon_training_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DNqfSv-A.mjs +0 -26
@@ -13041,2432 +13041,2464 @@ method(object({
13041
13041
  password: string()
13042
13042
  }), AuthResultSchema.nullable(), { kind: "mutation" }), method(object({ state: string() }), string()), method(record(string(), string()), AuthResultSchema, { kind: "mutation" }), method(object({ token: string() }), AuthResultSchema.nullable());
13043
13043
  /**
13044
- * How long one snapshot may take end to end: the pinned copy of the whole
13045
- * database plus `PRAGMA integrity_check` over the copy, on the hub's slowest
13046
- * disk. ~800 MB at a pessimistic 10 MB/s is under 3 minutes; the bound is
13047
- * generous because a timeout fails the whole backup run.
13044
+ * Query filter for settings-store collections.
13048
13045
  */
13049
- var DATABASE_SNAPSHOT_TIMEOUT_MS = 30 * 6e4;
13046
+ var QueryFilterSchema = object({
13047
+ where: record(string(), unknown()).optional(),
13048
+ whereIn: record(string(), array(unknown())).optional(),
13049
+ whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
13050
+ /** NULL-safe exclusion: matches rows whose field is NULL OR != the value. */
13051
+ whereNot: record(string(), unknown()).optional(),
13052
+ /** NULL-safe exclusion of a SET — `whereNot` for more than one value. An
13053
+ * empty list excludes nothing. See `QueryFilter.whereNotIn`. */
13054
+ whereNotIn: record(string(), array(unknown())).optional(),
13055
+ orderBy: object({
13056
+ field: string(),
13057
+ direction: _enum(["asc", "desc"])
13058
+ }).optional(),
13059
+ limit: number().optional(),
13060
+ offset: number().optional()
13061
+ });
13050
13062
  /**
13051
- * The databases an owner snapshots — declared by the OWNER, so a caller never
13052
- * guesses which file is live from what sits beside it (a cleanly closed
13053
- * database has no sidecars and is just as owned). Paths are absolute on the
13054
- * owner's host.
13063
+ * The predicate half of a filter, for BULK MUTATIONS.
13064
+ *
13065
+ * Deliberately not `QueryFilterSchema`: `orderBy` / `limit` / `offset` have no
13066
+ * meaning for a statement that rewrites a set, and accepting them would invite
13067
+ * a caller to believe `limit` bounds the damage. Every field is optional here
13068
+ * only so the shape stays composable — the implementation REJECTS a filter
13069
+ * that compiles to no predicate, because that is the whole collection.
13055
13070
  */
13056
- var DatabaseSnapshotTargetsSchema = object({ databases: array(object({ livePath: string() })) });
13057
- /** One b-tree table of the snapshot and the rows it holds. */
13058
- var DatabaseSnapshotTableSchema = object({
13059
- name: string(),
13060
- rows: number().int().nonnegative()
13071
+ var MutationFilterSchema = object({
13072
+ where: record(string(), unknown()).optional(),
13073
+ whereIn: record(string(), array(unknown())).optional(),
13074
+ whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
13075
+ whereNot: record(string(), unknown()).optional(),
13076
+ whereNotIn: record(string(), array(unknown())).optional()
13061
13077
  });
13062
13078
  /**
13063
- * What the owner wrote. Every path is ABSOLUTE on the owner's host — the
13064
- * caller pins the call to its own node, so it can read the file; a caller
13065
- * that finds the path outside its own data root refuses it rather than
13066
- * archiving something it cannot place.
13079
+ * One scalar an {@link settingsStoreCapability.methods.aggregate} call asks for.
13080
+ *
13081
+ * `as` names the slot in the result, so the SAME column may be asked twice with
13082
+ * two operations (`MIN(startMs)` and `MAX(startMs)` in one round trip) — which
13083
+ * a `Record<column, op>` shape could not express.
13067
13084
  */
13068
- var DatabaseSnapshotResultSchema = object({
13069
- /** The live database the snapshot copies. Never to be copied as a file. */
13070
- livePath: string(),
13071
- /** The verified copy: `<livePath>` + {@link DATABASE_SNAPSHOT_SUFFIX}. */
13072
- snapshotPath: string(),
13073
- sizeBytes: number().int().nonnegative(),
13074
- pageCount: number().int().nonnegative(),
13075
- pageSize: number().int().positive(),
13076
- /**
13077
- * Every b-tree table with its row count. Read inside the SAME read
13078
- * transaction the copy was taken from, and found EQUAL on the copy — so
13079
- * this is the content of the snapshot, not an estimate of the source.
13080
- */
13081
- tables: array(DatabaseSnapshotTableSchema),
13082
- /** Wall time of the pinned copy (row counts included). */
13083
- copyMs: number().nonnegative(),
13084
- /** Wall time of the checks on the copy (`integrity_check`, row counts). */
13085
- verifyMs: number().nonnegative(),
13086
- /** When the pinned read transaction began (ms epoch) — the snapshot's instant. */
13087
- takenAt: number()
13085
+ var AggregateFieldSchema = object({
13086
+ /** Result key. */
13087
+ as: string().min(1),
13088
+ /** Column to aggregate. Must be a real column of a declared collection. */
13089
+ field: string().min(1),
13090
+ op: _enum([
13091
+ "sum",
13092
+ "min",
13093
+ "max"
13094
+ ])
13088
13095
  });
13089
13096
  /**
13090
- * A snapshot as an ARCHIVE records it (`ArchiveManifest.sqliteSnapshots`):
13091
- * the same facts, with both paths relative to the archive root (the node's
13092
- * data dir), because an archive outlives the host that made it.
13097
+ * `COUNT(*)` plus one number per requested field.
13098
+ *
13099
+ * `null` means NO ROW MATCHED, never zero: a `SUM` over an empty set and a sum
13100
+ * that really is 0 are different facts, and an accounting caller that renders
13101
+ * "0 bytes, oldest = 0" for "nothing here" reports a lie about a disk.
13093
13102
  */
13094
- var ArchiveSqliteSnapshotSchema = DatabaseSnapshotResultSchema.extend({
13095
- /** Archive-relative path of the live database the archive does NOT carry. */
13096
- livePath: string(),
13097
- /** Archive-relative path of the verified copy it carries in its place. */
13098
- snapshotPath: string()
13103
+ var AggregateResultSchema = object({
13104
+ count: number().int(),
13105
+ values: record(string(), number().nullable())
13099
13106
  });
13100
- method(object({}), DatabaseSnapshotTargetsSchema, { auth: "admin" }), method(object({}), DatabaseSnapshotResultSchema, {
13101
- kind: "mutation",
13102
- auth: "admin",
13103
- timeoutMs: DATABASE_SNAPSHOT_TIMEOUT_MS
13107
+ /** A single stored record: `{ id, data }`. */
13108
+ var SettingsRecordSchema = object({
13109
+ id: string(),
13110
+ data: record(string(), unknown())
13104
13111
  });
13105
13112
  /**
13106
- * Orchestrator-side destination metadata. The orchestrator computes
13107
- * `id = <addonId>:<subId>` from its provider lookup so consumers
13108
- * (admin UI, restore flow) see one canonical key.
13109
- *
13110
- * Phase 4 (admin UI redesign) adds the per-destination policy fields
13111
- * (`enabled`, `retentionCount`, `label`) so the destinations table can
13112
- * render the joined view without a follow-up round-trip. The backend
13113
- * already joins these in `listDestinations` — we now surface them on
13114
- * the wire.
13113
+ * One record of a BULK insert — {@link SettingsRecordSchema} with the id made
13114
+ * optional.
13115
13115
  *
13116
- * `lastSuccessAt` / `lastSuccessSizeBytes` are computed from each
13117
- * location's `manifests.json` (newest archive). Optional — locations
13118
- * with no archives yet leave them undefined.
13116
+ * A separate schema rather than loosening the shared one: every other method
13117
+ * on this cap addresses a row BY its id, and making that field optional
13118
+ * everywhere would turn a forgotten key into a silently generated one on
13119
+ * `update` and `delete` as well.
13119
13120
  */
13120
- var BackupDestinationInfoSchema = object({
13121
- /**
13122
- * Sub-id within this addon. Convention `default` for single-
13123
- * destination addons; addons that host many (S3 with N buckets)
13124
- * pick stable per-bucket strings. The orchestrator stitches
13125
- * `<manifestId>:<subId>` for the wire format.
13126
- */
13127
- subId: string(),
13128
- displayName: string(),
13129
- description: string().optional(),
13130
- kind: string(),
13131
- triggerSupported: boolean(),
13132
- restoreSupported: boolean()
13133
- }).extend({
13134
- /** `<addonId>:<subId>` — globally-unique dispatch key. */
13135
- id: string(),
13136
- /**
13137
- * Manifest id of the owning addon. The Settings button on the
13138
- * destination card opens that addon's `globalSettingsSchema` panel.
13139
- */
13140
- addonId: string(),
13141
- /** Operator-toggled enable flag from the destination-policy table. */
13142
- enabled: boolean(),
13143
- /** Operator-defined retention count (archives kept per destination). */
13144
- retentionCount: number(),
13145
- /** Operator-defined display label (overrides storage location displayName). */
13146
- label: string().optional(),
13147
- /** Newest-archive timestamp from `manifests.json`, or undefined. */
13148
- lastSuccessAt: number().optional(),
13149
- /** Newest-archive size from `manifests.json`, or undefined. */
13150
- lastSuccessSizeBytes: number().optional(),
13151
- /**
13152
- * Cron cadence(s) of the ENABLED schedules that fan out to this
13153
- * destination, comma-joined. Absent when no enabled schedule targets it
13154
- * — a destination nothing is scheduled to write to must not advertise a
13155
- * cadence (D384). This is never the `backup_destination_policies.cron`
13156
- * column: that per-location cron has scheduled nothing since 2026-07-28
13157
- * and reading it made a destination with a DISABLED schedule claim a
13158
- * nightly run.
13159
- */
13160
- cron: string().optional(),
13161
- /** ms-epoch of the next firing across those schedules (earliest), if any. */
13162
- nextRunAt: number().optional(),
13163
- /**
13164
- * ms-epoch of the last time a run ATTEMPTED to write here — success or
13165
- * failure. Never a success stamp: pair it with `lastSuccessAt` (the
13166
- * newest archive that actually landed) and `lastError`.
13167
- */
13168
- lastAttemptAt: number().optional(),
13169
- /**
13170
- * Why the last attempt failed, verbatim. Absent when the last attempt
13171
- * landed the archive. A destination that has never been written to has
13172
- * neither this nor `lastAttemptAt`.
13173
- */
13174
- lastError: string().optional()
13121
+ var BulkRecordSchema = object({
13122
+ id: string().optional(),
13123
+ data: record(string(), unknown())
13175
13124
  });
13176
13125
  /**
13177
- * How an archive is encrypted (operator decision 2026-10-09): to a backup
13178
- * PUBLIC key the hub holds, so only the operator's secret key opens it.
13179
- * Absent on a plain `.tar.gz` — every archive made before encryption, or
13180
- * with it off — which restores without a key.
13126
+ * The database FILES one `sqlite-settings` engine process owns (D851).
13127
+ * Closed on purpose: a file is a thing the engine opens, backs up and gives a
13128
+ * serving thread, so a new one is a code change, never a string a caller
13129
+ * invents. `settings` is `camstack.db` and the default for every collection
13130
+ * that names no file.
13181
13131
  */
13182
- var BackupArchiveEncryptionSchema = object({
13183
- /** `x25519-hkdf-aes256gcm-stream-v1`. */
13184
- scheme: string(),
13185
- /** 16 hex — the backup key whose secret restores this archive. */
13186
- keyId: string()
13187
- });
13132
+ var DataStoreDatabaseSchema = _enum([
13133
+ "settings",
13134
+ "events",
13135
+ "history"
13136
+ ]);
13137
+ DataStoreDatabaseSchema.options;
13188
13138
  /**
13189
- * The backup encryption setting. The hub keeps PUBLIC keys only: the active
13190
- * one new archives are encrypted to, and the retired ones older archives
13191
- * still need. The secret key is shown once, at `setupEncryption`.
13139
+ * One write inside a {@link settingsStoreCapability.methods.transact} call.
13140
+ * The three that a "replace this set, bump that revision" write needs, plus
13141
+ * the batched upsert a rollup writer needs.
13192
13142
  */
13193
- var BackupEncryptionStatusSchema = object({
13194
- enabled: boolean(),
13195
- active: object({
13196
- keyId: string(),
13197
- publicKey: string(),
13198
- createdAt: number()
13199
- }).nullable(),
13200
- /** Every key ever set up, newest first. */
13201
- keys: array(object({
13202
- keyId: string(),
13203
- createdAt: number(),
13204
- retiredAt: number().nullable()
13205
- })).readonly()
13206
- });
13143
+ var TransactOpSchema = discriminatedUnion("op", [
13144
+ object({
13145
+ op: literal("deleteWhere"),
13146
+ collection: string(),
13147
+ filter: MutationFilterSchema
13148
+ }),
13149
+ object({
13150
+ op: literal("insertMany"),
13151
+ collection: string(),
13152
+ records: array(BulkRecordSchema).readonly()
13153
+ }),
13154
+ object({
13155
+ op: literal("update"),
13156
+ collection: string(),
13157
+ id: string(),
13158
+ data: record(string(), unknown())
13159
+ }),
13160
+ object({
13161
+ op: literal("upsertMany"),
13162
+ collection: string(),
13163
+ conflict: array(string()).readonly(),
13164
+ records: array(BulkRecordSchema).readonly()
13165
+ })
13166
+ ]);
13207
13167
  /**
13208
- * Per-archive entry returned by `backup.listArchives({ destinationId })`.
13209
- * Same shape the destination drill-in renders. Sourced from the per-
13210
- * location `manifests.json` (no per-archive tarball read required).
13168
+ * The precondition of a transaction: the row `key` of `collection` must hold
13169
+ * `equals` in `column`, read INSIDE the transaction.
13211
13170
  */
13212
- var BackupArchiveEntrySchema = object({
13213
- id: string(),
13214
- filename: string(),
13215
- createdAt: number(),
13216
- sizeBytes: number(),
13217
- label: string().optional(),
13218
- /** Top-level locations included in the archive (db, addons, tls, …). */
13219
- locations: array(string()).readonly(),
13220
- /** Present when the archive is encrypted; restore then needs that key. */
13221
- encryption: BackupArchiveEncryptionSchema.optional()
13222
- });
13223
- var BackupEntrySchema = object({
13224
- id: string(),
13225
- /** Addon id of the destination that owns this backup (e.g. `local-backup`, `s3-backup`). */
13226
- destinationId: string().optional(),
13227
- label: string().optional(),
13228
- createdAt: number(),
13229
- sizeBytes: number(),
13230
- locations: array(string()).optional(),
13231
- encryption: BackupArchiveEncryptionSchema.optional()
13232
- });
13233
- var ArchiveEntrySchema = object({
13234
- path: string(),
13235
- kind: _enum([
13236
- "file",
13237
- "dir",
13238
- "symlink"
13239
- ]),
13240
- sizeBytes: number(),
13241
- mtime: number()
13242
- });
13243
- var ArchiveManifestSchema = object({
13244
- archiveVersion: literal(1),
13245
- createdAt: number(),
13246
- dataDir: string(),
13247
- locations: array(string()),
13248
- entries: array(ArchiveEntrySchema),
13249
- totalBytes: number(),
13250
- totalFiles: number(),
13251
- /**
13252
- * Live SQLite databases this archive carries as their OWNER's verified
13253
- * snapshot instead of as files (D693). Absent on archives made before.
13254
- */
13255
- sqliteSnapshots: array(ArchiveSqliteSnapshotSchema).optional()
13256
- });
13257
- var LocationStatSchema = object({
13258
- name: string(),
13259
- sizeBytes: number(),
13260
- fileCount: number(),
13261
- present: boolean()
13171
+ var TransactGuardSchema = object({
13172
+ collection: string(),
13173
+ key: string(),
13174
+ column: string(),
13175
+ equals: unknown()
13262
13176
  });
13263
- /** Lifecycle of a backup run. Terminal states: succeeded / failed / cancelled. */
13264
- var BackupRunStateSchema = _enum([
13265
- "queued",
13266
- "running",
13267
- "succeeded",
13268
- "failed",
13269
- "cancelled"
13270
- ]);
13271
- /**
13272
- * Where a running backup currently is. `queued` before it starts,
13273
- * `building` while the tar.gz is being staged, `uploading` during the
13274
- * per-destination fan-out, `done` once terminal.
13275
- */
13276
- var BackupRunPhaseSchema = _enum([
13277
- "queued",
13278
- "building",
13279
- "uploading",
13280
- "done"
13281
- ]);
13282
13177
  /**
13283
- * Observable state of one backup run — readable WHILE it runs via
13284
- * `backup.listRuns`. This is what makes the execution queue and
13285
- * `backup.cancel` usable: the 2026-09-04 incident (two concurrent
13286
- * multi-GB builds, staging 5.1 GB → 18 GB, load 62) was only
13287
- * diagnosable with `du` because nothing reported that runs existed or
13288
- * how large the staged archive had grown.
13178
+ * Column declaration for a structured (SQL-backed) collection.
13179
+ *
13180
+ * Logical types — the backend translates each to the matching SQLite
13181
+ * storage class and handles per-type marshaling:
13182
+ * - `TEXT` / `INTEGER` / `REAL` — native SQLite types, pass-through
13183
+ * - `JSON` — TEXT under the hood; serialised on write, parsed on read
13184
+ * - `BOOLEAN` — INTEGER 0/1 under the hood; coerced both directions
13289
13185
  */
13290
- var BackupRunSchema = object({
13291
- /** Stable run id — the handle `backup.cancel` takes. */
13292
- id: string(),
13293
- state: BackupRunStateSchema,
13294
- phase: BackupRunPhaseSchema,
13295
- /**
13296
- * Resolved destination location ids. Empty while queued (targets are
13297
- * resolved when the run starts, against the then-current policies).
13298
- */
13299
- destinationIds: array(string()).readonly(),
13300
- label: string().optional(),
13301
- /** ms-epoch when the run was submitted (trigger call / schedule fire). */
13302
- requestedAt: number(),
13303
- /** ms-epoch when the run left the queue and started building. */
13304
- startedAt: number().optional(),
13305
- /** ms-epoch when the run reached a terminal state. */
13306
- finishedAt: number().optional(),
13307
- /** Compressed bytes of the staging archive written so far. */
13308
- stagedBytes: number(),
13309
- /** Final staged archive size, once the build phase completes. */
13310
- archiveSizeBytes: number().optional(),
13311
- /** Bytes pushed to the destination currently uploading. */
13312
- uploadedBytes: number(),
13313
- /** Destinations where the archive fully landed (uploaded + indexed). */
13314
- completedDestinationIds: array(string()).readonly(),
13315
- /** Destinations that failed during the fan-out. */
13316
- failedDestinationIds: array(string()).readonly(),
13317
- /** Failure message when `state === 'failed'`. */
13318
- error: string().optional(),
13186
+ var CollectionColumnSchema = object({
13187
+ name: string(),
13188
+ type: _enum([
13189
+ "TEXT",
13190
+ "INTEGER",
13191
+ "REAL",
13192
+ "JSON",
13193
+ "BOOLEAN"
13194
+ ]),
13195
+ primaryKey: boolean().optional(),
13196
+ notNull: boolean().optional(),
13197
+ unique: boolean().optional(),
13319
13198
  /**
13320
- * 1-based place in the execution queue — 1 = runs next. Present only
13321
- * while `state === 'queued'`. Stamped by the orchestrator from the
13322
- * queue's OWN pending order, never derived from timestamps, so the
13323
- * UI cannot show an order the executor will not honour.
13199
+ * Column DEFAULT. Required for B2: the four `ensureTable` consumers declare
13200
+ * columns like `enabled INTEGER NOT NULL DEFAULT 1`, and without this the
13201
+ * collection surface simply cannot express their existing tables — which is
13202
+ * why they were still on the uncapped `table*` API.
13324
13203
  */
13325
- queuePosition: number().int().min(1).optional()
13204
+ defaultValue: union([
13205
+ string(),
13206
+ number(),
13207
+ boolean()
13208
+ ]).optional()
13326
13209
  });
13327
- /**
13328
- * Result of `backup.trigger`. The call still resolves when the run
13329
- * terminates (compat with schedule-driven runs and the admin UI), but
13330
- * it now names the run and says whether it had to WAIT: a trigger that
13331
- * arrives while another run is in flight is enqueued (or joined onto
13332
- * an identical already-queued run), never started concurrently.
13333
- */
13334
- var BackupTriggerResultSchema = object({
13335
- /** The run this trigger mapped to — poll it via `listRuns`, stop it via `cancel`. */
13336
- runId: string(),
13337
- /** True when the run waited behind an in-flight run instead of starting immediately. */
13338
- queued: boolean(),
13339
- /** True when this trigger was coalesced onto an identical already-queued run. */
13340
- joined: boolean(),
13341
- /** True when the run was cancelled before completing every destination. */
13342
- cancelled: boolean(),
13343
- /** One entry per destination the archive landed at (partial on cancel). */
13344
- entries: array(BackupEntrySchema).readonly()
13210
+ var CollectionIndexSchema = object({
13211
+ name: string(),
13212
+ columns: array(string()).readonly(),
13213
+ unique: boolean().optional()
13345
13214
  });
13346
13215
  /**
13347
- * A backup schedule — the N:M "entry" that binds one cron cadence to a
13348
- * SET of destination locations. Supersedes the per-location cron on
13349
- * `BackupDestinationPolicy`: an operator creates a schedule, picks the
13350
- * `backups` locations it should write to, and the orchestrator fans a
13351
- * single archive out to all of them when the cron fires.
13352
- *
13353
- * `retentionCount` is per-schedule (D-decision 2026-07-28): every
13354
- * location targeted by this schedule keeps this many archives from
13355
- * this schedule's runs.
13216
+ * The input of `declareCollection` — ONE schema for the door and for the
13217
+ * engine behind it, so the two surfaces cannot drift (the dispatcher forwards
13218
+ * the payload untouched).
13356
13219
  *
13357
- * `dataSources` optionally narrows which top-level state locations
13358
- * (db, addons, tls, …) are archived; omitted = the orchestrator's
13359
- * default full set.
13220
+ * - `database` — the file the collection lives in (D851). Absent means
13221
+ * `settings`. A declaration naming a file other than the one the table is
13222
+ * already in is REFUSED by the engine, never moved.
13223
+ * - `primaryKey` — a COMPOSITE key, as column names. A single name is the same
13224
+ * as `primaryKey: true` on that column. A composite-keyed collection has no
13225
+ * `id`: it is written with `insertMany` / `upsertMany` / `transact` and read
13226
+ * with `query` / `count` / `aggregate` / `deleteWhere`; the calls that
13227
+ * address one row by id are refused.
13228
+ * - `withoutRowid` — emit `WITHOUT ROWID`. Requires a primary key.
13360
13229
  */
13361
- var BackupScheduleSchema = object({
13362
- /** Stable id. Generated by the orchestrator on first upsert if absent. */
13363
- id: string(),
13364
- /** Operator-facing display name. */
13365
- label: string(),
13366
- /** 5-field POSIX cron. Empty = disabled cadence (kept for editing). */
13367
- cron: string(),
13368
- /** Master on/off toggle for the whole schedule. */
13369
- enabled: boolean(),
13370
- /** `backups`-location ids this schedule writes to (fan-out set). */
13371
- locationIds: array(string()).readonly(),
13372
- /** Archives kept per targeted location for this schedule. */
13373
- retentionCount: number().int().min(1).max(1e3),
13374
- /** Optional subset of source locations to include; omitted = all. */
13375
- dataSources: array(string()).readonly().optional(),
13376
- /**
13377
- * ms-epoch of the last tick that FIRED this schedule. Stamped before the
13378
- * archive runs (it is the dedupe anchor), so it says "attempted", never
13379
- * "succeeded" — a run refused by every destination stamps it too.
13380
- */
13381
- lastAttemptAt: number().optional(),
13382
- /** ms-epoch of the last run of this schedule that landed at ≥1 destination. */
13383
- lastSuccessAt: number().optional(),
13384
- /** Why the last fired run failed, verbatim. Absent when it succeeded. */
13385
- lastError: string().optional(),
13386
- /** ms-epoch of next computed firing (read-only, filled on list). */
13387
- nextRunAt: number().optional()
13230
+ var DeclareCollectionInputSchema = object({
13231
+ namespace: string().optional(),
13232
+ collection: string(),
13233
+ columns: array(CollectionColumnSchema).readonly(),
13234
+ indexes: array(CollectionIndexSchema).readonly().optional(),
13235
+ database: DataStoreDatabaseSchema.optional(),
13236
+ primaryKey: array(string()).readonly().optional(),
13237
+ withoutRowid: boolean().optional()
13388
13238
  });
13389
- method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }), method(object({
13390
- /** Subset of registered `backup-destination` addon ids to write to. */
13391
- destinations: array(string()).optional(),
13392
- locations: array(string()).optional(),
13393
- label: string().optional(),
13394
- /**
13395
- * Per-run retention override applied to every targeted
13396
- * destination. Used by schedule-driven runs (per-entry
13397
- * retention). Omitted = each destination's own policy
13398
- * retention (manual runs).
13399
- */
13400
- retentionCount: number().int().min(1).max(1e3).optional()
13401
- }).optional(), BackupTriggerResultSchema, {
13239
+ method(object({
13240
+ namespace: string().optional(),
13241
+ collection: string(),
13242
+ key: string()
13243
+ }), unknown(), { auth: "admin" }), method(object({
13244
+ namespace: string().optional(),
13245
+ collection: string(),
13246
+ key: string(),
13247
+ value: unknown()
13248
+ }), _void(), {
13402
13249
  kind: "mutation",
13403
13250
  auth: "admin"
13404
- }), method(_void(), array(BackupRunSchema).readonly(), { auth: "admin" }), method(object({ runId: string() }), object({ cancelled: boolean() }), {
13251
+ }), method(object({
13252
+ namespace: string().optional(),
13253
+ collection: string(),
13254
+ filter: QueryFilterSchema.optional(),
13255
+ columns: array(string()).readonly().optional()
13256
+ }), array(SettingsRecordSchema).readonly(), { auth: "admin" }), method(object({
13257
+ namespace: string().optional(),
13258
+ collection: string(),
13259
+ /** Declared columns identifying the slot; must be covered by a UNIQUE
13260
+ * index on the collection. Never empty. */
13261
+ conflict: array(string()).readonly(),
13262
+ record: BulkRecordSchema
13263
+ }), object({
13264
+ /** The id the row HAS: assigned by SQLite on an insert, or the existing
13265
+ * row's own id on an update. */
13266
+ id: union([string(), number()]) }), {
13405
13267
  kind: "mutation",
13406
13268
  auth: "admin"
13407
- }), method(_void(), array(BackupEntrySchema).readonly(), { auth: "admin" }), method(_void(), array(LocationStatSchema).readonly(), { auth: "admin" }), method(object({
13408
- destinationId: string(),
13409
- backupId: string()
13410
- }), ArchiveManifestSchema.nullable(), { auth: "admin" }), method(object({
13411
- destinationId: string(),
13412
- backupId: string(),
13413
- /**
13414
- * Optional whitelist — only restore these top-level locations
13415
- * from the archive. Default = every location the archive
13416
- * carries (full restore). The boot-time apply hook reads
13417
- * this list and skips entries whose path doesn't start with
13418
- * any of them.
13419
- */
13420
- locations: array(string()).optional(),
13421
- /**
13422
- * The backup SECRET key, for an encrypted archive (ignored for a plain
13423
- * one). The archive is decrypted into the hub's private staging
13424
- * before the restart; the key is not stored. A wrong or missing key
13425
- * is refused before the archive is downloaded.
13426
- */
13427
- backupSecretKey: string().optional()
13428
- }), _void(), {
13269
+ }), method(object({
13270
+ namespace: string().optional(),
13271
+ collection: string(),
13272
+ conflict: array(string()).readonly(),
13273
+ records: array(BulkRecordSchema).readonly()
13274
+ }), object({ upserted: number().int() }), {
13429
13275
  kind: "mutation",
13430
13276
  auth: "admin"
13431
- }), method(_void(), BackupEncryptionStatusSchema, { auth: "admin" }), method(object({}), object({
13432
- keyId: string(),
13433
- secretKey: string(),
13434
- publicKey: string(),
13435
- createdAt: number(),
13436
- replacedKeyId: string().nullable()
13437
- }), {
13277
+ }), method(object({
13278
+ namespace: string().optional(),
13279
+ collection: string(),
13280
+ record: object({
13281
+ id: string().optional(),
13282
+ data: record(string(), unknown())
13283
+ })
13284
+ }), object({
13285
+ /**
13286
+ * The id the row ACTUALLY got (D473): the one supplied, the UUID minted
13287
+ * for an absent one, or the ROWID SQLite assigned on an `INTEGER`
13288
+ * primary key — which is the only place an auto key is knowable.
13289
+ */
13290
+ id: union([string(), number()]) }), {
13438
13291
  kind: "mutation",
13439
13292
  auth: "admin"
13440
- }), method(object({}), object({ disabledKeyId: string().nullable() }), {
13293
+ }), method(object({
13294
+ namespace: string().optional(),
13295
+ collection: string(),
13296
+ records: array(BulkRecordSchema).readonly()
13297
+ }), object({ inserted: number().int() }), {
13441
13298
  kind: "mutation",
13442
13299
  auth: "admin"
13443
- }), method(object({ backupSecretKey: string() }), object({
13444
- valid: boolean(),
13445
- keyId: string().nullable(),
13446
- /** The hub knows this key (active or retired). */
13447
- known: boolean(),
13448
- /** It is the key new archives are encrypted to. */
13449
- active: boolean(),
13450
- error: string().optional()
13300
+ }), method(object({
13301
+ namespace: string().optional(),
13302
+ guard: TransactGuardSchema.optional(),
13303
+ ops: array(TransactOpSchema).readonly()
13304
+ }), object({
13305
+ applied: boolean(),
13306
+ current: unknown().optional()
13451
13307
  }), {
13452
13308
  kind: "mutation",
13453
13309
  auth: "admin"
13454
13310
  }), method(object({
13455
- destinationId: string(),
13456
- backupId: string()
13311
+ namespace: string().optional(),
13312
+ collection: string(),
13313
+ id: string(),
13314
+ data: record(string(), unknown())
13457
13315
  }), _void(), {
13458
13316
  kind: "mutation",
13459
13317
  auth: "admin"
13460
- }), method(object({ destinationId: string() }), array(BackupArchiveEntrySchema).readonly(), { auth: "admin" }), method(object({
13461
- locationId: string(),
13462
- enabled: boolean(),
13463
- retentionCount: number().int().min(1).max(1e3),
13464
- label: string().optional()
13318
+ }), method(object({
13319
+ namespace: string().optional(),
13320
+ collection: string(),
13321
+ key: string()
13465
13322
  }), _void(), {
13466
13323
  kind: "mutation",
13467
13324
  auth: "admin"
13468
13325
  }), method(object({
13469
- cron: string(),
13470
- count: number().int().min(1).max(20).optional()
13471
- }), object({
13472
- ok: boolean(),
13473
- error: string().optional(),
13474
- nextRuns: array(number()).readonly()
13475
- })), method(_void(), array(BackupScheduleSchema).readonly(), { auth: "admin" }), method(object({
13476
- id: string().optional(),
13477
- label: string(),
13478
- cron: string(),
13479
- enabled: boolean(),
13480
- locationIds: array(string()).readonly(),
13481
- retentionCount: number().int().min(1).max(1e3),
13482
- dataSources: array(string()).readonly().optional()
13483
- }), BackupScheduleSchema, {
13326
+ namespace: string().optional(),
13327
+ collection: string(),
13328
+ filter: MutationFilterSchema
13329
+ }), object({ deleted: number().int() }), {
13484
13330
  kind: "mutation",
13485
13331
  auth: "admin"
13486
- }), method(object({ id: string() }), _void(), {
13332
+ }), method(object({
13333
+ namespace: string().optional(),
13334
+ collection: string(),
13335
+ filter: MutationFilterSchema,
13336
+ data: record(string(), unknown())
13337
+ }), object({ updated: number().int() }), {
13338
+ kind: "mutation",
13339
+ auth: "admin"
13340
+ }), method(object({
13341
+ namespace: string().optional(),
13342
+ collection: string(),
13343
+ filter: QueryFilterSchema.optional()
13344
+ }), number(), { auth: "admin" }), method(object({
13345
+ namespace: string().optional(),
13346
+ collection: string(),
13347
+ fields: array(AggregateFieldSchema).readonly(),
13348
+ filter: QueryFilterSchema.optional()
13349
+ }), AggregateResultSchema, { auth: "admin" }), method(object({
13350
+ namespace: string().optional(),
13351
+ collection: string(),
13352
+ field: string(),
13353
+ bucketSize: number().int().positive(),
13354
+ origin: number().int(),
13355
+ filter: QueryFilterSchema.optional()
13356
+ }), array(object({
13357
+ bucket: number().int(),
13358
+ count: number().int()
13359
+ })).readonly(), { auth: "admin" }), method(object({
13360
+ namespace: string().optional(),
13361
+ collection: string()
13362
+ }), boolean(), { auth: "admin" }), method(DeclareCollectionInputSchema, _void(), {
13487
13363
  kind: "mutation",
13488
13364
  auth: "admin"
13489
13365
  });
13490
13366
  /**
13491
- * `broker` — unified pub/sub broker registry, system-scoped collection.
13492
- *
13493
- * The cap models any kind-tagged message broker the user wants to
13494
- * register and that other addons might consume. The first two kinds
13495
- * are MQTT (mosquitto / aedes embedded / cloud bridge) and
13496
- * Home Assistant (HA WebSocket — subscribe_entities + call_service).
13497
- * Future kinds (Zigbee2MQTT bridge, ZHA, KNX, Telegram, …) plug in
13498
- * the same surface.
13499
- *
13500
- * Why one cap, not one cap per kind:
13501
- * - The integrations page wants a single table of "every broker"
13502
- * across kinds; a unified cap drives it without join logic.
13503
- * - Generic admin operations (add / remove / test / status) are
13504
- * identical across kinds — duplicating them per cap was busywork.
13505
- * - The `network-access` cap precedent: same interface, many
13506
- * `providerKind: 'ingress'` implementations (Tailscale, ngrok, …).
13507
- *
13508
- * What stays kind-specific:
13509
- * - `getBrokerConfig` payload (kind decides whether it returns
13510
- * mqtt URL+creds, HA baseUrl+token, …) — typed as
13511
- * `Record<string, unknown>` at the wire; consumers narrow.
13512
- * - `publish` / `subscribe` / `getState` arguments (target / filter /
13513
- * key) — also typed as `Record<string, unknown>` so each kind can
13514
- * evolve its surface (MQTT topic+qos vs HA entity_id+domain)
13515
- * without rebreaking the cap signature.
13516
- * - `add` `settings` payload (different fields per kind, validated by
13517
- * the kind-specific provider).
13518
- *
13519
- * Bidirectionality:
13520
- * - `publish` is RPC — synchronous result (provider-defined).
13521
- * - `subscribe` returns a subscription handle; the actual message
13522
- * stream flows over the typed event-bus as `broker.message` events
13523
- * keyed by `(brokerId, subscriptionId)`. Consumers filter in their
13524
- * event handler. Subscriptions persist across reconnects — the
13525
- * provider re-subscribes upstream after a transport drop.
13526
- *
13527
- * Why event-bus push (not a subscription RPC stream):
13528
- * - Matches the rest of CamStack's D8 contract — events for telemetry,
13529
- * RPC for loss-is-a-bug. Broker messages are telemetry (a single
13530
- * drop is recoverable via `getState({key})`).
13531
- * - Frees `broker.subscribe` from holding a long-lived RPC channel.
13532
- * - Cross-process: events already route through `$event-bus`; no
13533
- * per-broker wire-shaping needed.
13367
+ * How long one snapshot may take end to end: the pinned copy of the whole
13368
+ * database plus `PRAGMA integrity_check` over the copy, on the hub's slowest
13369
+ * disk. ~800 MB at a pessimistic 10 MB/s is under 3 minutes; the bound is
13370
+ * generous because a timeout fails the whole backup run.
13534
13371
  */
13535
- var BrokerStatusEnum = _enum([
13536
- "connected",
13537
- "disconnected",
13538
- "connecting",
13539
- "auth-failed",
13540
- "unreachable",
13541
- "error"
13542
- ]);
13543
- var BrokerInfoSchema = object({
13544
- /** Stable broker id. Persisted; survives addon restarts. */
13545
- id: string(),
13546
- /** Addon id of the provider that OWNS this broker.
13547
- *
13548
- * The `broker` cap is a system-scoped collection: several addons
13549
- * register a `broker` provider, each owning a DISJOINT set of
13550
- * brokers (mqtt-broker owns `mqtt_*`, provider-homeassistant owns
13551
- * `ha_*`). A broker therefore belongs to exactly one addon — like a
13552
- * device belongs to one integration. The admin UI threads this id
13553
- * back as the `{ addonId }` system-collection selector on every
13554
- * id-keyed call (`get` / `getSettings` / `setSettings` / `remove` /
13555
- * `testConnection`), so the call routes to the OWNING provider
13556
- * instead of defaulting to the first-registered one. */
13557
- addonId: string(),
13558
- /** Human-readable name (operator-chosen at add-time). */
13372
+ var DATABASE_SNAPSHOT_TIMEOUT_MS = 30 * 6e4;
13373
+ /**
13374
+ * The databases an owner snapshots — declared by the OWNER, so a caller never
13375
+ * guesses which file is live from what sits beside it (a cleanly closed
13376
+ * database has no sidecars and is just as owned). Paths are absolute on the
13377
+ * owner's host.
13378
+ */
13379
+ var DatabaseSnapshotTargetsSchema = object({ databases: array(object({
13380
+ /** Which owned file this is — the handle `writeSnapshot` takes. */
13381
+ databaseId: DataStoreDatabaseSchema,
13382
+ livePath: string()
13383
+ })) });
13384
+ /** One b-tree table of the snapshot and the rows it holds. */
13385
+ var DatabaseSnapshotTableSchema = object({
13559
13386
  name: string(),
13560
- /** Provider-defined kind tag — `mqtt` / `home-assistant` / future. */
13561
- kind: string(),
13562
- status: BrokerStatusEnum,
13563
- /** Free-form provider-specific info (HA version, MQTT broker
13564
- * flavour, latency, mTLS active, …). */
13565
- info: record(string(), unknown()),
13566
- /** Ms epoch of the last connection probe / status update. */
13567
- lastCheckedAt: number().nullable(),
13568
- /** Last error message, when `status` indicates failure. */
13569
- error: string().nullable()
13570
- });
13571
- var RegistryStatusSchema = object({
13572
- brokerCount: number().int().nonnegative(),
13573
- connectedCount: number().int().nonnegative()
13387
+ rows: number().int().nonnegative()
13574
13388
  });
13575
13389
  /**
13576
- * One entry per `broker` provider — which addon provides which broker
13577
- * kind(s). The unified create picker reads this to know, for each
13578
- * (addonId, kind) pair the operator can pick, where to route the
13579
- * follow-up `getSettingsSchema` / `testSettings` / `add` calls (via the
13580
- * `{ addonId }` selector). A provider only advertises a kind here when
13581
- * it implements `getSettingsSchema` for that kind (so the picker never
13582
- * offers a kind whose creation form can't be rendered).
13390
+ * What the owner wrote. Every path is ABSOLUTE on the owner's host — the
13391
+ * caller pins the call to its own node, so it can read the file; a caller
13392
+ * that finds the path outside its own data root refuses it rather than
13393
+ * archiving something it cannot place.
13583
13394
  */
13584
- var BrokerProviderInfoSchema = object({
13585
- /** Addon id of the `broker` provider this entry describes. */
13586
- addonId: string(),
13587
- /** Broker kinds this provider can create, with a display label. */
13588
- kinds: array(object({
13589
- kind: string(),
13590
- label: string(),
13591
- /**
13592
- * Relative hub path to start an interactive account link (OAuth popup).
13593
- * May include `{{brokerId}}`; admin-ui substitutes and appends `?popup=1`.
13594
- */
13595
- interactiveConnectPath: string().min(1).optional(),
13596
- /** Relative hub path to disconnect the linked account (same popup pattern). */
13597
- interactiveDisconnectPath: string().min(1).optional()
13598
- }))
13599
- });
13600
- var ListInputSchema = object({
13601
- /** Optional kind filter — `list({kind:'home-assistant'})` returns
13602
- * only HA brokers. Omit to list every broker. */
13603
- kind: string().optional() });
13604
- var GetInputSchema = object({ id: string() });
13605
- var AddInputSchema = object({
13606
- kind: string().min(1),
13607
- name: string().min(1),
13395
+ var DatabaseSnapshotResultSchema = object({
13396
+ /** The live database the snapshot copies. Never to be copied as a file. */
13397
+ livePath: string(),
13398
+ /** The verified copy: `<livePath>` + {@link DATABASE_SNAPSHOT_SUFFIX}. */
13399
+ snapshotPath: string(),
13400
+ sizeBytes: number().int().nonnegative(),
13401
+ pageCount: number().int().nonnegative(),
13402
+ pageSize: number().int().positive(),
13608
13403
  /**
13609
- * ADOPT an existing id instead of minting a new one.
13610
- *
13611
- * Written the day this cost an outage. A broker's id is not a detail: HA
13612
- * devices carry it inside their `stableId` (`ha:ha_004:dev:…`), so a broker
13613
- * lost from config and re-added as `ha_001` leaves every one of its devices
13614
- * bound to a broker that no longer exists. Re-entering the password under the
13615
- * ORIGINAL id turns a multi-step device migration back into re-entering a
13616
- * password.
13617
- *
13618
- * A provider MUST refuse an id that is already in use — adopting a live
13619
- * broker's id would silently take it over.
13404
+ * Every b-tree table with its row count. Read inside the SAME read
13405
+ * transaction the copy was taken from, and found EQUAL on the copy — so
13406
+ * this is the content of the snapshot, not an estimate of the source.
13620
13407
  */
13621
- id: string().min(1).optional(),
13622
- /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
13623
- * `{baseUrl,accessToken}`). Validated by the kind-specific provider
13624
- * branch on receipt — invalid shape rejects the add. */
13625
- settings: record(string(), unknown())
13626
- });
13627
- var AddResultSchema = object({ id: string() });
13628
- var RemoveInputSchema = object({ id: string() });
13629
- var TestConnectionResultSchema = discriminatedUnion("ok", [object({
13630
- ok: literal(true),
13631
- latencyMs: number().nonnegative()
13632
- }), object({
13633
- ok: literal(false),
13634
- error: string()
13635
- })]);
13636
- var SettingsRecordSchema$1 = record(string(), unknown());
13637
- var SettingsSchemaInputSchema = object({ kind: string() });
13638
- var TestSettingsInputSchema = object({
13639
- kind: string(),
13640
- settings: SettingsRecordSchema$1
13641
- });
13642
- var TestSettingsResultSchema = discriminatedUnion("ok", [object({
13643
- ok: literal(true),
13644
- latencyMs: number().nonnegative().optional()
13645
- }).strict(), object({
13646
- ok: literal(false),
13647
- error: string()
13648
- })]);
13649
- var SettingsSchemaResultSchema = unknown().nullable();
13650
- var PublishInputSchema = object({
13651
- brokerId: string(),
13652
- /** Kind-specific routing target.
13653
- * MQTT: `{ topic: string, qos?: 0|1|2, retain?: boolean }`.
13654
- * HA: `{ domain: string, service: string, entityId?: string, area?: string }`. */
13655
- target: record(string(), unknown()),
13656
- /** Kind-specific payload.
13657
- * MQTT: raw string / number / object (provider serialises).
13658
- * HA: service-call `data` object (transition, brightness, …). */
13659
- payload: unknown().optional()
13660
- });
13661
- var SubscribeInputSchema = object({
13662
- brokerId: string(),
13663
- /** Kind-specific filter.
13664
- * MQTT: `{ topic: string, qos?: 0|1|2 }` — topic can include wildcards.
13665
- * HA: `{ entityIds: string[] }` or `{ domain: string }`. */
13666
- filter: record(string(), unknown())
13667
- });
13668
- var SubscribeResultSchema = object({
13669
- /** Stable subscription id. Used to unsubscribe and to filter the
13670
- * matching `broker.message` events on the bus. */
13671
- subscriptionId: string() });
13672
- var UnsubscribeInputSchema = object({
13673
- brokerId: string(),
13674
- subscriptionId: string()
13408
+ tables: array(DatabaseSnapshotTableSchema),
13409
+ /** Wall time of the pinned copy (row counts included). */
13410
+ copyMs: number().nonnegative(),
13411
+ /** Wall time of the checks on the copy (`integrity_check`, row counts). */
13412
+ verifyMs: number().nonnegative(),
13413
+ /** When the pinned read transaction began (ms epoch) — the snapshot's instant. */
13414
+ takenAt: number()
13675
13415
  });
13676
- var GetStateInputSchema = object({
13677
- brokerId: string(),
13678
- /** Kind-specific lookup key.
13679
- * MQTT: topic string (returns the last retained message).
13680
- * HA: entity_id (returns the cached entity state). */
13681
- key: string()
13416
+ /** A snapshot the owner wrote, with the file it belongs to. */
13417
+ var DatabaseSnapshotWrittenSchema = DatabaseSnapshotResultSchema.extend({ databaseId: DataStoreDatabaseSchema });
13418
+ /** Which database `writeSnapshot` copies. Required: an unstated ask is not guessed. */
13419
+ var DatabaseSnapshotRequestSchema = object({ databaseId: DataStoreDatabaseSchema });
13420
+ /**
13421
+ * A snapshot as an ARCHIVE records it (`ArchiveManifest.sqliteSnapshots`):
13422
+ * the same facts, with both paths relative to the archive root (the node's
13423
+ * data dir), because an archive outlives the host that made it.
13424
+ */
13425
+ var ArchiveSqliteSnapshotSchema = DatabaseSnapshotResultSchema.extend({
13426
+ /** Archive-relative path of the live database the archive does NOT carry. */
13427
+ livePath: string(),
13428
+ /** Archive-relative path of the verified copy it carries in its place. */
13429
+ snapshotPath: string(),
13430
+ /** Absent on an archive made before several files were owned: that was `settings`. */
13431
+ databaseId: DataStoreDatabaseSchema.optional()
13682
13432
  });
13683
- method(ListInputSchema, array(BrokerInfoSchema)), method(GetInputSchema, BrokerInfoSchema.nullable()), method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }), method(AddInputSchema, AddResultSchema, {
13684
- kind: "mutation",
13685
- auth: "admin"
13686
- }), method(RemoveInputSchema, _void(), {
13687
- kind: "mutation",
13688
- auth: "admin"
13689
- }), method(GetInputSchema, TestConnectionResultSchema, {
13690
- kind: "mutation",
13691
- auth: "admin"
13692
- }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(object({
13693
- id: string(),
13694
- settings: SettingsRecordSchema$1
13695
- }), _void(), {
13696
- kind: "mutation",
13697
- auth: "admin"
13698
- }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }), method(TestSettingsInputSchema, TestSettingsResultSchema, {
13699
- kind: "mutation",
13700
- auth: "admin"
13701
- }), method(PublishInputSchema, unknown(), {
13702
- kind: "mutation",
13703
- auth: "admin"
13704
- }), method(SubscribeInputSchema, SubscribeResultSchema, {
13705
- kind: "mutation",
13706
- auth: "admin"
13707
- }), method(UnsubscribeInputSchema, _void(), {
13433
+ method(object({}), DatabaseSnapshotTargetsSchema, { auth: "admin" }), method(DatabaseSnapshotRequestSchema, DatabaseSnapshotWrittenSchema, {
13708
13434
  kind: "mutation",
13709
- auth: "admin"
13710
- }), method(GetStateInputSchema, unknown().nullable(), { auth: "admin" }), method(_void(), RegistryStatusSchema);
13711
- DeviceType.Camera;
13712
- /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
13713
- var StreamFormatSchema = _enum([
13714
- "webrtc",
13715
- "hls",
13716
- "mjpeg",
13717
- "rtsp"
13718
- ]);
13719
- /** A container `produceEventMedia` can emit. */
13720
- var EventMediaKindSchema = _enum(["mp4", "gif"]);
13435
+ auth: "admin",
13436
+ timeoutMs: DATABASE_SNAPSHOT_TIMEOUT_MS
13437
+ });
13721
13438
  /**
13722
- * One produced artifact, referenced by HANDLE.
13439
+ * Orchestrator-side destination metadata. The orchestrator computes
13440
+ * `id = <addonId>:<subId>` from its provider lookup so consumers
13441
+ * (admin UI, restore flow) see one canonical key.
13723
13442
  *
13724
- * Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
13725
- * method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
13726
- * [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
13727
- * on demand, compressed, by handle). `bytes` is here so a caller can decide
13728
- * whether it wants the fetch at all.
13443
+ * Phase 4 (admin UI redesign) adds the per-destination policy fields
13444
+ * (`enabled`, `retentionCount`, `label`) so the destinations table can
13445
+ * render the joined view without a follow-up round-trip. The backend
13446
+ * already joins these in `listDestinations` — we now surface them on
13447
+ * the wire.
13448
+ *
13449
+ * `lastSuccessAt` / `lastSuccessSizeBytes` are computed from each
13450
+ * location's `manifests.json` (newest archive). Optional — locations
13451
+ * with no archives yet leave them undefined.
13729
13452
  */
13730
- var EventMediaArtifactSchema = object({
13731
- kind: EventMediaKindSchema,
13732
- /** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
13733
- handle: string(),
13453
+ var BackupDestinationInfoSchema = object({
13734
13454
  /**
13735
- * The node holding the bytes — the ROUTING key for `fetchEventMedia`.
13736
- *
13737
- * `stream-broker` is a singleton cap and an unpinned call never leaves the
13738
- * hub, so a handle produced on an agent's broker would be redeemed against
13739
- * the hub's store and come back `null`. Same contract, same field name and
13740
- * the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
13741
- * lives and the consumer pins to it.
13455
+ * Sub-id within this addon. Convention `default` for single-
13456
+ * destination addons; addons that host many (S3 with N buckets)
13457
+ * pick stable per-bucket strings. The orchestrator stitches
13458
+ * `<manifestId>:<subId>` for the wire format.
13742
13459
  */
13743
- nodeId: string(),
13744
- mime: string(),
13745
- bytes: number().int(),
13746
- width: number().int(),
13747
- height: number().int()
13460
+ subId: string(),
13461
+ displayName: string(),
13462
+ description: string().optional(),
13463
+ kind: string(),
13464
+ triggerSupported: boolean(),
13465
+ restoreSupported: boolean()
13466
+ }).extend({
13467
+ /** `<addonId>:<subId>` — globally-unique dispatch key. */
13468
+ id: string(),
13469
+ /**
13470
+ * Manifest id of the owning addon. The Settings button on the
13471
+ * destination card opens that addon's `globalSettingsSchema` panel.
13472
+ */
13473
+ addonId: string(),
13474
+ /** Operator-toggled enable flag from the destination-policy table. */
13475
+ enabled: boolean(),
13476
+ /** Operator-defined retention count (archives kept per destination). */
13477
+ retentionCount: number(),
13478
+ /** Operator-defined display label (overrides storage location displayName). */
13479
+ label: string().optional(),
13480
+ /** Newest-archive timestamp from `manifests.json`, or undefined. */
13481
+ lastSuccessAt: number().optional(),
13482
+ /** Newest-archive size from `manifests.json`, or undefined. */
13483
+ lastSuccessSizeBytes: number().optional(),
13484
+ /**
13485
+ * Cron cadence(s) of the ENABLED schedules that fan out to this
13486
+ * destination, comma-joined. Absent when no enabled schedule targets it
13487
+ * — a destination nothing is scheduled to write to must not advertise a
13488
+ * cadence (D384). This is never the `backup_destination_policies.cron`
13489
+ * column: that per-location cron has scheduled nothing since 2026-07-28
13490
+ * and reading it made a destination with a DISABLED schedule claim a
13491
+ * nightly run.
13492
+ */
13493
+ cron: string().optional(),
13494
+ /** ms-epoch of the next firing across those schedules (earliest), if any. */
13495
+ nextRunAt: number().optional(),
13496
+ /**
13497
+ * ms-epoch of the last time a run ATTEMPTED to write here — success or
13498
+ * failure. Never a success stamp: pair it with `lastSuccessAt` (the
13499
+ * newest archive that actually landed) and `lastError`.
13500
+ */
13501
+ lastAttemptAt: number().optional(),
13502
+ /**
13503
+ * Why the last attempt failed, verbatim. Absent when the last attempt
13504
+ * landed the archive. A destination that has never been written to has
13505
+ * neither this nor `lastAttemptAt`.
13506
+ */
13507
+ lastError: string().optional()
13748
13508
  });
13749
13509
  /**
13750
- * What a production actually covered — the answer to the only question an
13751
- * operator asks about a notification clip.
13752
- *
13753
- * `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
13754
- * so a caller can state "this clip starts 4.1 s before the event" instead of
13755
- * inferring it from a duration. A production whose `fromTs` is later than the
13756
- * event is a production with no pre-roll, and that is exactly the defect this
13757
- * method exists to make visible rather than plausible.
13510
+ * How an archive is encrypted (operator decision 2026-10-09): to a backup
13511
+ * PUBLIC key the hub holds, so only the operator's secret key opens it.
13512
+ * Absent on a plain `.tar.gz` — every archive made before encryption, or
13513
+ * with it off — which restores without a key.
13758
13514
  */
13759
- var EventMediaCoverageSchema = object({
13760
- fromTs: number(),
13761
- toTs: number(),
13762
- /** Encoded packets in the muxed window. */
13763
- packets: number().int()
13515
+ var BackupArchiveEncryptionSchema = object({
13516
+ /** `x25519-hkdf-aes256gcm-stream-v1`. */
13517
+ scheme: string(),
13518
+ /** 16 hex — the backup key whose secret restores this archive. */
13519
+ keyId: string()
13764
13520
  });
13765
13521
  /**
13766
- * The result of ONE cut, in every container the caller asked for.
13767
- *
13768
- * Every artifact in `media` came out of the SAME window of the SAME rendition —
13769
- * that is the whole reason this is one method rather than one call per format.
13770
- * A consumer attaching a gif and a video can no longer show two different
13771
- * moments, because it never chose two sources.
13522
+ * The backup encryption setting. The hub keeps PUBLIC keys only: the active
13523
+ * one new archives are encrypted to, and the retired ones older archives
13524
+ * still need. The secret key is shown once, at `setupEncryption`.
13772
13525
  */
13773
- var EventMediaProductionSchema = object({
13774
- media: array(EventMediaArtifactSchema).readonly(),
13775
- coverage: EventMediaCoverageSchema,
13776
- /** The rendition actually cut from — what the default or the fallback chose. */
13777
- profile: CamProfileSchema,
13778
- /** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
13779
- * source, a downscale, or a playback rate other than 1). */
13780
- video: _enum(["copy", "encode"])
13781
- });
13782
- var RtspRestreamEntrySchema = object({
13783
- brokerId: string(),
13784
- url: string(),
13785
- mutedUrl: string(),
13526
+ var BackupEncryptionStatusSchema = object({
13786
13527
  enabled: boolean(),
13528
+ active: object({
13529
+ keyId: string(),
13530
+ publicKey: string(),
13531
+ createdAt: number()
13532
+ }).nullable(),
13533
+ /** Every key ever set up, newest first. */
13534
+ keys: array(object({
13535
+ keyId: string(),
13536
+ createdAt: number(),
13537
+ retiredAt: number().nullable()
13538
+ })).readonly()
13539
+ });
13540
+ /**
13541
+ * Per-archive entry returned by `backup.listArchives({ destinationId })`.
13542
+ * Same shape the destination drill-in renders. Sourced from the per-
13543
+ * location `manifests.json` (no per-archive tarball read required).
13544
+ */
13545
+ var BackupArchiveEntrySchema = object({
13546
+ id: string(),
13547
+ filename: string(),
13548
+ createdAt: number(),
13549
+ sizeBytes: number(),
13550
+ label: string().optional(),
13551
+ /** Top-level locations included in the archive (db, addons, tls, …). */
13552
+ locations: array(string()).readonly(),
13553
+ /** Present when the archive is encrypted; restore then needs that key. */
13554
+ encryption: BackupArchiveEncryptionSchema.optional(),
13787
13555
  /**
13788
- * Source-stream codec / resolution for the camStream this entry serves
13789
- * (the broker's "high"/"mid"/"low" profile slot for this device).
13790
- * Used by exporter pickers (`pickPreferredRtspEntry`) to resolve
13791
- * `streamPreference: 'auto'` to the slot whose source is closest to
13792
- * the consumer's target — Alexa wants ~720p, HomeKit wants ~1080p,
13793
- * and there's no point dialling the 4K slot for an Echo Show. Absent
13794
- * when the source publisher never advertised the field; pickers fall
13795
- * back to first-enabled in that case.
13556
+ * The databases the run asked to carry (its retention class). Absent on an
13557
+ * archive made before several files were owned: that was `['settings']`.
13796
13558
  */
13797
- codec: string().optional(),
13798
- resolution: object({
13799
- width: number().int().positive(),
13800
- height: number().int().positive()
13801
- }).optional()
13802
- });
13803
- var BrokerRtspClientSchema = object({
13804
- sessionId: string(),
13805
- remoteAddr: string(),
13806
- /** Client `User-Agent` (e.g. `recorder` for the recorder's RTSP pull), or
13807
- * null/absent when the client sent none. Lets the UI label a consumer by
13808
- * purpose. Optional so a client built against an older schema stays valid. */
13809
- userAgent: string().nullish(),
13810
- playing: boolean(),
13811
- muted: boolean(),
13812
- connectedAt: number(),
13813
- lastRtpAt: number(),
13814
- bytesSent: number()
13559
+ databases: array(DataStoreDatabaseSchema).readonly().optional()
13815
13560
  });
13816
- var BrokerAudioClientSchema = object({
13817
- tag: string(),
13818
- subscribedAt: number(),
13819
- chunksDelivered: number()
13561
+ var BackupEntrySchema = object({
13562
+ id: string(),
13563
+ /** Addon id of the destination that owns this backup (e.g. `local-backup`, `s3-backup`). */
13564
+ destinationId: string().optional(),
13565
+ label: string().optional(),
13566
+ createdAt: number(),
13567
+ sizeBytes: number(),
13568
+ locations: array(string()).optional(),
13569
+ encryption: BackupArchiveEncryptionSchema.optional()
13820
13570
  });
13821
- var BrokerConsumerAttributionSchema = object({
13571
+ var ArchiveEntrySchema = object({
13572
+ path: string(),
13822
13573
  kind: _enum([
13823
- "alexa",
13824
- "homekit",
13825
- "webrtc-browser",
13826
- "webrtc-mobile",
13827
- "webrtc-whep",
13828
- "rtsp-listen",
13829
- "derived-broker",
13830
- "recording",
13831
- "pipeline",
13832
- "snapshot",
13833
- "warmup",
13834
- "unknown"
13574
+ "file",
13575
+ "dir",
13576
+ "symlink"
13835
13577
  ]),
13578
+ sizeBytes: number(),
13579
+ mtime: number()
13580
+ });
13581
+ var ArchiveManifestSchema = object({
13582
+ archiveVersion: literal(1),
13583
+ createdAt: number(),
13584
+ dataDir: string(),
13585
+ locations: array(string()),
13586
+ entries: array(ArchiveEntrySchema),
13587
+ totalBytes: number(),
13588
+ totalFiles: number(),
13836
13589
  /**
13837
- * Free-form label intended to disambiguate consumers OF THE SAME kind
13838
- * on the same broker — e.g. user name, device alias. Should NOT repeat
13839
- * the kind / cam / cam-stream / sessionId (those are surfaced by
13840
- * dedicated fields). When empty the widget falls back to `${kind} ·
13841
- * <sessionId tail>`.
13842
- */
13843
- label: string().optional(),
13844
- /**
13845
- * Which part of the encoded plane this subscription consumes:
13846
- * - `'audio'` — audio packets only
13847
- * - `'video'` — video packets only
13848
- * - `'both'` — both video + audio (typical for AnnexB push paths
13849
- * that don't split the source RTP)
13850
- *
13851
- * Missing field means "unknown" — older callers and the legacy
13852
- * paths that haven't been migrated yet.
13590
+ * Live SQLite databases this archive carries as their OWNER's verified
13591
+ * snapshot instead of as files (D693). Absent on archives made before.
13853
13592
  */
13854
- media: _enum([
13855
- "audio",
13856
- "video",
13857
- "both"
13858
- ]).optional(),
13593
+ sqliteSnapshots: array(ArchiveSqliteSnapshotSchema).optional(),
13859
13594
  /**
13860
- * Codec the consumer receives AFTER any per-session re-encoding. For
13861
- * a WebRTC session this is the negotiated egress codec (e.g. `H264`,
13862
- * `H265`, `Opus`, `Pcmu`); for an RTSP restreamer it mirrors the
13863
- * source codec. Surfaced in the widget chip so operators see at a
13864
- * glance whether a viewer is on H.264 (Echo) or H.265 (Safari).
13595
+ * Owned database files the run did NOT carry (not selected): left out of the
13596
+ * tar and the entry list alike. Absent when every owned file was carried.
13865
13597
  */
13866
- targetCodec: string().optional(),
13598
+ omittedDatabases: array(DataStoreDatabaseSchema).optional()
13599
+ });
13600
+ var LocationStatSchema = object({
13601
+ name: string(),
13602
+ sizeBytes: number(),
13603
+ fileCount: number(),
13604
+ present: boolean()
13605
+ });
13606
+ /** Lifecycle of a backup run. Terminal states: succeeded / failed / cancelled. */
13607
+ var BackupRunStateSchema = _enum([
13608
+ "queued",
13609
+ "running",
13610
+ "succeeded",
13611
+ "failed",
13612
+ "cancelled"
13613
+ ]);
13614
+ /**
13615
+ * Where a running backup currently is. `queued` before it starts,
13616
+ * `building` while the tar.gz is being staged, `uploading` during the
13617
+ * per-destination fan-out, `done` once terminal.
13618
+ */
13619
+ var BackupRunPhaseSchema = _enum([
13620
+ "queued",
13621
+ "building",
13622
+ "uploading",
13623
+ "done"
13624
+ ]);
13625
+ /**
13626
+ * Observable state of one backup run — readable WHILE it runs via
13627
+ * `backup.listRuns`. This is what makes the execution queue and
13628
+ * `backup.cancel` usable: the 2026-09-04 incident (two concurrent
13629
+ * multi-GB builds, staging 5.1 GB → 18 GB, load 62) was only
13630
+ * diagnosable with `du` because nothing reported that runs existed or
13631
+ * how large the staged archive had grown.
13632
+ */
13633
+ var BackupRunSchema = object({
13634
+ /** Stable run id — the handle `backup.cancel` takes. */
13635
+ id: string(),
13636
+ state: BackupRunStateSchema,
13637
+ phase: BackupRunPhaseSchema,
13867
13638
  /**
13868
- * Whether the broker is re-encoding for this consumer:
13869
- * - `'passthrough'` — bytes flow source→consumer without ffmpeg
13870
- * - `'repacketize'` — RTP payload is re-packetized but NOT re-encoded
13871
- * (preserves frames, just rebuilds headers; e.g.
13872
- * the H.265 repacketizer path)
13873
- * - `'transcode'` — a full encode/decode round (ffmpeg, libx264,
13874
- * libav…); the most expensive path
13639
+ * Resolved destination location ids. Empty while queued (targets are
13640
+ * resolved when the run starts, against the then-current policies).
13875
13641
  */
13876
- transport: _enum([
13877
- "passthrough",
13878
- "repacketize",
13879
- "transcode"
13880
- ]).optional(),
13881
- /** Remote peer IP if the consumer terminates a network socket. */
13882
- remoteAddr: string().optional(),
13642
+ destinationIds: array(string()).readonly(),
13643
+ label: string().optional(),
13644
+ /** ms-epoch when the run was submitted (trigger call / schedule fire). */
13645
+ requestedAt: number(),
13646
+ /** ms-epoch when the run left the queue and started building. */
13647
+ startedAt: number().optional(),
13648
+ /** ms-epoch when the run reached a terminal state. */
13649
+ finishedAt: number().optional(),
13650
+ /** Compressed bytes of the staging archive written so far. */
13651
+ stagedBytes: number(),
13652
+ /** Final staged archive size, once the build phase completes. */
13653
+ archiveSizeBytes: number().optional(),
13654
+ /** Bytes pushed to the destination currently uploading. */
13655
+ uploadedBytes: number(),
13656
+ /** Destinations where the archive fully landed (uploaded + indexed). */
13657
+ completedDestinationIds: array(string()).readonly(),
13658
+ /** Destinations that failed during the fan-out. */
13659
+ failedDestinationIds: array(string()).readonly(),
13660
+ /** Failure message when `state === 'failed'`. */
13661
+ error: string().optional(),
13883
13662
  /**
13884
- * Server-read User-Agent of the originating client; enriched by the hub
13885
- * from the tRPC request context (browser sessions).
13663
+ * 1-based place in the execution queue — 1 = runs next. Present only
13664
+ * while `state === 'queued'`. Stamped by the orchestrator from the
13665
+ * queue's OWN pending order, never derived from timestamps, so the
13666
+ * UI cannot show an order the executor will not honour.
13886
13667
  */
13887
- userAgent: string().optional(),
13888
- /** Authenticated user id (CamStack OAuth subject) when known. */
13889
- userId: string().optional(),
13890
- /** Higher-level session identifier (Alexa Echo sessionId, HAP session, …). */
13891
- sessionId: string().optional(),
13892
- /** Free-form key/value extras (e.g. clientHints from a WebRTC offer). */
13893
- extra: record(string(), string()).optional()
13894
- }).readonly();
13895
- var BrokerEncodedClientSchema = object({
13896
- /** Stable id assigned on attach; used by `killClient`. */
13897
- id: string(),
13898
- /** Which broker fanout the subscriber rides — annexB encoded packets vs raw RTP. */
13899
- channel: _enum(["annexb", "rtp"]),
13900
- attribution: BrokerConsumerAttributionSchema,
13901
- subscribedAt: number(),
13902
- /** Total packets delivered (annex-B EncodedPackets or RTP byte-buffers). */
13903
- packetsDelivered: number()
13904
- });
13905
- var BrokerClientsSchema = object({
13906
- rtsp: array(BrokerRtspClientSchema).readonly(),
13907
- audio: array(BrokerAudioClientSchema).readonly(),
13908
- encoded: array(BrokerEncodedClientSchema).readonly(),
13909
- pipeClients: number(),
13910
- /** Total encoded + raw-RTP callback subscriber count (sum of `encoded[].length`). */
13911
- encodedSubscribers: number()
13668
+ queuePosition: number().int().min(1).optional()
13912
13669
  });
13913
13670
  /**
13914
- * How many people are watching one camera LIVE right now — see
13915
- * `getLiveViewerCounts`. A camera with nobody watching is absent from the list
13916
- * (so `viewers` is always at least 1): the listing carries the cameras that
13917
- * have viewers, never a row of zeros.
13671
+ * Result of `backup.trigger`. The call still resolves when the run
13672
+ * terminates (compat with schedule-driven runs and the admin UI), but
13673
+ * it now names the run and says whether it had to WAIT: a trigger that
13674
+ * arrives while another run is in flight is enqueued (or joined onto
13675
+ * an identical already-queued run), never started concurrently.
13918
13676
  */
13919
- var LiveViewerCountSchema = object({
13920
- deviceId: number().int().nonnegative(),
13921
- viewers: number().int().positive()
13922
- });
13923
- _enum([
13924
- "reconnecting",
13925
- "sleeping",
13926
- "offline",
13927
- "disabled",
13928
- "waking"
13929
- ]);
13930
- var VideoCodecTargetSchema = _enum([
13931
- "h264",
13932
- "h265",
13933
- "copy"
13934
- ]);
13935
- var AudioCodecTargetSchema = _enum([
13936
- "aac",
13937
- "opus",
13938
- "pcmu",
13939
- "copy",
13940
- "none"
13941
- ]);
13942
- var GetStreamWithCodecInputSchema = object({
13943
- deviceId: number().int().nonnegative(),
13944
- /** Target codec. `'copy'` = passthrough (no re-encode). */
13945
- video: VideoCodecTargetSchema,
13946
- /** Target audio codec. `'copy'` = passthrough. Defaults to `'aac'`. */
13947
- audio: AudioCodecTargetSchema.optional(),
13948
- /** Target a profile's assigned source camStream. Required to resolve a source. */
13949
- profile: CamProfileSchema.optional(),
13950
- /** Optional output resolution target. When set: drives the transcode
13951
- * output scale (downscale toward WxH), and — when `profile` is absent —
13952
- * selects the published source closest to this resolution. Ignored for
13953
- * `video:'copy'` scaling (a copy can't be rescaled), but still used for
13954
- * source selection. */
13955
- targetResolution: object({
13956
- width: number().int().positive(),
13957
- height: number().int().positive()
13958
- }).optional(),
13959
- /** Extra ffmpeg output flags appended verbatim by the consumer (in code).
13960
- * Folded into the pipeline key so different args never share a child. */
13961
- outputArgs: array(string()).optional(),
13962
- /** Opt-in: carry transcoded audio over an RTP sidecar grafted into the
13963
- * restreamer SDP (only for re-encoded audio targets — aac/opus/pcmu).
13964
- * Default/absent = the live-verified video-only egress. */
13965
- audioEgress: boolean().optional(),
13966
- /** Host to embed in the returned restream `url`. The broker is hub-only, so
13967
- * its restream URLs default to `127.0.0.1` (consumable only from the hub).
13968
- * A REMOTE consumer (e.g. a recorder running on an agent designated as the
13969
- * recording node) passes the hub's cluster-resolvable host here so the
13970
- * returned `rtsp://<host>:<port>/<token>` is dialable from that node. The
13971
- * port/token (and the underlying restreamer, bound on 0.0.0.0) are
13972
- * unchanged — `hostname` only rewrites the client-facing dial address.
13973
- * Absent/empty = `127.0.0.1` (the hub-local default). */
13974
- hostname: string().optional(),
13975
- tag: string().optional()
13976
- });
13977
- var RtpSourceSchema = object({
13978
- url: string(),
13979
- /**
13980
- * The SENTRY counterpart of {@link url} — the dial that asks for the stream
13981
- * WITHOUT asking for the camera — or `null` when this camera offers none.
13982
- *
13983
- * The broker answers it because the broker is the only thing that can:
13984
- * eligibility is `sentryEligible` (a BATTERY camera that DECLARES the signals
13985
- * cap), asked of facts only the broker holds. A consumer re-deriving it from
13986
- * cap bindings would be a second authority, and two authorities on the same
13987
- * question is how a composite camera ends up dialling a sleeping camera
13988
- * because its own copy of the rule was a release behind.
13989
- *
13990
- * `null` is a real answer and is never a live url in disguise: a consumer
13991
- * that silently fell back to {@link url} would perform exactly the wake this
13992
- * field exists to prevent. Only a PASSTHROUGH acquisition can offer one — a
13993
- * transcode hands back an egress the broker is feeding itself, which has no
13994
- * sentry counterpart.
13995
- */
13996
- sentryUrl: string().nullable().optional(),
13997
- videoCodec: _enum(["H264", "H265"]),
13998
- audioCodec: string(),
13999
- resolution: object({
14000
- width: number().int().positive(),
14001
- height: number().int().positive()
14002
- }),
14003
- transcoded: boolean(),
14004
- encoder: string(),
14005
- pipelineKey: string()
13677
+ var BackupTriggerResultSchema = object({
13678
+ /** The run this trigger mapped to — poll it via `listRuns`, stop it via `cancel`. */
13679
+ runId: string(),
13680
+ /** True when the run waited behind an in-flight run instead of starting immediately. */
13681
+ queued: boolean(),
13682
+ /** True when this trigger was coalesced onto an identical already-queued run. */
13683
+ joined: boolean(),
13684
+ /** True when the run was cancelled before completing every destination. */
13685
+ cancelled: boolean(),
13686
+ /** One entry per destination the archive landed at (partial on cancel). */
13687
+ entries: array(BackupEntrySchema).readonly()
14006
13688
  });
14007
13689
  /**
14008
- * The encode request — **structured and serialisable, with NO raw-flag escape
14009
- * hatch.** This is deliberate and it is the one lesson taken from
14010
- * `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
14011
- * its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
14012
- * adding a flag silently forks the shared child, and two consumers that mean
14013
- * the same thing but spell it differently never share. Here every knob is a
14014
- * NAMED field: a new requirement becomes a schema field (and a codegen run),
14015
- * never an opaque array.
13690
+ * A backup schedule — the N:M "entry" that binds one cron cadence to a
13691
+ * SET of destination locations. Supersedes the per-location cron on
13692
+ * `BackupDestinationPolicy`: an operator creates a schedule, picks the
13693
+ * `backups` locations it should write to, and the orchestrator fans a
13694
+ * single archive out to all of them when the cron fires.
14016
13695
  *
14017
- * `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
14018
- * The operator-facing derived-stream transform editor still has them — that is
14019
- * a different surface (`publishCameraStream({ kind: 'derived' })`) with a
14020
- * different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
14021
- */
14022
- var EgressEncodeSchema = EncodeProfileSchema.omit({
14023
- inputArgs: true,
14024
- outputArgs: true
14025
- });
14026
- /**
14027
- * How the encoder is bounded. `'tight'` is a one-second VBV window for a
14028
- * consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
14029
- * seconds, letting a keyframe spike borrow from the next second (a browser,
14030
- * an Echo). Named rather than numeric so the INTENT survives.
13696
+ * `retentionCount` is per-schedule (D-decision 2026-07-28): every
13697
+ * location targeted by this schedule keeps this many archives from
13698
+ * this schedule's runs.
13699
+ *
13700
+ * `dataSources` optionally narrows which top-level state locations
13701
+ * (db, addons, tls, …) are archived; omitted = the orchestrator's
13702
+ * default full set.
14031
13703
  */
14032
- var EgressRateControlSchema = _enum(["tight", "relaxed"]);
14033
- var EgressTranscodeRequestSchema = object({
14034
- deviceId: number().int().nonnegative(),
14035
- /** Which published stream to read. */
14036
- source: discriminatedUnion("kind", [object({
14037
- kind: literal("profile"),
14038
- profile: CamProfileSchema
14039
- }), object({
14040
- kind: literal("cam-stream"),
14041
- camStreamId: string().min(1)
14042
- })]),
14043
- encode: EgressEncodeSchema,
14044
- rateControl: EgressRateControlSchema.optional(),
13704
+ var BackupScheduleSchema = object({
13705
+ /** Stable id. Generated by the orchestrator on first upsert if absent. */
13706
+ id: string(),
13707
+ /** Operator-facing display name. */
13708
+ label: string(),
13709
+ /** 5-field POSIX cron. Empty = disabled cadence (kept for editing). */
13710
+ cron: string(),
13711
+ /** Master on/off toggle for the whole schedule. */
13712
+ enabled: boolean(),
13713
+ /** `backups`-location ids this schedule writes to (fan-out set). */
13714
+ locationIds: array(string()).readonly(),
13715
+ /** Archives kept per targeted location for this schedule. */
13716
+ retentionCount: number().int().min(1).max(1e3),
13717
+ /** Optional subset of source locations to include; omitted = all. */
13718
+ dataSources: array(string()).readonly().optional(),
14045
13719
  /**
14046
- * `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
14047
- * out-of-band extradata and needs `dump_extra` on both the copy and encode
14048
- * branches. Enumerated, not free text.
13720
+ * Which database files this schedule carries. Absent = `['settings']`
13721
+ * (an unstated ask is the minimum, D382). Events and history run weekly,
13722
+ * settings nightly (D852).
14049
13723
  */
14050
- bitstreamFilter: _enum([
14051
- "dump_extra",
14052
- "h264_mp4toannexb",
14053
- "hevc_mp4toannexb"
14054
- ]).optional(),
13724
+ databases: array(DataStoreDatabaseSchema).readonly().optional(),
14055
13725
  /**
14056
- * Publish the transcode as a LOCAL push cam stream, instead of leaving the
14057
- * consumer to dial the returned url. The broker picks the id and returns it
14058
- * as `camStreamId` — a caller-supplied one would be circular, since the
14059
- * sharing key is computed FROM this request.
14060
- *
14061
- * The url is still returned and still the contract for a transcode pinned to
14062
- * another node. But dialling it locally costs an RTSP round trip that changes
14063
- * the transport underneath the consumer: a dialled stream is an RTP source,
14064
- * so `isRtpSource()` is true and the session takes the RTP-passthrough +
14065
- * repacketizer branch. The push branch — the one the derived mechanism has
14066
- * live hours on — is never reached. Measured on Alexa: broker registered, RTP
14067
- * arriving, key frame arriving, black screen, on a chain healthy at every
14068
- * other point.
14069
- *
14070
- * Same idea the transport already applies to CALLS, where `classifyCapRoute`
14071
- * gives priority to `hub-in-process` so a local call never leaves the node.
14072
- * This is that rule for media.
13726
+ * ms-epoch of the last tick that FIRED this schedule. Stamped before the
13727
+ * archive runs (it is the dedupe anchor), so it says "attempted", never
13728
+ * "succeeded" — a run refused by every destination stamps it too.
14073
13729
  */
14074
- publishLocally: boolean().optional(),
14075
- pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
13730
+ lastAttemptAt: number().optional(),
13731
+ /** ms-epoch of the last run of this schedule that landed at ≥1 destination. */
13732
+ lastSuccessAt: number().optional(),
13733
+ /** Why the last fired run failed, verbatim. Absent when it succeeded. */
13734
+ lastError: string().optional(),
13735
+ /** ms-epoch of next computed firing (read-only, filled on list). */
13736
+ nextRunAt: number().optional()
13737
+ });
13738
+ method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }), method(object({
13739
+ /** Subset of registered `backup-destination` addon ids to write to. */
13740
+ destinations: array(string()).optional(),
13741
+ locations: array(string()).optional(),
13742
+ label: string().optional(),
14076
13743
  /**
14077
- * Operator/consumer override for decode hardware. ABSENT is the normal case
14078
- * and the one that matters: the broker then resolves the backend from the
14079
- * DECODER ADDON's per-node `probedBestHwaccel` (see
14080
- * `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
14081
- * on this hardware — never the raw kernel resolver's qsv-first order.
13744
+ * Per-run retention override applied to every targeted
13745
+ * destination. Used by schedule-driven runs (per-entry
13746
+ * retention). Omitted = each destination's own policy
13747
+ * retention (manual runs).
14082
13748
  */
14083
- decodeHwAccel: _enum([
14084
- "auto",
14085
- "none",
14086
- "videotoolbox",
14087
- "vaapi",
14088
- "qsv",
14089
- "cuda"
14090
- ]).optional(),
13749
+ retentionCount: number().int().min(1).max(1e3).optional(),
13750
+ /** Database files to carry; omitted = `['settings']`. */
13751
+ databases: array(DataStoreDatabaseSchema).optional()
13752
+ }).optional(), BackupTriggerResultSchema, {
13753
+ kind: "mutation",
13754
+ auth: "admin"
13755
+ }), method(_void(), array(BackupRunSchema).readonly(), { auth: "admin" }), method(object({ runId: string() }), object({ cancelled: boolean() }), {
13756
+ kind: "mutation",
13757
+ auth: "admin"
13758
+ }), method(_void(), array(BackupEntrySchema).readonly(), { auth: "admin" }), method(_void(), array(LocationStatSchema).readonly(), { auth: "admin" }), method(object({
13759
+ destinationId: string(),
13760
+ backupId: string()
13761
+ }), ArchiveManifestSchema.nullable(), { auth: "admin" }), method(object({
13762
+ destinationId: string(),
13763
+ backupId: string(),
14091
13764
  /**
14092
- * Host to embed in the returned restream `url`. The broker mints hub-local
14093
- * `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
14094
- * host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
14095
- * dialable from there. Same contract as `getStreamWithCodec.hostname` —
14096
- * `substituteRtspHost` rewrites only the dial address, never the restreamer.
13765
+ * Optional whitelist — only restore these top-level locations
13766
+ * from the archive. Default = every location the archive
13767
+ * carries (full restore). The boot-time apply hook reads
13768
+ * this list and skips entries whose path doesn't start with
13769
+ * any of them.
14097
13770
  */
14098
- hostname: string().optional(),
14099
- /** Attribution for the broker panel. Never part of the sharing key. */
14100
- tag: string().optional()
14101
- });
14102
- var EgressTranscodeSchema = object({
14103
- /** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
14104
- url: string(),
14105
- /** Release handle. Refcounted — the child dies when the last holder releases. */
14106
- pipelineKey: string(),
14107
- videoCodec: _enum(["H264", "H265"]),
14108
- resolution: object({
14109
- width: number().int().positive(),
14110
- height: number().int().positive()
14111
- }),
14112
- transcoded: boolean(),
14113
- encoder: string(),
14114
- /**
14115
- * The decode backend the child ACTUALLY ran with — `null` for software.
14116
- * Returned rather than assumed: a consumer that asked for hardware and got
14117
- * software needs to be able to see that without reading the broker's logs.
14118
- */
14119
- decodeHwAccel: string().nullable(),
13771
+ locations: array(string()).optional(),
14120
13772
  /**
14121
- * Set when `publishLocally` was honoured: attach to THIS instead of dialling
14122
- * `url`, and the session takes the push/deframe transport rather than the
14123
- * RTP-passthrough one. `null` means the consumer must dial.
13773
+ * The backup SECRET key, for an encrypted archive (ignored for a plain
13774
+ * one). The archive is decrypted into the hub's private staging
13775
+ * before the restart; the key is not stored. A wrong or missing key
13776
+ * is refused before the archive is downloaded.
14124
13777
  */
14125
- camStreamId: string().nullable()
14126
- });
14127
- method(object({
14128
- deviceId: number().int().nonnegative(),
14129
- /** The provider's signal code. */
14130
- code: string().min(1),
14131
- /** Ms epoch. Absent ⇒ now. */
14132
- at: number().optional()
14133
- }), object({
14134
- /** Whether the broker acted on it, and if not, why. */
14135
- accepted: boolean(),
14136
- reason: _enum([
14137
- "woke",
14138
- "hold-extended",
14139
- "not-enabled",
14140
- "no-consumer",
14141
- "unknown-code"
14142
- ])
14143
- }), { kind: "mutation" }), method(object({
14144
- deviceId: number().int().nonnegative(),
14145
- camStreamId: string().min(1),
14146
- kind: CamStreamKindSchema,
14147
- url: string().optional(),
14148
- codec: string().optional(),
14149
- resolution: CamStreamResolutionSchema.optional(),
14150
- fps: number().positive().optional(),
14151
- label: string().optional(),
14152
- deviceFeatures: array(string()).optional(),
14153
- autoEligible: boolean().optional(),
14154
- /** The slot the publisher says this stream IS — see `CameraStreamSchema.profileHint`. */
14155
- profileHint: CamProfileSchema.optional(),
14156
- metadata: record(string(), unknown()).optional(),
14157
- /** Required when kind === 'derived' — the source camStreamId the
14158
- * derived broker reads its encoded plane from.
14159
- * The runtime guard (broker manager, Task 10) enforces presence for
14160
- * kind === 'derived'; the schema stays permissive so ts-morph emits
14161
- * full field types instead of collapsing to ZodEffects. */
14162
- sourceCamStreamId: string().min(1).optional(),
14163
- /** Required when kind === 'derived' — the ffmpeg params for the
14164
- * spawn. Reuses the same brokerId for identical params (single-flight).
14165
- * Runtime guard in broker manager; schema is permissive (see above). */
14166
- encodeProfile: EncodeProfileSchema.optional()
14167
- }), object({ success: literal(true) }), {
14168
- kind: "mutation",
14169
- auth: "admin"
14170
- }), method(object({
14171
- deviceId: number().int().nonnegative(),
14172
- camStreamId: string().min(1)
14173
- }), object({ success: literal(true) }), {
14174
- kind: "mutation",
14175
- auth: "admin"
14176
- }), method(object({
14177
- deviceId: number().int().nonnegative(),
14178
- profile: CamProfileSchema,
14179
- camStreamId: string().min(1)
14180
- }), object({ success: literal(true) }), {
14181
- kind: "mutation",
14182
- auth: "admin"
14183
- }), method(object({
14184
- deviceId: number().int().nonnegative(),
14185
- profile: CamProfileSchema
14186
- }), object({ success: literal(true) }), {
14187
- kind: "mutation",
14188
- auth: "admin"
14189
- }), method(object({ deviceId: number().int().nonnegative() }), object({
14190
- derivedStreamsDeleted: array(string()).readonly(),
14191
- assignmentsPurged: boolean(),
14192
- probeSnapshotsDropped: number().int().nonnegative(),
14193
- rtspTokenRowsDeleted: number().int().nonnegative()
14194
- }), {
13778
+ backupSecretKey: string().optional()
13779
+ }), _void(), {
14195
13780
  kind: "mutation",
14196
13781
  auth: "admin"
14197
- }), method(object({
14198
- deviceId: number(),
14199
- /** Absent = the LOWEST assigned profile — a notification attachment is
14200
- * watched on a phone, and the cheap rendition is the right default. */
14201
- profile: CamProfileSchema.optional(),
14202
- aroundMs: number(),
14203
- preRollSec: number().min(0).max(20).default(3),
14204
- postRollSec: number().min(0).max(20).default(5),
14205
- format: _enum(["gif", "mp4"]).default("gif"),
14206
- maxWidth: number().int().min(120).max(1920).default(480),
14207
- /** GIF only — MP4 keeps the source cadence. */
14208
- fps: number().int().min(1).max(15).default(5),
14209
- /**
14210
- * Playback rate. A notification clip is GLANCED at on a lock screen,
14211
- * not watched, so 2x is the default: the recipient sees the whole
14212
- * passage in half the time and the GIF is half the bytes. `1` is real
14213
- * time. Applies to MP4 as well — the operator set a speed, not a GIF
14214
- * speed.
14215
- */
14216
- speed: number().min(1).max(8).default(2)
14217
- }), object({
14218
- base64: string(),
14219
- mime: string(),
14220
- bytes: number().int(),
14221
- /** The profile actually rendered (what the default resolved to). */
14222
- profile: CamProfileSchema,
14223
- durationMs: number()
13782
+ }), method(_void(), BackupEncryptionStatusSchema, { auth: "admin" }), method(object({}), object({
13783
+ keyId: string(),
13784
+ secretKey: string(),
13785
+ publicKey: string(),
13786
+ createdAt: number(),
13787
+ replacedKeyId: string().nullable()
14224
13788
  }), {
14225
13789
  kind: "mutation",
14226
13790
  auth: "admin"
14227
- }), method(object({
14228
- deviceId: number(),
14229
- /** Absent = the largest H.264 rendition at or below 1080p, which is
14230
- * also the one that can be copied. Falls back to whatever the ring
14231
- * actually retained, and the answer says which. */
14232
- profile: CamProfileSchema.optional(),
14233
- aroundMs: number(),
14234
- preSeconds: number().min(0).max(20).default(4),
14235
- postSeconds: number().min(0).max(20).default(6),
14236
- kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
14237
- /** GIF geometry. The video keeps the source's own. */
14238
- gifMaxWidth: number().int().min(120).max(1280).default(640),
14239
- /**
14240
- * The gif's own PLAYBACK rate in frames per second — what the finished
14241
- * gif runs at, not how many source frames feed it. The decimation that
14242
- * feeds it samples `gifFps / gifSpeed` source frames per second, so at
14243
- * the defaults a 12 fps gif is built out of 3 source frames a second.
14244
- */
14245
- gifFps: number().int().min(1).max(15).default(12),
14246
- /**
14247
- * How fast the GIF plays against real time, independent of `speed`.
14248
- *
14249
- * 4× by default, by operator request: a notification gif is glanced at
14250
- * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
14251
- * a separate knob from `speed` even though both now default to 4 —
14252
- * a caller wanting a real-time video and a fast gif must not have to
14253
- * choose.
14254
- */
14255
- gifSpeed: number().min(1).max(8).default(4),
14256
- /**
14257
- * Playback rate of the VIDEO. Also 4× by default, by operator decision.
14258
- *
14259
- * `1` is real time and is the ONLY value that allows the copy branch —
14260
- * anything else forces `libx264` over the window. That was priced
14261
- * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
14262
- * on a real 615 720p cut, against 922 KB for the copy it replaces. A
14263
- * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
14264
- * once the decode is forced the width stops being free.
14265
- */
14266
- speed: number().min(1).max(8).default(4)
14267
- }), EventMediaProductionSchema, {
14268
- kind: "mutation",
14269
- auth: "admin"
14270
- }), method(object({ handle: string() }), object({
14271
- base64: string(),
14272
- mime: string(),
14273
- bytes: number().int()
14274
- }).nullable(), {
13791
+ }), method(object({}), object({ disabledKeyId: string().nullable() }), {
14275
13792
  kind: "mutation",
14276
13793
  auth: "admin"
14277
- }), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
14278
- probed: boolean(),
14279
- summary: string()
13794
+ }), method(object({ backupSecretKey: string() }), object({
13795
+ valid: boolean(),
13796
+ keyId: string().nullable(),
13797
+ /** The hub knows this key (active or retired). */
13798
+ known: boolean(),
13799
+ /** It is the key new archives are encrypted to. */
13800
+ active: boolean(),
13801
+ error: string().optional()
14280
13802
  }), {
14281
13803
  kind: "mutation",
14282
13804
  auth: "admin"
14283
- }), method(object({ brokerId: string() }), BrokerClientsSchema), method(_void(), array(LiveViewerCountSchema).readonly()), method(object({
14284
- brokerId: string(),
14285
- channel: _enum(["rtsp", "audio"]),
14286
- handle: string()
14287
- }), object({ killed: boolean() }), {
14288
- kind: "mutation",
14289
- auth: "admin"
14290
- }), method(object({
14291
- deviceId: number().int().nonnegative(),
14292
- profile: CamProfileSchema
14293
- }), object({ success: boolean() }), {
14294
- kind: "mutation",
14295
- auth: "admin"
14296
13805
  }), method(object({
14297
- streamId: string(),
14298
- format: StreamFormatSchema
14299
- }), object({ url: string() })), method(GetStreamWithCodecInputSchema, RtpSourceSchema, {
14300
- kind: "mutation",
14301
- auth: "admin"
14302
- }), method(object({ pipelineKey: string() }), object({
14303
- released: boolean(),
14304
- refcount: number().int().nonnegative()
14305
- }), {
13806
+ destinationId: string(),
13807
+ backupId: string()
13808
+ }), _void(), {
14306
13809
  kind: "mutation",
14307
13810
  auth: "admin"
14308
- }), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
13811
+ }), method(object({ destinationId: string() }), array(BackupArchiveEntrySchema).readonly(), { auth: "admin" }), method(object({
13812
+ locationId: string(),
13813
+ enabled: boolean(),
13814
+ retentionCount: number().int().min(1).max(1e3),
13815
+ label: string().optional()
13816
+ }), _void(), {
14309
13817
  kind: "mutation",
14310
13818
  auth: "admin"
14311
- }), method(object({ pipelineKey: string() }), object({
14312
- released: boolean(),
14313
- refcount: number().int().nonnegative()
14314
- }), {
13819
+ }), method(object({
13820
+ cron: string(),
13821
+ count: number().int().min(1).max(20).optional()
13822
+ }), object({
13823
+ ok: boolean(),
13824
+ error: string().optional(),
13825
+ nextRuns: array(number()).readonly()
13826
+ })), method(_void(), array(BackupScheduleSchema).readonly(), { auth: "admin" }), method(_void(), object({
13827
+ answered: boolean(),
13828
+ databases: array(DataStoreDatabaseSchema).readonly()
13829
+ }), { auth: "admin" }), method(object({
13830
+ id: string().optional(),
13831
+ label: string(),
13832
+ cron: string(),
13833
+ enabled: boolean(),
13834
+ locationIds: array(string()).readonly(),
13835
+ retentionCount: number().int().min(1).max(1e3),
13836
+ dataSources: array(string()).readonly().optional(),
13837
+ databases: array(DataStoreDatabaseSchema).readonly().optional()
13838
+ }), BackupScheduleSchema, {
14315
13839
  kind: "mutation",
14316
13840
  auth: "admin"
14317
- }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
14318
- subscriptionId: string(),
14319
- maxCount: number().int().positive().default(8)
14320
- }), array(DecodedAudioChunkSchema).readonly()), method(object({ subscriptionId: string() }), object({ released: boolean() }), { kind: "mutation" }), method(object({
14321
- brokerId: string(),
14322
- seconds: number().min(0).max(30)
14323
- }), _void(), {
13841
+ }), method(object({ id: string() }), _void(), {
14324
13842
  kind: "mutation",
14325
13843
  auth: "admin"
14326
- }), method(object({ brokerId: string() }), object({
14327
- configuredSec: number(),
14328
- bufferedMs: number(),
14329
- packetCount: number()
14330
- })), method(_void(), number()), method(object({ hostname: string().optional() }), array(RtspRestreamEntrySchema).readonly()), method(object({
14331
- brokerId: string(),
14332
- hostname: string().optional()
14333
- }), RtspRestreamEntrySchema.nullable()), method(object({ brokerId: string() }), string().nullable(), {
14334
- kind: "mutation",
14335
- auth: "admin"
14336
- }), method(object({
14337
- brokerId: string(),
14338
- enabled: boolean()
14339
- }), _void(), {
14340
- kind: "mutation",
14341
- auth: "admin"
14342
- }), method(object({ brokerId: string() }), boolean()), method(object({ deviceId: number().int() }), object({
14343
- muted: boolean(),
14344
- /**
14345
- * How many live non-derived brokers currently hold the mute. Purely
14346
- * diagnostic: `muted` is the policy and is authoritative on its own
14347
- * (it applies to brokers that do not exist yet), while this says
14348
- * whether anything is presently being silenced.
14349
- */
14350
- appliedBrokers: number().int().nonnegative()
14351
- })), method(object({
14352
- deviceId: number().int(),
14353
- muted: boolean()
14354
- }), object({
14355
- muted: boolean(),
14356
- appliedBrokers: number().int().nonnegative()
14357
- }), {
14358
- kind: "mutation",
14359
- auth: "admin"
14360
- }), object({
14361
- deviceId: number().int().nonnegative(),
14362
- camStreamId: string(),
14363
- profile: CamProfileSchema
14364
- }), object({
14365
- deviceId: number().int().nonnegative(),
14366
- camStreamId: string()
14367
- }), object({
14368
- deviceId: number().int().nonnegative(),
14369
- camStreamId: string(),
14370
- brokerId: string()
14371
13844
  });
14372
- var StreamCodecSchema = _enum([
14373
- "h264",
14374
- "h265",
14375
- "hevc",
14376
- "av1",
14377
- "mjpeg",
14378
- "vp8",
14379
- "vp9"
13845
+ /**
13846
+ * `broker` — unified pub/sub broker registry, system-scoped collection.
13847
+ *
13848
+ * The cap models any kind-tagged message broker the user wants to
13849
+ * register and that other addons might consume. The first two kinds
13850
+ * are MQTT (mosquitto / aedes embedded / cloud bridge) and
13851
+ * Home Assistant (HA WebSocket — subscribe_entities + call_service).
13852
+ * Future kinds (Zigbee2MQTT bridge, ZHA, KNX, Telegram, …) plug in
13853
+ * the same surface.
13854
+ *
13855
+ * Why one cap, not one cap per kind:
13856
+ * - The integrations page wants a single table of "every broker"
13857
+ * across kinds; a unified cap drives it without join logic.
13858
+ * - Generic admin operations (add / remove / test / status) are
13859
+ * identical across kinds — duplicating them per cap was busywork.
13860
+ * - The `network-access` cap precedent: same interface, many
13861
+ * `providerKind: 'ingress'` implementations (Tailscale, ngrok, …).
13862
+ *
13863
+ * What stays kind-specific:
13864
+ * - `getBrokerConfig` payload (kind decides whether it returns
13865
+ * mqtt URL+creds, HA baseUrl+token, …) — typed as
13866
+ * `Record<string, unknown>` at the wire; consumers narrow.
13867
+ * - `publish` / `subscribe` / `getState` arguments (target / filter /
13868
+ * key) — also typed as `Record<string, unknown>` so each kind can
13869
+ * evolve its surface (MQTT topic+qos vs HA entity_id+domain)
13870
+ * without rebreaking the cap signature.
13871
+ * - `add` `settings` payload (different fields per kind, validated by
13872
+ * the kind-specific provider).
13873
+ *
13874
+ * Bidirectionality:
13875
+ * - `publish` is RPC — synchronous result (provider-defined).
13876
+ * - `subscribe` returns a subscription handle; the actual message
13877
+ * stream flows over the typed event-bus as `broker.message` events
13878
+ * keyed by `(brokerId, subscriptionId)`. Consumers filter in their
13879
+ * event handler. Subscriptions persist across reconnects — the
13880
+ * provider re-subscribes upstream after a transport drop.
13881
+ *
13882
+ * Why event-bus push (not a subscription RPC stream):
13883
+ * - Matches the rest of CamStack's D8 contract — events for telemetry,
13884
+ * RPC for loss-is-a-bug. Broker messages are telemetry (a single
13885
+ * drop is recoverable via `getState({key})`).
13886
+ * - Frees `broker.subscribe` from holding a long-lived RPC channel.
13887
+ * - Cross-process: events already route through `$event-bus`; no
13888
+ * per-broker wire-shaping needed.
13889
+ */
13890
+ var BrokerStatusEnum = _enum([
13891
+ "connected",
13892
+ "disconnected",
13893
+ "connecting",
13894
+ "auth-failed",
13895
+ "unreachable",
13896
+ "error"
14380
13897
  ]);
14381
- var PickStreamRequirementsSchema = object({
14382
- /**
14383
- * Codecs the consumer can decode without an intermediate transcode.
14384
- * Match is case-insensitive against the camera stream's `codec`.
14385
- * Default (omitted / empty array): no codec filter — useful when the
14386
- * consumer just wants "the highest-quality stream of any codec".
14387
- */
14388
- acceptCodecs: array(StreamCodecSchema).readonly().optional(),
14389
- /** Minimum vertical resolution. Streams shorter than this are dropped. */
14390
- minHeight: number().int().positive().optional(),
14391
- /**
14392
- * Maximum vertical resolution. Streams TALLER than this are dropped.
14393
- *
14394
- * A consumer can have a ceiling as real as its floor: Alexa documents
14395
- * 480p to 1080p, and a 4K stream is as unusable to an Echo as a 360p one.
14396
- * Without this the ceiling had to be re-implemented by every caller — and
14397
- * one caller implementing it privately is how a second scoring authority
14398
- * gets born.
14399
- */
14400
- maxHeight: number().int().positive().optional(),
14401
- /** Minimum horizontal resolution. */
14402
- minWidth: number().int().positive().optional(),
14403
- /**
14404
- * When true (default), `derived:*` streams (transcoded broker pipes)
14405
- * are excluded — the caller wants a SOURCE stream the broker can
14406
- * forward without a re-encode. Set false to allow derived in the
14407
- * picker (useful for a "best playable" fallback).
14408
- */
14409
- excludeDerived: boolean().optional(),
14410
- /**
14411
- * Bypass gate. When set, the picker returns a hit ONLY IF the device
14412
- * also exposes a non-derived stream in one of these (rejected) codecs.
14413
- * Mirrors the Alexa "bypass only when the natural path WOULD have
14414
- * transcoded" guard: if the device is already serving the consumer's
14415
- * codec end-to-end, there's nothing to optimise.
14416
- */
14417
- requireSiblingCodec: array(StreamCodecSchema).readonly().optional(),
14418
- /**
14419
- * Whether a stream the consumer CANNOT decode may still be picked, on the
14420
- * understanding that it will be transcoded.
14421
- *
14422
- * Default `false` — today's behaviour, and the right one for a bypass
14423
- * question ("is there a stream I can forward untouched?"). Set `true` to
14424
- * ask the larger question: "what is the best stream for me, transcoding if
14425
- * I must?" A stream that satisfies `acceptCodecs` always outranks one that
14426
- * does not, so a passthrough is never lost to a transcode; the answer says
14427
- * which it is in {@link PickedCamStreamSchema.transcodes}.
13898
+ var BrokerInfoSchema = object({
13899
+ /** Stable broker id. Persisted; survives addon restarts. */
13900
+ id: string(),
13901
+ /** Addon id of the provider that OWNS this broker.
14428
13902
  *
14429
- * This is what lets one picker serve both the bypass and the full source
14430
- * choice, instead of a consumer scoring privately when the bypass misses.
14431
- */
14432
- allowTranscode: boolean().optional(),
13903
+ * The `broker` cap is a system-scoped collection: several addons
13904
+ * register a `broker` provider, each owning a DISJOINT set of
13905
+ * brokers (mqtt-broker owns `mqtt_*`, provider-homeassistant owns
13906
+ * `ha_*`). A broker therefore belongs to exactly one addon — like a
13907
+ * device belongs to one integration. The admin UI threads this id
13908
+ * back as the `{ addonId }` system-collection selector on every
13909
+ * id-keyed call (`get` / `getSettings` / `setSettings` / `remove` /
13910
+ * `testConnection`), so the call routes to the OWNING provider
13911
+ * instead of defaulting to the first-registered one. */
13912
+ addonId: string(),
13913
+ /** Human-readable name (operator-chosen at add-time). */
13914
+ name: string(),
13915
+ /** Provider-defined kind tag — `mqtt` / `home-assistant` / future. */
13916
+ kind: string(),
13917
+ status: BrokerStatusEnum,
13918
+ /** Free-form provider-specific info (HA version, MQTT broker
13919
+ * flavour, latency, mTLS active, …). */
13920
+ info: record(string(), unknown()),
13921
+ /** Ms epoch of the last connection probe / status update. */
13922
+ lastCheckedAt: number().nullable(),
13923
+ /** Last error message, when `status` indicates failure. */
13924
+ error: string().nullable()
13925
+ });
13926
+ var RegistryStatusSchema = object({
13927
+ brokerCount: number().int().nonnegative(),
13928
+ connectedCount: number().int().nonnegative()
13929
+ });
13930
+ /**
13931
+ * One entry per `broker` provider — which addon provides which broker
13932
+ * kind(s). The unified create picker reads this to know, for each
13933
+ * (addonId, kind) pair the operator can pick, where to route the
13934
+ * follow-up `getSettingsSchema` / `testSettings` / `add` calls (via the
13935
+ * `{ addonId }` selector). A provider only advertises a kind here when
13936
+ * it implements `getSettingsSchema` for that kind (so the picker never
13937
+ * offers a kind whose creation form can't be rendered).
13938
+ */
13939
+ var BrokerProviderInfoSchema = object({
13940
+ /** Addon id of the `broker` provider this entry describes. */
13941
+ addonId: string(),
13942
+ /** Broker kinds this provider can create, with a display label. */
13943
+ kinds: array(object({
13944
+ kind: string(),
13945
+ label: string(),
13946
+ /**
13947
+ * Relative hub path to start an interactive account link (OAuth popup).
13948
+ * May include `{{brokerId}}`; admin-ui substitutes and appends `?popup=1`.
13949
+ */
13950
+ interactiveConnectPath: string().min(1).optional(),
13951
+ /** Relative hub path to disconnect the linked account (same popup pattern). */
13952
+ interactiveDisconnectPath: string().min(1).optional()
13953
+ }))
13954
+ });
13955
+ var ListInputSchema = object({
13956
+ /** Optional kind filter — `list({kind:'home-assistant'})` returns
13957
+ * only HA brokers. Omit to list every broker. */
13958
+ kind: string().optional() });
13959
+ var GetInputSchema = object({ id: string() });
13960
+ var AddInputSchema = object({
13961
+ kind: string().min(1),
13962
+ name: string().min(1),
14433
13963
  /**
14434
- * Consider ONLY streams the broker has bound to a profile slot.
13964
+ * ADOPT an existing id instead of minting a new one.
14435
13965
  *
14436
- * A consumer that dials the broker — which is every exporter, since none
14437
- * of them may open the camera's own URL — can do nothing with a source
14438
- * that has no slot: there is no restream to dial. Without this the picker
14439
- * can name such a stream, and each consumer discovers the dead end for
14440
- * itself: Alexa's `profileForCamStream` returns `null` and it quietly
14441
- * falls back, and HomeKit would have had to grow the same function again.
13966
+ * Written the day this cost an outage. A broker's id is not a detail: HA
13967
+ * devices carry it inside their `stableId` (`ha:ha_004:dev:…`), so a broker
13968
+ * lost from config and re-added as `ha_001` leaves every one of its devices
13969
+ * bound to a broker that no longer exists. Re-entering the password under the
13970
+ * ORIGINAL id turns a multi-step device migration back into re-entering a
13971
+ * password.
14442
13972
  *
14443
- * Default `false`, and deliberately so: the BROKER itself picks through
14444
- * here when it decides which source to BIND to a slot
14445
- * (`computeInitialAssignment`), and it must see the unbound ones or it
14446
- * could never bind them.
14447
- */
14448
- requireProfileBound: boolean().optional()
14449
- }).readonly();
14450
- var PickStreamPreferencesSchema = object({
14451
- /**
14452
- * Ordered list of provider prefixes (e.g. `['native:', 'rtsp:']`) the
14453
- * picker prefers — earlier entries win ties. Defaults to
14454
- * `['native:']`, mirroring Alexa's "prefer the native dial path".
14455
- */
14456
- preferredProviders: array(string()).readonly().optional(),
14457
- /**
14458
- * Resolution preference within the surviving candidates. `'highest'`
14459
- * picks the tallest stream; `'lowest'` picks the shortest (used by
14460
- * memory-constrained consumers / Apple Home guest sessions).
13973
+ * A provider MUST refuse an id that is already in use — adopting a live
13974
+ * broker's id would silently take it over.
14461
13975
  */
14462
- resolutionPreference: _enum(["highest", "lowest"]).optional(),
14463
- /**
14464
- * The height the consumer actually wants to DELIVER.
14465
- *
14466
- * Not a constraint — an ordering. With it set, the SMALLEST stream at or
14467
- * above the target wins, and only if nothing reaches it does the tallest
14468
- * take over. Pulling 1296 lines to draw 720 on a 1280x800 Echo panel costs
14469
- * a decode and buys nothing, and `resolutionPreference: 'highest'` cannot
14470
- * express that: it says "as big as possible", which is a different wish.
14471
- *
14472
- * Ignored when absent, so every existing caller keeps its ordering.
14473
- */
14474
- targetHeight: number().int().positive().optional()
14475
- }).readonly();
14476
- var PickedCamStreamSchema = object({
14477
- camStreamId: string(),
14478
- codec: string().optional(),
14479
- resolution: CamStreamResolutionSchema.optional(),
14480
- /**
14481
- * Whether serving this stream requires a decode + re-encode.
14482
- *
14483
- * `false` is a stream the consumer can take as it stands. Only ever `true`
14484
- * when the caller asked for it with `allowTranscode`, so a caller that did
14485
- * not ask cannot be handed a cost it never agreed to pay.
14486
- */
14487
- transcodes: boolean(),
14488
- /** One-line explanation of why this stream won — for logs / debug UI. */
14489
- reason: string(),
14490
- /**
14491
- * The broker profile slot this source is bound to, when it is bound.
14492
- *
14493
- * Answered HERE because every consumer needs it and none of them can get it
14494
- * from the `camStreamId` alone. Alexa wrote `profileForCamStream` for it —
14495
- * a second cap read plus a `null` branch — and HomeKit was about to write
14496
- * the same function a second time.
14497
- *
14498
- * Absent when the source has no slot, which a caller passing
14499
- * `requireProfileBound` can never see.
14500
- */
14501
- profile: CamProfileSchema.optional(),
14502
- /** The broker id of that slot, for logs that must name what was dialled. */
14503
- brokerId: string().optional(),
14504
- /**
14505
- * The broker restream URL to dial for this stream.
14506
- *
14507
- * An exporter must never open the camera's own URL — the broker is the one
14508
- * that dials the camera, and every consumer goes through its restream. So
14509
- * the picker answers with the thing a consumer may actually use, instead of
14510
- * naming a source and leaving each caller to look the URL up.
14511
- */
14512
- url: string().optional(),
14513
- /**
14514
- * Frames per second this stream really delivers, and where the number came
14515
- * from.
14516
- *
14517
- * It is here because getting it wrong is expensive and silent: a HomeKit
14518
- * accessory advertised 720p at 24 fps while the slot serving it ran at 9,
14519
- * and the controller rendered nothing at all. The source is carried with the
14520
- * value for the same reason the bitrate evidence is: `published` is what the
14521
- * publisher declares and `measured` is what the broker observed, and they
14522
- * are not interchangeable.
14523
- */
14524
- fps: number().positive().optional(),
14525
- fpsSource: _enum(["published", "measured"]).optional()
13976
+ id: string().min(1).optional(),
13977
+ /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
13978
+ * `{baseUrl,accessToken}`). Validated by the kind-specific provider
13979
+ * branch on receipt — invalid shape rejects the add. */
13980
+ settings: record(string(), unknown())
14526
13981
  });
14527
- DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(CameraStreamSchema).readonly()), method(object({ deviceId: number().int().nonnegative() }), array(ProfileSlotSchema).readonly()), method(object({
14528
- deviceId: number().int().nonnegative(),
14529
- /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
14530
- hostname: string().optional()
14531
- }), array(RtspRestreamEntrySchema).readonly()), method(object({
14532
- deviceId: number().int().nonnegative(),
14533
- /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
14534
- hostname: string().optional()
14535
- }), array(ProfileRtspEntrySchema).readonly()), method(object({
14536
- deviceId: number().int().nonnegative(),
14537
- requirements: PickStreamRequirementsSchema,
14538
- preferences: PickStreamPreferencesSchema.optional()
14539
- }), PickedCamStreamSchema.nullable()), object({
14540
- deviceId: number().int().nonnegative(),
14541
- camStreams: array(CameraStreamSchema).readonly()
14542
- }), object({
14543
- deviceId: number().int().nonnegative(),
14544
- profileSlots: array(ProfileSlotSchema).readonly()
13982
+ var AddResultSchema = object({ id: string() });
13983
+ var RemoveInputSchema = object({ id: string() });
13984
+ var TestConnectionResultSchema = discriminatedUnion("ok", [object({
13985
+ ok: literal(true),
13986
+ latencyMs: number().nonnegative()
14545
13987
  }), object({
14546
- online: boolean(),
14547
- slotStatuses: object({
14548
- high: ProfileSlotStatusSchema.optional(),
14549
- mid: ProfileSlotStatusSchema.optional(),
14550
- low: ProfileSlotStatusSchema.optional()
14551
- }),
14552
- slotErrors: object({
14553
- high: string().optional(),
14554
- mid: string().optional(),
14555
- low: string().optional()
14556
- }),
14557
- lastChangedAt: number()
13988
+ ok: literal(false),
13989
+ error: string()
13990
+ })]);
13991
+ var SettingsRecordSchema$1 = record(string(), unknown());
13992
+ var SettingsSchemaInputSchema = object({ kind: string() });
13993
+ var TestSettingsInputSchema = object({
13994
+ kind: string(),
13995
+ settings: SettingsRecordSchema$1
14558
13996
  });
14559
- EventCategory.DoorbellOnPressed;
14560
- var CompositionEventPlanSchema = object({
14561
- featureIndex: number().int().nonnegative(),
14562
- cap: string(),
14563
- eventName: string(),
14564
- category: _enum(EventCategory),
14565
- stateField: string(),
14566
- fieldKey: string()
13997
+ var TestSettingsResultSchema = discriminatedUnion("ok", [object({
13998
+ ok: literal(true),
13999
+ latencyMs: number().nonnegative().optional()
14000
+ }).strict(), object({
14001
+ ok: literal(false),
14002
+ error: string()
14003
+ })]);
14004
+ var SettingsSchemaResultSchema = unknown().nullable();
14005
+ var PublishInputSchema = object({
14006
+ brokerId: string(),
14007
+ /** Kind-specific routing target.
14008
+ * MQTT: `{ topic: string, qos?: 0|1|2, retain?: boolean }`.
14009
+ * HA: `{ domain: string, service: string, entityId?: string, area?: string }`. */
14010
+ target: record(string(), unknown()),
14011
+ /** Kind-specific payload.
14012
+ * MQTT: raw string / number / object (provider serialises).
14013
+ * HA: service-call `data` object (transition, brightness, …). */
14014
+ payload: unknown().optional()
14567
14015
  });
14568
- /** One refusal, named (D391). `path` points into the composition, e.g. `features[0].fields.celsius`. */
14569
- var CompositionProblemSchema = object({
14570
- code: _enum([
14571
- "reserved-variant",
14572
- "unknown-capability",
14573
- "not-composable",
14574
- "wrong-device-type",
14575
- "has-commands",
14576
- "unknown-field",
14577
- "nested-field",
14578
- "missing-field",
14579
- "kind-mismatch",
14580
- "may-be-null",
14581
- "source-missing",
14582
- "source-unreadable",
14583
- "source-capability-missing",
14584
- "source-field-unknown",
14585
- "expression-invalid",
14586
- "cycle",
14587
- "duplicate-target",
14588
- "not-item-array",
14589
- "item-field-reserved",
14590
- "target-missing",
14591
- "target-unreadable",
14592
- "not-replaceable"
14593
- ]),
14594
- path: string(),
14595
- message: string()
14016
+ var SubscribeInputSchema = object({
14017
+ brokerId: string(),
14018
+ /** Kind-specific filter.
14019
+ * MQTT: `{ topic: string, qos?: 0|1|2 }` — topic can include wildcards.
14020
+ * HA: `{ entityIds: string[] }` or `{ domain: string }`. */
14021
+ filter: record(string(), unknown())
14596
14022
  });
14597
- var CompositionFieldKindSchema = _enum([
14598
- "string",
14599
- "number",
14600
- "boolean",
14601
- "enum"
14023
+ var SubscribeResultSchema = object({
14024
+ /** Stable subscription id. Used to unsubscribe and to filter the
14025
+ * matching `broker.message` events on the bus. */
14026
+ subscriptionId: string() });
14027
+ var UnsubscribeInputSchema = object({
14028
+ brokerId: string(),
14029
+ subscriptionId: string()
14030
+ });
14031
+ var GetStateInputSchema = object({
14032
+ brokerId: string(),
14033
+ /** Kind-specific lookup key.
14034
+ * MQTT: topic string (returns the last retained message).
14035
+ * HA: entity_id (returns the cached entity state). */
14036
+ key: string()
14037
+ });
14038
+ method(ListInputSchema, array(BrokerInfoSchema)), method(GetInputSchema, BrokerInfoSchema.nullable()), method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }), method(AddInputSchema, AddResultSchema, {
14039
+ kind: "mutation",
14040
+ auth: "admin"
14041
+ }), method(RemoveInputSchema, _void(), {
14042
+ kind: "mutation",
14043
+ auth: "admin"
14044
+ }), method(GetInputSchema, TestConnectionResultSchema, {
14045
+ kind: "mutation",
14046
+ auth: "admin"
14047
+ }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(object({
14048
+ id: string(),
14049
+ settings: SettingsRecordSchema$1
14050
+ }), _void(), {
14051
+ kind: "mutation",
14052
+ auth: "admin"
14053
+ }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }), method(TestSettingsInputSchema, TestSettingsResultSchema, {
14054
+ kind: "mutation",
14055
+ auth: "admin"
14056
+ }), method(PublishInputSchema, unknown(), {
14057
+ kind: "mutation",
14058
+ auth: "admin"
14059
+ }), method(SubscribeInputSchema, SubscribeResultSchema, {
14060
+ kind: "mutation",
14061
+ auth: "admin"
14062
+ }), method(UnsubscribeInputSchema, _void(), {
14063
+ kind: "mutation",
14064
+ auth: "admin"
14065
+ }), method(GetStateInputSchema, unknown().nullable(), { auth: "admin" }), method(_void(), RegistryStatusSchema);
14066
+ DeviceType.Camera;
14067
+ /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
14068
+ var StreamFormatSchema = _enum([
14069
+ "webrtc",
14070
+ "hls",
14071
+ "mjpeg",
14072
+ "rtsp"
14602
14073
  ]);
14074
+ /** A container `produceEventMedia` can emit. */
14075
+ var EventMediaKindSchema = _enum(["mp4", "gif"]);
14603
14076
  /**
14604
- * How a field says "unavailable" (D393, D663): `null` when its schema allows it;
14605
- * `unknown` — the enum's own word — for an ADDED field that has one; `release`
14606
- * for a REPLACED field of an existing device, whose claim is released so the
14607
- * native provider's reading shows again; else the (composed) device goes offline.
14077
+ * One produced artifact, referenced by HANDLE.
14078
+ *
14079
+ * Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
14080
+ * method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
14081
+ * [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
14082
+ * on demand, compressed, by handle). `bytes` is here so a caller can decide
14083
+ * whether it wants the fetch at all.
14608
14084
  */
14609
- var CompositionUnavailableModeSchema = _enum([
14610
- "null",
14611
- "offline",
14612
- "unknown",
14613
- "release"
14614
- ]);
14085
+ var EventMediaArtifactSchema = object({
14086
+ kind: EventMediaKindSchema,
14087
+ /** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
14088
+ handle: string(),
14089
+ /**
14090
+ * The node holding the bytes — the ROUTING key for `fetchEventMedia`.
14091
+ *
14092
+ * `stream-broker` is a singleton cap and an unpinned call never leaves the
14093
+ * hub, so a handle produced on an agent's broker would be redeemed against
14094
+ * the hub's store and come back `null`. Same contract, same field name and
14095
+ * the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
14096
+ * lives and the consumer pins to it.
14097
+ */
14098
+ nodeId: string(),
14099
+ mime: string(),
14100
+ bytes: number().int(),
14101
+ width: number().int(),
14102
+ height: number().int()
14103
+ });
14615
14104
  /**
14616
- * `derived` reads a source · `clock` reads only `now`, recomputed when a sibling
14617
- * field of its cap changes value · `constant` reads nothing, computed once ·
14618
- * `accumulator` advances from the target's seed/current output.
14105
+ * What a production actually covered — the answer to the only question an
14106
+ * operator asks about a notification clip.
14107
+ *
14108
+ * `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
14109
+ * so a caller can state "this clip starts 4.1 s before the event" instead of
14110
+ * inferring it from a duration. A production whose `fromTs` is later than the
14111
+ * event is a production with no pre-roll, and that is exactly the defect this
14112
+ * method exists to make visible rather than plausible.
14619
14113
  */
14620
- var CompositionFieldRoleSchema = _enum([
14621
- "derived",
14622
- "clock",
14623
- "constant",
14624
- "accumulator"
14625
- ]);
14626
- /** The item a planned field belongs to; its `path` is then the dotted path INSIDE the item. */
14627
- var CompositionFieldItemSchema = object({
14628
- arrayPath: string(),
14629
- key: string(),
14630
- label: string()
14631
- });
14632
- var CompositionFieldPlanSchema = object({
14633
- cap: string(),
14634
- /** Top-level field, or — when `item` is set — the dotted path inside that item. */
14635
- path: string(),
14636
- item: CompositionFieldItemSchema.optional(),
14637
- /** Index into `composition.features` this field's capability came from —
14638
- * lets a source/cycle problem name its path as `features[N].fields.<path>`
14639
- * (the same convention `planComposition`'s own problems already use). */
14640
- featureIndex: number().int().nonnegative(),
14641
- kind: CompositionFieldKindSchema,
14642
- nullable: boolean(),
14643
- required: boolean(),
14644
- unavailable: CompositionUnavailableModeSchema,
14645
- role: CompositionFieldRoleSchema,
14646
- source: CompositionFieldSourceSchema,
14647
- reads: array(CompositionFieldReadSchema)
14114
+ var EventMediaCoverageSchema = object({
14115
+ fromTs: number(),
14116
+ toTs: number(),
14117
+ /** Encoded packets in the muxed window. */
14118
+ packets: number().int()
14648
14119
  });
14649
14120
  /**
14650
- * What a feature does to its target (D663): `new` builds a capability on a new
14651
- * device; on an existing device it `add`s a capability the device lacks, or
14652
- * `replace`s named fields of one it has natively.
14121
+ * The result of ONE cut, in every container the caller asked for.
14122
+ *
14123
+ * Every artifact in `media` came out of the SAME window of the SAME rendition —
14124
+ * that is the whole reason this is one method rather than one call per format.
14125
+ * A consumer attaching a gif and a video can no longer show two different
14126
+ * moments, because it never chose two sources.
14653
14127
  */
14654
- var CompositionFeatureModeSchema = _enum([
14655
- "new",
14656
- "add",
14657
- "replace"
14658
- ]);
14659
- var CompositionFeaturePlanSchema = object({
14660
- /** Index in the stored feature array; disabled features are omitted rather than re-indexed. */
14661
- featureIndex: number().int().nonnegative(),
14662
- cap: string(),
14663
- mode: CompositionFeatureModeSchema
14128
+ var EventMediaProductionSchema = object({
14129
+ media: array(EventMediaArtifactSchema).readonly(),
14130
+ coverage: EventMediaCoverageSchema,
14131
+ /** The rendition actually cut from — what the default or the fallback chose. */
14132
+ profile: CamProfileSchema,
14133
+ /** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
14134
+ * source, a downscale, or a playback rate other than 1). */
14135
+ video: _enum(["copy", "encode"])
14664
14136
  });
14665
- var CompositionValidationSchema = object({
14666
- ok: boolean(),
14667
- problems: array(CompositionProblemSchema),
14668
- fields: array(CompositionFieldPlanSchema),
14669
- features: array(CompositionFeaturePlanSchema),
14670
- events: array(CompositionEventPlanSchema)
14137
+ var RtspRestreamEntrySchema = object({
14138
+ brokerId: string(),
14139
+ url: string(),
14140
+ mutedUrl: string(),
14141
+ enabled: boolean(),
14142
+ /**
14143
+ * Source-stream codec / resolution for the camStream this entry serves
14144
+ * (the broker's "high"/"mid"/"low" profile slot for this device).
14145
+ * Used by exporter pickers (`pickPreferredRtspEntry`) to resolve
14146
+ * `streamPreference: 'auto'` to the slot whose source is closest to
14147
+ * the consumer's target — Alexa wants ~720p, HomeKit wants ~1080p,
14148
+ * and there's no point dialling the 4K slot for an Echo Show. Absent
14149
+ * when the source publisher never advertised the field; pickers fall
14150
+ * back to first-enabled in that case.
14151
+ */
14152
+ codec: string().optional(),
14153
+ resolution: object({
14154
+ width: number().int().positive(),
14155
+ height: number().int().positive()
14156
+ }).optional()
14671
14157
  });
14672
- /**
14673
- * Mirrors `CoreBlockStatus`, pinned by `core-blocks-source.spec.ts`. `degraded`:
14674
- * running, but a replaced field is released to the native provider while its
14675
- * source is unavailable (ruling S1b, D663) — the reason names each such field.
14676
- */
14677
- var CompositionRunStatusSchema = _enum([
14678
- "stopped",
14679
- "starting",
14680
- "running",
14681
- "degraded",
14682
- "failed"
14683
- ]);
14684
- var CompositionFieldStateKindSchema = _enum([
14685
- "fresh",
14686
- "stale",
14687
- "unavailable"
14688
- ]);
14689
- var CompositionFieldStateSchema = object({
14690
- cap: string(),
14691
- path: string(),
14692
- /** Same as the plan's: two items' `status` are told apart by it. */
14693
- item: CompositionFieldItemSchema.optional(),
14694
- state: CompositionFieldStateKindSchema,
14695
- /** `null` whenever `state === 'unavailable'`; never a plausible stand-in (D393). */
14696
- value: union([
14697
- string(),
14698
- number(),
14699
- boolean(),
14700
- _null()
14701
- ]),
14702
- reason: string().optional()
14158
+ var BrokerRtspClientSchema = object({
14159
+ sessionId: string(),
14160
+ remoteAddr: string(),
14161
+ /** Client `User-Agent` (e.g. `recorder` for the recorder's RTSP pull), or
14162
+ * null/absent when the client sent none. Lets the UI label a consumer by
14163
+ * purpose. Optional so a client built against an older schema stays valid. */
14164
+ userAgent: string().nullish(),
14165
+ playing: boolean(),
14166
+ muted: boolean(),
14167
+ connectedAt: number(),
14168
+ lastRtpAt: number(),
14169
+ bytesSent: number()
14703
14170
  });
14704
- var CompositionBlockStateSchema = object({
14705
- blockId: string(),
14706
- deviceId: number().nullable(),
14707
- status: CompositionRunStatusSchema,
14708
- reason: string().optional(),
14709
- fields: array(CompositionFieldStateSchema)
14171
+ var BrokerAudioClientSchema = object({
14172
+ tag: string(),
14173
+ subscribedAt: number(),
14174
+ chunksDelivered: number()
14710
14175
  });
14711
- var ComposerBlockSchema = object({
14712
- id: string().min(1),
14713
- name: string().min(1).max(120),
14714
- enabled: boolean(),
14715
- updatedAt: number(),
14716
- composition: CompositionSchema
14176
+ var BrokerConsumerAttributionSchema = object({
14177
+ kind: _enum([
14178
+ "alexa",
14179
+ "homekit",
14180
+ "webrtc-browser",
14181
+ "webrtc-mobile",
14182
+ "webrtc-whep",
14183
+ "rtsp-listen",
14184
+ "derived-broker",
14185
+ "recording",
14186
+ "pipeline",
14187
+ "snapshot",
14188
+ "warmup",
14189
+ "unknown"
14190
+ ]),
14191
+ /**
14192
+ * Free-form label intended to disambiguate consumers OF THE SAME kind
14193
+ * on the same broker — e.g. user name, device alias. Should NOT repeat
14194
+ * the kind / cam / cam-stream / sessionId (those are surfaced by
14195
+ * dedicated fields). When empty the widget falls back to `${kind} ·
14196
+ * <sessionId tail>`.
14197
+ */
14198
+ label: string().optional(),
14199
+ /**
14200
+ * Which part of the encoded plane this subscription consumes:
14201
+ * - `'audio'` — audio packets only
14202
+ * - `'video'` — video packets only
14203
+ * - `'both'` — both video + audio (typical for AnnexB push paths
14204
+ * that don't split the source RTP)
14205
+ *
14206
+ * Missing field means "unknown" — older callers and the legacy
14207
+ * paths that haven't been migrated yet.
14208
+ */
14209
+ media: _enum([
14210
+ "audio",
14211
+ "video",
14212
+ "both"
14213
+ ]).optional(),
14214
+ /**
14215
+ * Codec the consumer receives AFTER any per-session re-encoding. For
14216
+ * a WebRTC session this is the negotiated egress codec (e.g. `H264`,
14217
+ * `H265`, `Opus`, `Pcmu`); for an RTSP restreamer it mirrors the
14218
+ * source codec. Surfaced in the widget chip so operators see at a
14219
+ * glance whether a viewer is on H.264 (Echo) or H.265 (Safari).
14220
+ */
14221
+ targetCodec: string().optional(),
14222
+ /**
14223
+ * Whether the broker is re-encoding for this consumer:
14224
+ * - `'passthrough'` — bytes flow source→consumer without ffmpeg
14225
+ * - `'repacketize'` — RTP payload is re-packetized but NOT re-encoded
14226
+ * (preserves frames, just rebuilds headers; e.g.
14227
+ * the H.265 repacketizer path)
14228
+ * - `'transcode'` — a full encode/decode round (ffmpeg, libx264,
14229
+ * libav…); the most expensive path
14230
+ */
14231
+ transport: _enum([
14232
+ "passthrough",
14233
+ "repacketize",
14234
+ "transcode"
14235
+ ]).optional(),
14236
+ /** Remote peer IP if the consumer terminates a network socket. */
14237
+ remoteAddr: string().optional(),
14238
+ /**
14239
+ * Server-read User-Agent of the originating client; enriched by the hub
14240
+ * from the tRPC request context (browser sessions).
14241
+ */
14242
+ userAgent: string().optional(),
14243
+ /** Authenticated user id (CamStack OAuth subject) when known. */
14244
+ userId: string().optional(),
14245
+ /** Higher-level session identifier (Alexa Echo sessionId, HAP session, …). */
14246
+ sessionId: string().optional(),
14247
+ /** Free-form key/value extras (e.g. clientHints from a WebRTC offer). */
14248
+ extra: record(string(), string()).optional()
14249
+ }).readonly();
14250
+ var BrokerEncodedClientSchema = object({
14251
+ /** Stable id assigned on attach; used by `killClient`. */
14252
+ id: string(),
14253
+ /** Which broker fanout the subscriber rides — annexB encoded packets vs raw RTP. */
14254
+ channel: _enum(["annexb", "rtp"]),
14255
+ attribution: BrokerConsumerAttributionSchema,
14256
+ subscribedAt: number(),
14257
+ /** Total packets delivered (annex-B EncodedPackets or RTP byte-buffers). */
14258
+ packetsDelivered: number()
14259
+ });
14260
+ var BrokerClientsSchema = object({
14261
+ rtsp: array(BrokerRtspClientSchema).readonly(),
14262
+ audio: array(BrokerAudioClientSchema).readonly(),
14263
+ encoded: array(BrokerEncodedClientSchema).readonly(),
14264
+ pipeClients: number(),
14265
+ /** Total encoded + raw-RTP callback subscriber count (sum of `encoded[].length`). */
14266
+ encodedSubscribers: number()
14717
14267
  });
14718
14268
  /**
14719
- * A stored composition row that no longer PARSES — a schema a release
14720
- * tightened, a row written by a newer build. Sent by id so the composer keeps
14721
- * its device as a placeholder and reports the block `failed` naming the parse
14722
- * error. Leaving it out would read, twice, exactly like a delete (D49).
14269
+ * How many people are watching one camera LIVE right now — see
14270
+ * `getLiveViewerCounts`. A camera with nobody watching is absent from the list
14271
+ * (so `viewers` is always at least 1): the listing carries the cameras that
14272
+ * have viewers, never a row of zeros.
14723
14273
  */
14724
- var ComposerUnreadableBlockSchema = object({
14725
- id: string().min(1),
14726
- /** The row's stored name column; null when even that is unreadable (the device keeps the name it has). */
14727
- name: string().nullable(),
14728
- /** The parse error, in the operator's words. */
14729
- error: string()
14274
+ var LiveViewerCountSchema = object({
14275
+ deviceId: number().int().nonnegative(),
14276
+ viewers: number().int().positive()
14277
+ });
14278
+ _enum([
14279
+ "reconnecting",
14280
+ "sleeping",
14281
+ "offline",
14282
+ "disabled",
14283
+ "waking"
14284
+ ]);
14285
+ var VideoCodecTargetSchema = _enum([
14286
+ "h264",
14287
+ "h265",
14288
+ "copy"
14289
+ ]);
14290
+ var AudioCodecTargetSchema = _enum([
14291
+ "aac",
14292
+ "opus",
14293
+ "pcmu",
14294
+ "copy",
14295
+ "none"
14296
+ ]);
14297
+ var GetStreamWithCodecInputSchema = object({
14298
+ deviceId: number().int().nonnegative(),
14299
+ /** Target codec. `'copy'` = passthrough (no re-encode). */
14300
+ video: VideoCodecTargetSchema,
14301
+ /** Target audio codec. `'copy'` = passthrough. Defaults to `'aac'`. */
14302
+ audio: AudioCodecTargetSchema.optional(),
14303
+ /** Target a profile's assigned source camStream. Required to resolve a source. */
14304
+ profile: CamProfileSchema.optional(),
14305
+ /** Optional output resolution target. When set: drives the transcode
14306
+ * output scale (downscale toward WxH), and — when `profile` is absent —
14307
+ * selects the published source closest to this resolution. Ignored for
14308
+ * `video:'copy'` scaling (a copy can't be rescaled), but still used for
14309
+ * source selection. */
14310
+ targetResolution: object({
14311
+ width: number().int().positive(),
14312
+ height: number().int().positive()
14313
+ }).optional(),
14314
+ /** Extra ffmpeg output flags appended verbatim by the consumer (in code).
14315
+ * Folded into the pipeline key so different args never share a child. */
14316
+ outputArgs: array(string()).optional(),
14317
+ /** Opt-in: carry transcoded audio over an RTP sidecar grafted into the
14318
+ * restreamer SDP (only for re-encoded audio targets — aac/opus/pcmu).
14319
+ * Default/absent = the live-verified video-only egress. */
14320
+ audioEgress: boolean().optional(),
14321
+ /** Host to embed in the returned restream `url`. The broker is hub-only, so
14322
+ * its restream URLs default to `127.0.0.1` (consumable only from the hub).
14323
+ * A REMOTE consumer (e.g. a recorder running on an agent designated as the
14324
+ * recording node) passes the hub's cluster-resolvable host here so the
14325
+ * returned `rtsp://<host>:<port>/<token>` is dialable from that node. The
14326
+ * port/token (and the underlying restreamer, bound on 0.0.0.0) are
14327
+ * unchanged — `hostname` only rewrites the client-facing dial address.
14328
+ * Absent/empty = `127.0.0.1` (the hub-local default). */
14329
+ hostname: string().optional(),
14330
+ tag: string().optional()
14331
+ });
14332
+ var RtpSourceSchema = object({
14333
+ url: string(),
14334
+ /**
14335
+ * The SENTRY counterpart of {@link url} — the dial that asks for the stream
14336
+ * WITHOUT asking for the camera — or `null` when this camera offers none.
14337
+ *
14338
+ * The broker answers it because the broker is the only thing that can:
14339
+ * eligibility is `sentryEligible` (a BATTERY camera that DECLARES the signals
14340
+ * cap), asked of facts only the broker holds. A consumer re-deriving it from
14341
+ * cap bindings would be a second authority, and two authorities on the same
14342
+ * question is how a composite camera ends up dialling a sleeping camera
14343
+ * because its own copy of the rule was a release behind.
14344
+ *
14345
+ * `null` is a real answer and is never a live url in disguise: a consumer
14346
+ * that silently fell back to {@link url} would perform exactly the wake this
14347
+ * field exists to prevent. Only a PASSTHROUGH acquisition can offer one — a
14348
+ * transcode hands back an egress the broker is feeding itself, which has no
14349
+ * sentry counterpart.
14350
+ */
14351
+ sentryUrl: string().nullable().optional(),
14352
+ videoCodec: _enum(["H264", "H265"]),
14353
+ audioCodec: string(),
14354
+ resolution: object({
14355
+ width: number().int().positive(),
14356
+ height: number().int().positive()
14357
+ }),
14358
+ transcoded: boolean(),
14359
+ encoder: string(),
14360
+ pipelineKey: string()
14361
+ });
14362
+ /**
14363
+ * The encode request — **structured and serialisable, with NO raw-flag escape
14364
+ * hatch.** This is deliberate and it is the one lesson taken from
14365
+ * `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
14366
+ * its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
14367
+ * adding a flag silently forks the shared child, and two consumers that mean
14368
+ * the same thing but spell it differently never share. Here every knob is a
14369
+ * NAMED field: a new requirement becomes a schema field (and a codegen run),
14370
+ * never an opaque array.
14371
+ *
14372
+ * `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
14373
+ * The operator-facing derived-stream transform editor still has them — that is
14374
+ * a different surface (`publishCameraStream({ kind: 'derived' })`) with a
14375
+ * different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
14376
+ */
14377
+ var EgressEncodeSchema = EncodeProfileSchema.omit({
14378
+ inputArgs: true,
14379
+ outputArgs: true
14380
+ });
14381
+ /**
14382
+ * How the encoder is bounded. `'tight'` is a one-second VBV window for a
14383
+ * consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
14384
+ * seconds, letting a keyframe spike borrow from the next second (a browser,
14385
+ * an Echo). Named rather than numeric so the INTENT survives.
14386
+ */
14387
+ var EgressRateControlSchema = _enum(["tight", "relaxed"]);
14388
+ var EgressTranscodeRequestSchema = object({
14389
+ deviceId: number().int().nonnegative(),
14390
+ /** Which published stream to read. */
14391
+ source: discriminatedUnion("kind", [object({
14392
+ kind: literal("profile"),
14393
+ profile: CamProfileSchema
14394
+ }), object({
14395
+ kind: literal("cam-stream"),
14396
+ camStreamId: string().min(1)
14397
+ })]),
14398
+ encode: EgressEncodeSchema,
14399
+ rateControl: EgressRateControlSchema.optional(),
14400
+ /**
14401
+ * `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
14402
+ * out-of-band extradata and needs `dump_extra` on both the copy and encode
14403
+ * branches. Enumerated, not free text.
14404
+ */
14405
+ bitstreamFilter: _enum([
14406
+ "dump_extra",
14407
+ "h264_mp4toannexb",
14408
+ "hevc_mp4toannexb"
14409
+ ]).optional(),
14410
+ /**
14411
+ * Publish the transcode as a LOCAL push cam stream, instead of leaving the
14412
+ * consumer to dial the returned url. The broker picks the id and returns it
14413
+ * as `camStreamId` — a caller-supplied one would be circular, since the
14414
+ * sharing key is computed FROM this request.
14415
+ *
14416
+ * The url is still returned and still the contract for a transcode pinned to
14417
+ * another node. But dialling it locally costs an RTSP round trip that changes
14418
+ * the transport underneath the consumer: a dialled stream is an RTP source,
14419
+ * so `isRtpSource()` is true and the session takes the RTP-passthrough +
14420
+ * repacketizer branch. The push branch — the one the derived mechanism has
14421
+ * live hours on — is never reached. Measured on Alexa: broker registered, RTP
14422
+ * arriving, key frame arriving, black screen, on a chain healthy at every
14423
+ * other point.
14424
+ *
14425
+ * Same idea the transport already applies to CALLS, where `classifyCapRoute`
14426
+ * gives priority to `hub-in-process` so a local call never leaves the node.
14427
+ * This is that rule for media.
14428
+ */
14429
+ publishLocally: boolean().optional(),
14430
+ pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
14431
+ /**
14432
+ * Operator/consumer override for decode hardware. ABSENT is the normal case
14433
+ * and the one that matters: the broker then resolves the backend from the
14434
+ * DECODER ADDON's per-node `probedBestHwaccel` (see
14435
+ * `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
14436
+ * on this hardware — never the raw kernel resolver's qsv-first order.
14437
+ */
14438
+ decodeHwAccel: _enum([
14439
+ "auto",
14440
+ "none",
14441
+ "videotoolbox",
14442
+ "vaapi",
14443
+ "qsv",
14444
+ "cuda"
14445
+ ]).optional(),
14446
+ /**
14447
+ * Host to embed in the returned restream `url`. The broker mints hub-local
14448
+ * `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
14449
+ * host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
14450
+ * dialable from there. Same contract as `getStreamWithCodec.hostname` —
14451
+ * `substituteRtspHost` rewrites only the dial address, never the restreamer.
14452
+ */
14453
+ hostname: string().optional(),
14454
+ /** Attribution for the broker panel. Never part of the sharing key. */
14455
+ tag: string().optional()
14456
+ });
14457
+ var EgressTranscodeSchema = object({
14458
+ /** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
14459
+ url: string(),
14460
+ /** Release handle. Refcounted — the child dies when the last holder releases. */
14461
+ pipelineKey: string(),
14462
+ videoCodec: _enum(["H264", "H265"]),
14463
+ resolution: object({
14464
+ width: number().int().positive(),
14465
+ height: number().int().positive()
14466
+ }),
14467
+ transcoded: boolean(),
14468
+ encoder: string(),
14469
+ /**
14470
+ * The decode backend the child ACTUALLY ran with — `null` for software.
14471
+ * Returned rather than assumed: a consumer that asked for hardware and got
14472
+ * software needs to be able to see that without reading the broker's logs.
14473
+ */
14474
+ decodeHwAccel: string().nullable(),
14475
+ /**
14476
+ * Set when `publishLocally` was honoured: attach to THIS instead of dialling
14477
+ * `url`, and the session takes the push/deframe transport rather than the
14478
+ * RTP-passthrough one. `null` means the consumer must dial.
14479
+ */
14480
+ camStreamId: string().nullable()
14730
14481
  });
14731
14482
  method(object({
14732
- /** The shared Blocks integration; null until `core-blocks` has reconciled it. */
14733
- integrationId: string().nullable(),
14734
- /** False when the `core_blocks` read failed: then nothing may be withdrawn (D49). */
14735
- storeLoaded: boolean(),
14736
- blocks: array(ComposerBlockSchema),
14737
- /** Operator restarts: rebuild these runtimes and re-resolve their sources. Stores nothing (D62). */
14738
- restartBlockIds: array(string()),
14739
- /** Composition rows that exist but do not parse: held, never withdrawn, reported failed. */
14740
- unreadableBlocks: array(ComposerUnreadableBlockSchema)
14483
+ deviceId: number().int().nonnegative(),
14484
+ /** The provider's signal code. */
14485
+ code: string().min(1),
14486
+ /** Ms epoch. Absent ⇒ now. */
14487
+ at: number().optional()
14741
14488
  }), object({
14742
- blocks: array(object({
14743
- blockId: string(),
14744
- status: CompositionRunStatusSchema,
14745
- reason: string().optional()
14746
- })),
14489
+ /** Whether the broker acted on it, and if not, why. */
14490
+ accepted: boolean(),
14491
+ reason: _enum([
14492
+ "woke",
14493
+ "hold-extended",
14494
+ "not-enabled",
14495
+ "no-consumer",
14496
+ "unknown-code"
14497
+ ])
14498
+ }), { kind: "mutation" }), method(object({
14499
+ deviceId: number().int().nonnegative(),
14500
+ camStreamId: string().min(1),
14501
+ kind: CamStreamKindSchema,
14502
+ url: string().optional(),
14503
+ codec: string().optional(),
14504
+ resolution: CamStreamResolutionSchema.optional(),
14505
+ fps: number().positive().optional(),
14506
+ label: string().optional(),
14507
+ deviceFeatures: array(string()).optional(),
14508
+ autoEligible: boolean().optional(),
14509
+ /** The slot the publisher says this stream IS — see `CameraStreamSchema.profileHint`. */
14510
+ profileHint: CamProfileSchema.optional(),
14511
+ metadata: record(string(), unknown()).optional(),
14512
+ /** Required when kind === 'derived' — the source camStreamId the
14513
+ * derived broker reads its encoded plane from.
14514
+ * The runtime guard (broker manager, Task 10) enforces presence for
14515
+ * kind === 'derived'; the schema stays permissive so ts-morph emits
14516
+ * full field types instead of collapsing to ZodEffects. */
14517
+ sourceCamStreamId: string().min(1).optional(),
14518
+ /** Required when kind === 'derived' — the ffmpeg params for the
14519
+ * spawn. Reuses the same brokerId for identical params (single-flight).
14520
+ * Runtime guard in broker manager; schema is permissive (see above). */
14521
+ encodeProfile: EncodeProfileSchema.optional()
14522
+ }), object({ success: literal(true) }), {
14523
+ kind: "mutation",
14524
+ auth: "admin"
14525
+ }), method(object({
14526
+ deviceId: number().int().nonnegative(),
14527
+ camStreamId: string().min(1)
14528
+ }), object({ success: literal(true) }), {
14529
+ kind: "mutation",
14530
+ auth: "admin"
14531
+ }), method(object({
14532
+ deviceId: number().int().nonnegative(),
14533
+ profile: CamProfileSchema,
14534
+ camStreamId: string().min(1)
14535
+ }), object({ success: literal(true) }), {
14536
+ kind: "mutation",
14537
+ auth: "admin"
14538
+ }), method(object({
14539
+ deviceId: number().int().nonnegative(),
14540
+ profile: CamProfileSchema
14541
+ }), object({ success: literal(true) }), {
14542
+ kind: "mutation",
14543
+ auth: "admin"
14544
+ }), method(object({ deviceId: number().int().nonnegative() }), object({
14545
+ derivedStreamsDeleted: array(string()).readonly(),
14546
+ assignmentsPurged: boolean(),
14547
+ probeSnapshotsDropped: number().int().nonnegative(),
14548
+ rtspTokenRowsDeleted: number().int().nonnegative()
14549
+ }), {
14550
+ kind: "mutation",
14551
+ auth: "admin"
14552
+ }), method(object({
14553
+ deviceId: number(),
14554
+ /** Absent = the LOWEST assigned profile — a notification attachment is
14555
+ * watched on a phone, and the cheap rendition is the right default. */
14556
+ profile: CamProfileSchema.optional(),
14557
+ aroundMs: number(),
14558
+ preRollSec: number().min(0).max(20).default(3),
14559
+ postRollSec: number().min(0).max(20).default(5),
14560
+ format: _enum(["gif", "mp4"]).default("gif"),
14561
+ maxWidth: number().int().min(120).max(1920).default(480),
14562
+ /** GIF only — MP4 keeps the source cadence. */
14563
+ fps: number().int().min(1).max(15).default(5),
14564
+ /**
14565
+ * Playback rate. A notification clip is GLANCED at on a lock screen,
14566
+ * not watched, so 2x is the default: the recipient sees the whole
14567
+ * passage in half the time and the GIF is half the bytes. `1` is real
14568
+ * time. Applies to MP4 as well — the operator set a speed, not a GIF
14569
+ * speed.
14570
+ */
14571
+ speed: number().min(1).max(8).default(2)
14572
+ }), object({
14573
+ base64: string(),
14574
+ mime: string(),
14575
+ bytes: number().int(),
14576
+ /** The profile actually rendered (what the default resolved to). */
14577
+ profile: CamProfileSchema,
14578
+ durationMs: number()
14579
+ }), {
14580
+ kind: "mutation",
14581
+ auth: "admin"
14582
+ }), method(object({
14583
+ deviceId: number(),
14584
+ /** Absent = the largest H.264 rendition at or below 1080p, which is
14585
+ * also the one that can be copied. Falls back to whatever the ring
14586
+ * actually retained, and the answer says which. */
14587
+ profile: CamProfileSchema.optional(),
14588
+ aroundMs: number(),
14589
+ preSeconds: number().min(0).max(20).default(4),
14590
+ postSeconds: number().min(0).max(20).default(6),
14591
+ kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
14592
+ /** GIF geometry. The video keeps the source's own. */
14593
+ gifMaxWidth: number().int().min(120).max(1280).default(640),
14594
+ /**
14595
+ * The gif's own PLAYBACK rate in frames per second — what the finished
14596
+ * gif runs at, not how many source frames feed it. The decimation that
14597
+ * feeds it samples `gifFps / gifSpeed` source frames per second, so at
14598
+ * the defaults a 12 fps gif is built out of 3 source frames a second.
14599
+ */
14600
+ gifFps: number().int().min(1).max(15).default(12),
14601
+ /**
14602
+ * How fast the GIF plays against real time, independent of `speed`.
14603
+ *
14604
+ * 4× by default, by operator request: a notification gif is glanced at
14605
+ * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
14606
+ * a separate knob from `speed` even though both now default to 4 —
14607
+ * a caller wanting a real-time video and a fast gif must not have to
14608
+ * choose.
14609
+ */
14610
+ gifSpeed: number().min(1).max(8).default(4),
14611
+ /**
14612
+ * Playback rate of the VIDEO. Also 4× by default, by operator decision.
14613
+ *
14614
+ * `1` is real time and is the ONLY value that allows the copy branch —
14615
+ * anything else forces `libx264` over the window. That was priced
14616
+ * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
14617
+ * on a real 615 720p cut, against 922 KB for the copy it replaces. A
14618
+ * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
14619
+ * once the decode is forced the width stops being free.
14620
+ */
14621
+ speed: number().min(1).max(8).default(4)
14622
+ }), EventMediaProductionSchema, {
14623
+ kind: "mutation",
14624
+ auth: "admin"
14625
+ }), method(object({ handle: string() }), object({
14626
+ base64: string(),
14627
+ mime: string(),
14628
+ bytes: number().int()
14629
+ }).nullable(), {
14630
+ kind: "mutation",
14631
+ auth: "admin"
14632
+ }), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
14633
+ probed: boolean(),
14634
+ summary: string()
14635
+ }), {
14636
+ kind: "mutation",
14637
+ auth: "admin"
14638
+ }), method(object({ brokerId: string() }), BrokerClientsSchema), method(_void(), array(LiveViewerCountSchema).readonly()), method(object({
14639
+ brokerId: string(),
14640
+ channel: _enum(["rtsp", "audio"]),
14641
+ handle: string()
14642
+ }), object({ killed: boolean() }), {
14643
+ kind: "mutation",
14644
+ auth: "admin"
14645
+ }), method(object({
14646
+ deviceId: number().int().nonnegative(),
14647
+ profile: CamProfileSchema
14648
+ }), object({ success: boolean() }), {
14649
+ kind: "mutation",
14650
+ auth: "admin"
14651
+ }), method(object({
14652
+ streamId: string(),
14653
+ format: StreamFormatSchema
14654
+ }), object({ url: string() })), method(GetStreamWithCodecInputSchema, RtpSourceSchema, {
14655
+ kind: "mutation",
14656
+ auth: "admin"
14657
+ }), method(object({ pipelineKey: string() }), object({
14658
+ released: boolean(),
14659
+ refcount: number().int().nonnegative()
14660
+ }), {
14661
+ kind: "mutation",
14662
+ auth: "admin"
14663
+ }), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
14664
+ kind: "mutation",
14665
+ auth: "admin"
14666
+ }), method(object({ pipelineKey: string() }), object({
14667
+ released: boolean(),
14668
+ refcount: number().int().nonnegative()
14669
+ }), {
14670
+ kind: "mutation",
14671
+ auth: "admin"
14672
+ }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
14673
+ subscriptionId: string(),
14674
+ maxCount: number().int().positive().default(8)
14675
+ }), array(DecodedAudioChunkSchema).readonly()), method(object({ subscriptionId: string() }), object({ released: boolean() }), { kind: "mutation" }), method(object({
14676
+ brokerId: string(),
14677
+ seconds: number().min(0).max(30)
14678
+ }), _void(), {
14679
+ kind: "mutation",
14680
+ auth: "admin"
14681
+ }), method(object({ brokerId: string() }), object({
14682
+ configuredSec: number(),
14683
+ bufferedMs: number(),
14684
+ packetCount: number()
14685
+ })), method(_void(), number()), method(object({ hostname: string().optional() }), array(RtspRestreamEntrySchema).readonly()), method(object({
14686
+ brokerId: string(),
14687
+ hostname: string().optional()
14688
+ }), RtspRestreamEntrySchema.nullable()), method(object({ brokerId: string() }), string().nullable(), {
14689
+ kind: "mutation",
14690
+ auth: "admin"
14691
+ }), method(object({
14692
+ brokerId: string(),
14693
+ enabled: boolean()
14694
+ }), _void(), {
14695
+ kind: "mutation",
14696
+ auth: "admin"
14697
+ }), method(object({ brokerId: string() }), boolean()), method(object({ deviceId: number().int() }), object({
14698
+ muted: boolean(),
14699
+ /**
14700
+ * How many live non-derived brokers currently hold the mute. Purely
14701
+ * diagnostic: `muted` is the policy and is authoritative on its own
14702
+ * (it applies to brokers that do not exist yet), while this says
14703
+ * whether anything is presently being silenced.
14704
+ */
14705
+ appliedBrokers: number().int().nonnegative()
14706
+ })), method(object({
14707
+ deviceId: number().int(),
14708
+ muted: boolean()
14709
+ }), object({
14710
+ muted: boolean(),
14711
+ appliedBrokers: number().int().nonnegative()
14712
+ }), {
14713
+ kind: "mutation",
14714
+ auth: "admin"
14715
+ }), object({
14716
+ deviceId: number().int().nonnegative(),
14717
+ camStreamId: string(),
14718
+ profile: CamProfileSchema
14719
+ }), object({
14720
+ deviceId: number().int().nonnegative(),
14721
+ camStreamId: string()
14722
+ }), object({
14723
+ deviceId: number().int().nonnegative(),
14724
+ camStreamId: string(),
14725
+ brokerId: string()
14726
+ });
14727
+ var StreamCodecSchema = _enum([
14728
+ "h264",
14729
+ "h265",
14730
+ "hevc",
14731
+ "av1",
14732
+ "mjpeg",
14733
+ "vp8",
14734
+ "vp9"
14735
+ ]);
14736
+ var PickStreamRequirementsSchema = object({
14737
+ /**
14738
+ * Codecs the consumer can decode without an intermediate transcode.
14739
+ * Match is case-insensitive against the camera stream's `codec`.
14740
+ * Default (omitted / empty array): no codec filter — useful when the
14741
+ * consumer just wants "the highest-quality stream of any codec".
14742
+ */
14743
+ acceptCodecs: array(StreamCodecSchema).readonly().optional(),
14744
+ /** Minimum vertical resolution. Streams shorter than this are dropped. */
14745
+ minHeight: number().int().positive().optional(),
14746
+ /**
14747
+ * Maximum vertical resolution. Streams TALLER than this are dropped.
14748
+ *
14749
+ * A consumer can have a ceiling as real as its floor: Alexa documents
14750
+ * 480p to 1080p, and a 4K stream is as unusable to an Echo as a 360p one.
14751
+ * Without this the ceiling had to be re-implemented by every caller — and
14752
+ * one caller implementing it privately is how a second scoring authority
14753
+ * gets born.
14754
+ */
14755
+ maxHeight: number().int().positive().optional(),
14756
+ /** Minimum horizontal resolution. */
14757
+ minWidth: number().int().positive().optional(),
14758
+ /**
14759
+ * When true (default), `derived:*` streams (transcoded broker pipes)
14760
+ * are excluded — the caller wants a SOURCE stream the broker can
14761
+ * forward without a re-encode. Set false to allow derived in the
14762
+ * picker (useful for a "best playable" fallback).
14763
+ */
14764
+ excludeDerived: boolean().optional(),
14765
+ /**
14766
+ * Bypass gate. When set, the picker returns a hit ONLY IF the device
14767
+ * also exposes a non-derived stream in one of these (rejected) codecs.
14768
+ * Mirrors the Alexa "bypass only when the natural path WOULD have
14769
+ * transcoded" guard: if the device is already serving the consumer's
14770
+ * codec end-to-end, there's nothing to optimise.
14771
+ */
14772
+ requireSiblingCodec: array(StreamCodecSchema).readonly().optional(),
14773
+ /**
14774
+ * Whether a stream the consumer CANNOT decode may still be picked, on the
14775
+ * understanding that it will be transcoded.
14776
+ *
14777
+ * Default `false` — today's behaviour, and the right one for a bypass
14778
+ * question ("is there a stream I can forward untouched?"). Set `true` to
14779
+ * ask the larger question: "what is the best stream for me, transcoding if
14780
+ * I must?" A stream that satisfies `acceptCodecs` always outranks one that
14781
+ * does not, so a passthrough is never lost to a transcode; the answer says
14782
+ * which it is in {@link PickedCamStreamSchema.transcodes}.
14783
+ *
14784
+ * This is what lets one picker serve both the bypass and the full source
14785
+ * choice, instead of a consumer scoring privately when the bypass misses.
14786
+ */
14787
+ allowTranscode: boolean().optional(),
14788
+ /**
14789
+ * Consider ONLY streams the broker has bound to a profile slot.
14790
+ *
14791
+ * A consumer that dials the broker — which is every exporter, since none
14792
+ * of them may open the camera's own URL — can do nothing with a source
14793
+ * that has no slot: there is no restream to dial. Without this the picker
14794
+ * can name such a stream, and each consumer discovers the dead end for
14795
+ * itself: Alexa's `profileForCamStream` returns `null` and it quietly
14796
+ * falls back, and HomeKit would have had to grow the same function again.
14797
+ *
14798
+ * Default `false`, and deliberately so: the BROKER itself picks through
14799
+ * here when it decides which source to BIND to a slot
14800
+ * (`computeInitialAssignment`), and it must see the unbound ones or it
14801
+ * could never bind them.
14802
+ */
14803
+ requireProfileBound: boolean().optional()
14804
+ }).readonly();
14805
+ var PickStreamPreferencesSchema = object({
14747
14806
  /**
14748
- * False when the composer declared and withdrew NOTHING (store not loaded,
14749
- * integration unknown, its own rows unread or read empty once). An empty
14750
- * apply the caller owes is paid off only by one the composer acted on (D49).
14807
+ * Ordered list of provider prefixes (e.g. `['native:', 'rtsp:']`) the
14808
+ * picker prefers — earlier entries win ties. Defaults to
14809
+ * `['native:']`, mirroring Alexa's "prefer the native dial path".
14751
14810
  */
14752
- acted: boolean()
14753
- }), {
14754
- kind: "mutation",
14755
- auth: "admin"
14756
- }), method(object({ blockIds: array(string()) }), object({ states: array(CompositionBlockStateSchema) }), { auth: "admin" });
14757
- var NUMBER = {
14758
- kind: "fixed",
14759
- result: ["number"]
14760
- };
14761
- var BOOLEAN = {
14762
- kind: "fixed",
14763
- result: ["boolean"]
14764
- };
14765
- Object.freeze({
14766
- min: NUMBER,
14767
- max: NUMBER,
14768
- abs: NUMBER,
14769
- floor: NUMBER,
14770
- ceil: NUMBER,
14771
- sqrt: NUMBER,
14772
- round: NUMBER,
14773
- pow: NUMBER,
14774
- clamp: NUMBER,
14775
- avg: NUMBER,
14776
- sum: NUMBER,
14777
- age: NUMBER,
14778
- convert: NUMBER,
14779
- coalesce: { kind: "coalesce" },
14780
- any: BOOLEAN,
14781
- all: BOOLEAN,
14782
- within: BOOLEAN,
14783
- latest: {
14784
- kind: "fixed",
14785
- result: ["number", "null"]
14786
- },
14787
- number: {
14788
- kind: "fixed",
14789
- result: ["number", "null"]
14790
- },
14791
- rose: {
14792
- kind: "fixed",
14793
- result: ["number", "null"]
14794
- },
14795
- count: NUMBER
14796
- });
14797
- `${COMPOSED_DEVICE_STABLE_ID_PREFIX}`;
14798
- /**
14799
- * What a composition may BUILD on a device type (G2, D676): the caps a new
14800
- * device of that type, or an existing one that lacks them, can be given. The
14801
- * blockers are the validator's own refusal codes, and a spec compares this
14802
- * listing with `planComposition` for every capability — the editor can never
14803
- * offer a feature the save would refuse for the same reason.
14804
- */
14805
- var ComposableFieldSchema = object({
14806
- path: string(),
14807
- kind: CompositionFieldKindSchema,
14808
- nullable: boolean(),
14809
- required: boolean(),
14810
- enumValues: array(string()).optional()
14811
- });
14812
- var ComposableCapabilitiesSchema = object({ caps: array(object({
14813
- cap: string(),
14814
- fields: array(ComposableFieldSchema),
14815
- /** Present for an item-array cap (`consumables.items`). */
14816
- itemArray: object({
14817
- path: string(),
14818
- keyField: string()
14819
- }).optional()
14820
- })) });
14821
- /** Where a block runs. The operator chooses — a block driving a device on an
14822
- * agent is the reason placement is not fixed to the hub. */
14823
- var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
14824
- /** What a block's process is doing. Mirrors the addon runner's own lifecycle so
14825
- * a failing block reads the same way a failing addon does. `degraded` is a
14826
- * composition on an existing device with a field handed back to the native
14827
- * provider while its source is unavailable (S1b, D663); the error names it. */
14828
- var CoreBlockStatusSchema = _enum([
14829
- "stopped",
14830
- "starting",
14831
- "running",
14832
- "degraded",
14833
- "failed"
14834
- ]);
14835
- /** path → TypeScript source for a multi-file block. */
14836
- var CoreBlockFilesSchema = record(string().min(1).max(260), string().max(2e5));
14837
- /**
14838
- * A block's settings form — the same ConfigUISchema addons declare.
14839
- * Loose on the wire (field variants are polymorphic); typed for callers.
14840
- */
14841
- var CoreBlockSettingsSchemaBridge = custom();
14842
- /**
14843
- * Client-authored fields (plain object). Kept separate from the refined create
14844
- * schema because Zod 4 refuses `.partial()` on schemas that carry refinements —
14845
- * `update` needs a partial of these fields.
14846
- */
14847
- var CoreBlockInputFieldsSchema = object({
14848
- name: string().min(1).max(120),
14811
+ preferredProviders: array(string()).readonly().optional(),
14849
14812
  /**
14850
- * Single-file TypeScript source. Kept for backward compatibility: when
14851
- * `files` is absent, this IS `files[entry]`. On write, if `files` is
14852
- * provided, the server derives `code` from `files[entry]` so compile and
14853
- * runtime paths that still read `code` keep working.
14813
+ * Resolution preference within the surviving candidates. `'highest'`
14814
+ * picks the tallest stream; `'lowest'` picks the shortest (used by
14815
+ * memory-constrained consumers / Apple Home guest sessions).
14854
14816
  */
14855
- code: string().max(2e5).optional(),
14856
- /** Multi-file project (path → source). Absent = legacy single-file `code`. */
14857
- files: CoreBlockFilesSchema.optional(),
14858
- /** Entry file inside `files`. Default {@link DEFAULT_CORE_BLOCK_ENTRY}. */
14859
- entry: string().min(1).max(260).optional(),
14860
- enabled: boolean(),
14861
- placement: CoreBlockPlacementSchema,
14817
+ resolutionPreference: _enum(["highest", "lowest"]).optional(),
14862
14818
  /**
14863
- * Integration the block's devices hang from. Absent = the system integration
14864
- * blocks share. A block may declare its own instead.
14819
+ * The height the consumer actually wants to DELIVER.
14820
+ *
14821
+ * Not a constraint — an ordering. With it set, the SMALLEST stream at or
14822
+ * above the target wins, and only if nothing reaches it does the tallest
14823
+ * take over. Pulling 1296 lines to draw 720 on a 1280x800 Echo panel costs
14824
+ * a decode and buys nothing, and `resolutionPreference: 'highest'` cannot
14825
+ * express that: it says "as big as possible", which is a different wish.
14826
+ *
14827
+ * Ignored when absent, so every existing caller keeps its ordering.
14865
14828
  */
14866
- integrationId: string().optional(),
14829
+ targetHeight: number().int().positive().optional()
14830
+ }).readonly();
14831
+ var PickedCamStreamSchema = object({
14832
+ camStreamId: string(),
14833
+ codec: string().optional(),
14834
+ resolution: CamStreamResolutionSchema.optional(),
14867
14835
  /**
14868
- * Cached settings form for this block. Prefer extracting `settingsSchema`
14869
- * from the compiled entry after a successful load; the editor may also set
14870
- * it. Absent ⇒ `getBlockSettings` returns null until one is known.
14836
+ * Whether serving this stream requires a decode + re-encode.
14837
+ *
14838
+ * `false` is a stream the consumer can take as it stands. Only ever `true`
14839
+ * when the caller asked for it with `allowTranscode`, so a caller that did
14840
+ * not ask cannot be handed a cost it never agreed to pay.
14871
14841
  */
14872
- settingsSchema: CoreBlockSettingsSchemaBridge.optional()
14842
+ transcodes: boolean(),
14843
+ /** One-line explanation of why this stream won — for logs / debug UI. */
14844
+ reason: string(),
14845
+ /**
14846
+ * The broker profile slot this source is bound to, when it is bound.
14847
+ *
14848
+ * Answered HERE because every consumer needs it and none of them can get it
14849
+ * from the `camStreamId` alone. Alexa wrote `profileForCamStream` for it —
14850
+ * a second cap read plus a `null` branch — and HomeKit was about to write
14851
+ * the same function a second time.
14852
+ *
14853
+ * Absent when the source has no slot, which a caller passing
14854
+ * `requireProfileBound` can never see.
14855
+ */
14856
+ profile: CamProfileSchema.optional(),
14857
+ /** The broker id of that slot, for logs that must name what was dialled. */
14858
+ brokerId: string().optional(),
14859
+ /**
14860
+ * The broker restream URL to dial for this stream.
14861
+ *
14862
+ * An exporter must never open the camera's own URL — the broker is the one
14863
+ * that dials the camera, and every consumer goes through its restream. So
14864
+ * the picker answers with the thing a consumer may actually use, instead of
14865
+ * naming a source and leaving each caller to look the URL up.
14866
+ */
14867
+ url: string().optional(),
14868
+ /**
14869
+ * Frames per second this stream really delivers, and where the number came
14870
+ * from.
14871
+ *
14872
+ * It is here because getting it wrong is expensive and silent: a HomeKit
14873
+ * accessory advertised 720p at 24 fps while the slot serving it ran at 9,
14874
+ * and the controller rendered nothing at all. The source is carried with the
14875
+ * value for the same reason the bitrate evidence is: `published` is what the
14876
+ * publisher declares and `measured` is what the broker observed, and they
14877
+ * are not interchangeable.
14878
+ */
14879
+ fps: number().positive().optional(),
14880
+ fpsSource: _enum(["published", "measured"]).optional()
14873
14881
  });
14874
- /** Client-authored fields for create — requires `code` or `files[entry]`. */
14875
- var CoreBlockInputSchema = CoreBlockInputFieldsSchema.superRefine((val, ctx) => {
14876
- const entry = val.entry ?? "main.ts";
14877
- if (val.files !== void 0) {
14878
- if (val.files[entry] === void 0) ctx.addIssue({
14879
- code: "custom",
14880
- message: `files must include the entry "${entry}"`,
14881
- path: ["files"]
14882
- });
14883
- return;
14884
- }
14885
- if (val.code === void 0) ctx.addIssue({
14886
- code: "custom",
14887
- message: "code or files is required",
14888
- path: ["code"]
14889
- });
14882
+ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(CameraStreamSchema).readonly()), method(object({ deviceId: number().int().nonnegative() }), array(ProfileSlotSchema).readonly()), method(object({
14883
+ deviceId: number().int().nonnegative(),
14884
+ /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
14885
+ hostname: string().optional()
14886
+ }), array(RtspRestreamEntrySchema).readonly()), method(object({
14887
+ deviceId: number().int().nonnegative(),
14888
+ /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
14889
+ hostname: string().optional()
14890
+ }), array(ProfileRtspEntrySchema).readonly()), method(object({
14891
+ deviceId: number().int().nonnegative(),
14892
+ requirements: PickStreamRequirementsSchema,
14893
+ preferences: PickStreamPreferencesSchema.optional()
14894
+ }), PickedCamStreamSchema.nullable()), object({
14895
+ deviceId: number().int().nonnegative(),
14896
+ camStreams: array(CameraStreamSchema).readonly()
14897
+ }), object({
14898
+ deviceId: number().int().nonnegative(),
14899
+ profileSlots: array(ProfileSlotSchema).readonly()
14900
+ }), object({
14901
+ online: boolean(),
14902
+ slotStatuses: object({
14903
+ high: ProfileSlotStatusSchema.optional(),
14904
+ mid: ProfileSlotStatusSchema.optional(),
14905
+ low: ProfileSlotStatusSchema.optional()
14906
+ }),
14907
+ slotErrors: object({
14908
+ high: string().optional(),
14909
+ mid: string().optional(),
14910
+ low: string().optional()
14911
+ }),
14912
+ lastChangedAt: number()
14913
+ });
14914
+ EventCategory.DoorbellOnPressed;
14915
+ var CompositionEventPlanSchema = object({
14916
+ featureIndex: number().int().nonnegative(),
14917
+ cap: string(),
14918
+ eventName: string(),
14919
+ category: _enum(EventCategory),
14920
+ stateField: string(),
14921
+ fieldKey: string()
14922
+ });
14923
+ /** One refusal, named (D391). `path` points into the composition, e.g. `features[0].fields.celsius`. */
14924
+ var CompositionProblemSchema = object({
14925
+ code: _enum([
14926
+ "reserved-variant",
14927
+ "unknown-capability",
14928
+ "not-composable",
14929
+ "wrong-device-type",
14930
+ "has-commands",
14931
+ "unknown-field",
14932
+ "nested-field",
14933
+ "missing-field",
14934
+ "kind-mismatch",
14935
+ "may-be-null",
14936
+ "source-missing",
14937
+ "source-unreadable",
14938
+ "source-capability-missing",
14939
+ "source-field-unknown",
14940
+ "expression-invalid",
14941
+ "cycle",
14942
+ "duplicate-target",
14943
+ "not-item-array",
14944
+ "item-field-reserved",
14945
+ "target-missing",
14946
+ "target-unreadable",
14947
+ "not-replaceable"
14948
+ ]),
14949
+ path: string(),
14950
+ message: string()
14890
14951
  });
14952
+ var CompositionFieldKindSchema = _enum([
14953
+ "string",
14954
+ "number",
14955
+ "boolean",
14956
+ "enum"
14957
+ ]);
14891
14958
  /**
14892
- * What a block IS (D659). Absent means `code` — every row stored before this
14893
- * union keeps working unchanged.
14894
- * - `code`: the TypeScript in `code`, compiled and run in its own runner;
14895
- * - `recipe`: RESERVED (automations), stored shape only; refused at run by name;
14896
- * - `composition`: declarative, run by the shared `composer`; `code` is `''`.
14959
+ * How a field says "unavailable" (D393, D663): `null` when its schema allows it;
14960
+ * `unknown` — the enum's own word — for an ADDED field that has one; `release`
14961
+ * for a REPLACED field of an existing device, whose claim is released so the
14962
+ * native provider's reading shows again; else the (composed) device goes offline.
14897
14963
  */
14898
- var CoreBlockSourceSchema = discriminatedUnion("kind", [
14899
- object({ kind: literal("code") }),
14900
- object({
14901
- kind: literal("recipe"),
14902
- recipe: AutomationRecipeSchema
14903
- }),
14904
- object({
14905
- kind: literal("composition"),
14906
- composition: CompositionSchema
14907
- })
14964
+ var CompositionUnavailableModeSchema = _enum([
14965
+ "null",
14966
+ "offline",
14967
+ "unknown",
14968
+ "release"
14908
14969
  ]);
14909
14970
  /**
14910
- * The client-authored half of a composition block. No `code`, no placement (hub-only; Phase 7).
14911
- *
14912
- * `name`: REQUIRED for a `new` target — the block's name IS the device's name
14913
- * (D659). ABSENT for an `existing` target — a customization has no name of its
14914
- * own; the server derives one (`customizationBlockName`, D663) and refuses a
14915
- * client-sent name by name.
14971
+ * `derived` reads a source · `clock` reads only `now`, recomputed when a sibling
14972
+ * field of its cap changes value · `constant` reads nothing, computed once ·
14973
+ * `accumulator` advances from the target's seed/current output.
14916
14974
  */
14917
- var CompositionBlockInputSchema = object({
14918
- name: string().min(1).max(120).optional(),
14919
- enabled: boolean(),
14920
- composition: CompositionSchema
14921
- });
14922
- /** A stored block. `code` is always present after normalize (derived from files). */
14923
- var CoreBlockSchema = object({
14924
- name: string().min(1).max(120),
14925
- /** Single-file source, or `files[entry]` when multi-file. Always set on disk. */
14926
- code: string().max(2e5),
14927
- files: CoreBlockFilesSchema.optional(),
14928
- entry: string().min(1).max(260).optional(),
14929
- enabled: boolean(),
14930
- placement: CoreBlockPlacementSchema,
14931
- integrationId: string().optional(),
14932
- settingsSchema: CoreBlockSettingsSchemaBridge.optional(),
14933
- id: string(),
14934
- createdAt: number(),
14935
- updatedAt: number(),
14936
- /** Server-stamped author. */
14937
- createdBy: string(),
14938
- status: CoreBlockStatusSchema,
14939
- /**
14940
- * Why the block is not running, when it is not. The operator's ONLY window
14941
- * into a block that failed at load — a block that is silently absent is the
14942
- * failure mode this whole feature has to avoid.
14943
- */
14944
- lastError: string().optional(),
14945
- /** Ms epoch of the last state change. */
14946
- lastChangedAt: number(),
14947
- /** What the block is. Absent means `code` (D659). */
14948
- source: CoreBlockSourceSchema.optional()
14975
+ var CompositionFieldRoleSchema = _enum([
14976
+ "derived",
14977
+ "clock",
14978
+ "constant",
14979
+ "accumulator"
14980
+ ]);
14981
+ /** The item a planned field belongs to; its `path` is then the dotted path INSIDE the item. */
14982
+ var CompositionFieldItemSchema = object({
14983
+ arrayPath: string(),
14984
+ key: string(),
14985
+ label: string()
14949
14986
  });
14950
- /** A stored composition block and what the composer says about it. */
14951
- var CompositionViewSchema = object({
14952
- block: CoreBlockSchema,
14953
- state: discriminatedUnion("kind", [object({
14954
- kind: literal("answered"),
14955
- state: CompositionBlockStateSchema.nullable()
14956
- }), object({
14957
- kind: literal("unreachable"),
14958
- error: string()
14959
- })])
14987
+ var CompositionFieldPlanSchema = object({
14988
+ cap: string(),
14989
+ /** Top-level field, or — when `item` is set — the dotted path inside that item. */
14990
+ path: string(),
14991
+ item: CompositionFieldItemSchema.optional(),
14992
+ /** Index into `composition.features` this field's capability came from —
14993
+ * lets a source/cycle problem name its path as `features[N].fields.<path>`
14994
+ * (the same convention `planComposition`'s own problems already use). */
14995
+ featureIndex: number().int().nonnegative(),
14996
+ kind: CompositionFieldKindSchema,
14997
+ nullable: boolean(),
14998
+ required: boolean(),
14999
+ unavailable: CompositionUnavailableModeSchema,
15000
+ role: CompositionFieldRoleSchema,
15001
+ source: CompositionFieldSourceSchema,
15002
+ reads: array(CompositionFieldReadSchema)
14960
15003
  });
14961
15004
  /**
14962
- * A read of the stored compositions. `not-loaded` while the `core_blocks`
14963
- * table has not been READ: an empty answer then would make "not yet known"
14964
- * look like "none", and invite the operator to recreate what exists (D315).
15005
+ * What a feature does to its target (D663): `new` builds a capability on a new
15006
+ * device; on an existing device it `add`s a capability the device lacks, or
15007
+ * `replace`s named fields of one it has natively.
14965
15008
  */
14966
- var CompositionsNotLoadedSchema = object({
14967
- state: literal("not-loaded"),
14968
- reason: string()
15009
+ var CompositionFeatureModeSchema = _enum([
15010
+ "new",
15011
+ "add",
15012
+ "replace"
15013
+ ]);
15014
+ var CompositionFeaturePlanSchema = object({
15015
+ /** Index in the stored feature array; disabled features are omitted rather than re-indexed. */
15016
+ featureIndex: number().int().nonnegative(),
15017
+ cap: string(),
15018
+ mode: CompositionFeatureModeSchema
14969
15019
  });
14970
- var CompositionListReadSchema = discriminatedUnion("state", [object({
14971
- state: literal("loaded"),
14972
- views: array(CompositionViewSchema)
14973
- }), CompositionsNotLoadedSchema]);
14974
- var CompositionGetReadSchema = discriminatedUnion("state", [object({
14975
- state: literal("loaded"),
14976
- view: CompositionViewSchema.nullable()
14977
- }), CompositionsNotLoadedSchema]);
14978
- /** The customization of one existing device, found by its TARGET, or `not-loaded` (D315). */
14979
- var CompositionCustomizationReadSchema = discriminatedUnion("state", [object({
14980
- state: literal("loaded"),
14981
- view: CompositionViewSchema.nullable()
14982
- }), CompositionsNotLoadedSchema]);
14983
- /** What a compile attempt produced. */
14984
- var CoreBlockCompileResultSchema = object({
15020
+ var CompositionValidationSchema = object({
14985
15021
  ok: boolean(),
14986
- /** Present when `ok` is false — the first error, in the author's words. */
14987
- error: string().optional(),
14988
- line: number().optional(),
14989
- column: number().optional()
15022
+ problems: array(CompositionProblemSchema),
15023
+ fields: array(CompositionFieldPlanSchema),
15024
+ features: array(CompositionFeaturePlanSchema),
15025
+ events: array(CompositionEventPlanSchema)
14990
15026
  });
14991
15027
  /**
14992
- * Compile input: legacy single-file `{ code }`, or a multi-file project.
14993
- * When `files` is present, it wins over `code`.
15028
+ * Mirrors `CoreBlockStatus`, pinned by `core-blocks-source.spec.ts`. `degraded`:
15029
+ * running, but a replaced field is released to the native provider while its
15030
+ * source is unavailable (ruling S1b, D663) — the reason names each such field.
14994
15031
  */
14995
- var CoreBlockCompileInputSchema = object({
14996
- code: string().optional(),
14997
- files: CoreBlockFilesSchema.optional(),
14998
- entry: string().min(1).max(260).optional()
14999
- }).superRefine((val, ctx) => {
15000
- if (val.files !== void 0) {
15001
- const entry = val.entry ?? "main.ts";
15002
- if (val.files[entry] === void 0) ctx.addIssue({
15003
- code: "custom",
15004
- message: `files must include the entry "${entry}"`,
15005
- path: ["files"]
15006
- });
15007
- return;
15008
- }
15009
- if (val.code === void 0) ctx.addIssue({
15010
- code: "custom",
15011
- message: "code or files is required",
15012
- path: ["code"]
15013
- });
15014
- });
15015
- method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }), method(object({ blockId: string() }), object({ block: CoreBlockSchema.nullable() }), { auth: "admin" }), method(object({ block: CoreBlockInputSchema }), object({ block: CoreBlockSchema }), {
15016
- kind: "mutation",
15017
- auth: "admin",
15018
- caller: "required"
15019
- }), method(object({
15020
- blockId: string(),
15021
- block: CoreBlockInputFieldsSchema.partial()
15022
- }), object({ block: CoreBlockSchema }), {
15023
- kind: "mutation",
15024
- auth: "admin",
15025
- caller: "required"
15026
- }), method(object({ blockId: string() }), object({ success: literal(true) }), {
15027
- kind: "mutation",
15028
- auth: "admin"
15029
- }), method(object({
15030
- blockId: string(),
15031
- enabled: boolean()
15032
- }), object({ block: CoreBlockSchema }), {
15033
- kind: "mutation",
15034
- auth: "admin"
15035
- }), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
15036
- kind: "mutation",
15037
- auth: "admin"
15038
- }), method(CoreBlockCompileInputSchema, CoreBlockCompileResultSchema, {
15039
- kind: "mutation",
15040
- auth: "admin"
15041
- }), method(object({ blockId: string() }), SettingsSchemaWithValuesSchema.nullable(), { auth: "admin" }), method(object({
15042
- blockId: string(),
15043
- patch: SettingsPatchSchema
15044
- }), SettingsUpdateResultSchema, {
15045
- kind: "mutation",
15046
- auth: "admin"
15047
- }), method(object({}), object({ libs: array(object({
15048
- filePath: string(),
15049
- content: string()
15050
- })) }), { auth: "admin" }), method(object({ block: CompositionBlockInputSchema }), object({ block: CoreBlockSchema }), {
15051
- kind: "mutation",
15052
- auth: "admin",
15053
- caller: "required"
15054
- }), method(object({
15032
+ var CompositionRunStatusSchema = _enum([
15033
+ "stopped",
15034
+ "starting",
15035
+ "running",
15036
+ "degraded",
15037
+ "failed"
15038
+ ]);
15039
+ var CompositionFieldStateKindSchema = _enum([
15040
+ "fresh",
15041
+ "stale",
15042
+ "unavailable"
15043
+ ]);
15044
+ var CompositionFieldStateSchema = object({
15045
+ cap: string(),
15046
+ path: string(),
15047
+ /** Same as the plan's: two items' `status` are told apart by it. */
15048
+ item: CompositionFieldItemSchema.optional(),
15049
+ state: CompositionFieldStateKindSchema,
15050
+ /** `null` whenever `state === 'unavailable'`; never a plausible stand-in (D393). */
15051
+ value: union([
15052
+ string(),
15053
+ number(),
15054
+ boolean(),
15055
+ _null()
15056
+ ]),
15057
+ reason: string().optional()
15058
+ });
15059
+ var CompositionBlockStateSchema = object({
15055
15060
  blockId: string(),
15056
- expectedUpdatedAt: number(),
15057
- block: CompositionBlockInputSchema.partial()
15058
- }), object({ block: CoreBlockSchema }), {
15059
- kind: "mutation",
15060
- auth: "admin",
15061
- caller: "required"
15062
- }), method(object({ blockId: string() }), CompositionGetReadSchema, { auth: "admin" }), method(object({}), CompositionListReadSchema, { auth: "admin" }), method(object({
15063
- addonId: string().min(1),
15064
- stableId: string().min(1)
15065
- }), CompositionCustomizationReadSchema, { auth: "admin" }), method(object({ deviceType: _enum(DeviceType) }), ComposableCapabilitiesSchema, { auth: "admin" }), method(object({
15066
- composition: CompositionSchema,
15067
- blockId: string().optional()
15068
- }), CompositionValidationSchema, {
15069
- kind: "mutation",
15070
- auth: "admin",
15071
- access: "view"
15061
+ deviceId: number().nullable(),
15062
+ status: CompositionRunStatusSchema,
15063
+ reason: string().optional(),
15064
+ fields: array(CompositionFieldStateSchema)
15065
+ });
15066
+ var ComposerBlockSchema = object({
15067
+ id: string().min(1),
15068
+ name: string().min(1).max(120),
15069
+ enabled: boolean(),
15070
+ updatedAt: number(),
15071
+ composition: CompositionSchema
15072
15072
  });
15073
15073
  /**
15074
- * `custom-model-registry` — collection cap exposing operator-registered
15075
- * custom detection models. Each provider (today: `addon-model-studio`)
15076
- * contributes a list of `CustomModelDescriptor`s; the hub auto-concatenates
15077
- * them across providers (`concatCollection`).
15078
- *
15079
- * The detection-pipeline is *aware* of this cap: when at least one provider
15080
- * exists it unions these descriptors into the per-step model picker and the
15081
- * runtime model-resolution path, alongside the static catalog. When no
15082
- * provider exists the consumer no-ops entirely (identical to the catalog-only
15083
- * behaviour).
15084
- *
15085
- * A descriptor carries a full `ModelCatalogEntry` directly — the same shape
15086
- * the static catalog uses — so the existing download/resolution code consumes
15087
- * it unchanged. `stepId` is the detection step the model targets
15088
- * (e.g. `'object-detection'`).
15074
+ * A stored composition row that no longer PARSES — a schema a release
15075
+ * tightened, a row written by a newer build. Sent by id so the composer keeps
15076
+ * its device as a placeholder and reports the block `failed` naming the parse
15077
+ * error. Leaving it out would read, twice, exactly like a delete (D49).
15089
15078
  */
15090
- var CustomModelDescriptorSchema = object({
15091
- stepId: string(),
15092
- entry: ModelCatalogEntrySchema
15093
- });
15094
- /** One regular file of an imported bundle, as the producer measured it. */
15095
- var ModelBundleFileSchema = object({
15096
- /** Relative path inside the bundle tar; an mlpackage member is `<x>.mlpackage/<member>`. */
15097
- path: string().min(1),
15098
- bytes: number().int().nonnegative(),
15099
- sha256: string().regex(/^[0-9a-f]{64}$/)
15079
+ var ComposerUnreadableBlockSchema = object({
15080
+ id: string().min(1),
15081
+ /** The row's stored name column; null when even that is unreadable (the device keeps the name it has). */
15082
+ name: string().nullable(),
15083
+ /** The parse error, in the operator's words. */
15084
+ error: string()
15100
15085
  });
15101
- var ImportModelBundleInputSchema = object({
15102
- /** The registry provider that keeps the model (Model Studio). */
15103
- addonId: string(),
15104
- stepId: string().min(1),
15086
+ method(object({
15087
+ /** The shared Blocks integration; null until `core-blocks` has reconciled it. */
15088
+ integrationId: string().nullable(),
15089
+ /** False when the `core_blocks` read failed: then nothing may be withdrawn (D49). */
15090
+ storeLoaded: boolean(),
15091
+ blocks: array(ComposerBlockSchema),
15092
+ /** Operator restarts: rebuild these runtimes and re-resolve their sources. Stores nothing (D62). */
15093
+ restartBlockIds: array(string()),
15094
+ /** Composition rows that exist but do not parse: held, never withdrawn, reported failed. */
15095
+ unreadableBlocks: array(ComposerUnreadableBlockSchema)
15096
+ }), object({
15097
+ blocks: array(object({
15098
+ blockId: string(),
15099
+ status: CompositionRunStatusSchema,
15100
+ reason: string().optional()
15101
+ })),
15105
15102
  /**
15106
- * The catalog rows to register, complete, every format URL already
15107
- * `camstack-local://<modelId>/<file>` naming a file of the bundle. One
15108
- * bundle may carry several rows (a base and its INT8 variant).
15103
+ * False when the composer declared and withdrew NOTHING (store not loaded,
15104
+ * integration unknown, its own rows unread or read empty once). An empty
15105
+ * apply the caller owes is paid off only by one the composer acted on (D49).
15109
15106
  */
15110
- entries: array(ModelCatalogEntrySchema).min(1).max(4),
15111
- /** The bundle tar, offered by the producer over `ctx.peerBytes` (same host). */
15112
- bundle: object({
15113
- ticket: PeerBytesTicketSchema,
15114
- bytes: number().int().positive(),
15115
- sha256: string().regex(/^[0-9a-f]{64}$/)
15116
- }),
15117
- /** Every file the tar must hold; anything missing, extra or different is refused. */
15118
- files: array(ModelBundleFileSchema).min(1).max(64),
15119
- /** Convert missing engine formats and push the model to every agent afterwards. */
15120
- distribute: boolean()
15121
- });
15122
- var ImportModelBundleResultSchema = discriminatedUnion("kind", [object({
15123
- kind: literal("imported"),
15124
- modelIds: array(string()).readonly(),
15125
- distributed: array(object({
15126
- modelId: string(),
15127
- nodeId: string(),
15128
- format: _enum(MODEL_FORMATS),
15129
- ok: boolean(),
15130
- error: string().optional()
15131
- })).readonly()
15132
- }), object({
15133
- kind: literal("refused"),
15134
- reason: _enum([
15135
- "transfer-failed",
15136
- "checksum-mismatch",
15137
- "bundle-mismatch",
15138
- "invalid-entry",
15139
- "id-taken"
15140
- ]),
15141
- detail: string()
15142
- })]);
15143
- method(_void(), array(CustomModelDescriptorSchema).readonly(), { auth: "admin" }), method(ImportModelBundleInputSchema, ImportModelBundleResultSchema, {
15107
+ acted: boolean()
15108
+ }), {
15144
15109
  kind: "mutation",
15145
- auth: "admin",
15146
- timeoutMs: 30 * 6e4
15147
- });
15148
- /**
15149
- * Query filter for settings-store collections.
15150
- */
15151
- var QueryFilterSchema = object({
15152
- where: record(string(), unknown()).optional(),
15153
- whereIn: record(string(), array(unknown())).optional(),
15154
- whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
15155
- /** NULL-safe exclusion: matches rows whose field is NULL OR != the value. */
15156
- whereNot: record(string(), unknown()).optional(),
15157
- /** NULL-safe exclusion of a SET — `whereNot` for more than one value. An
15158
- * empty list excludes nothing. See `QueryFilter.whereNotIn`. */
15159
- whereNotIn: record(string(), array(unknown())).optional(),
15160
- orderBy: object({
15161
- field: string(),
15162
- direction: _enum(["asc", "desc"])
15163
- }).optional(),
15164
- limit: number().optional(),
15165
- offset: number().optional()
15166
- });
15167
- /**
15168
- * The predicate half of a filter, for BULK MUTATIONS.
15169
- *
15170
- * Deliberately not `QueryFilterSchema`: `orderBy` / `limit` / `offset` have no
15171
- * meaning for a statement that rewrites a set, and accepting them would invite
15172
- * a caller to believe `limit` bounds the damage. Every field is optional here
15173
- * only so the shape stays composable — the implementation REJECTS a filter
15174
- * that compiles to no predicate, because that is the whole collection.
15175
- */
15176
- var MutationFilterSchema = object({
15177
- where: record(string(), unknown()).optional(),
15178
- whereIn: record(string(), array(unknown())).optional(),
15179
- whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
15180
- whereNot: record(string(), unknown()).optional(),
15181
- whereNotIn: record(string(), array(unknown())).optional()
15110
+ auth: "admin"
15111
+ }), method(object({ blockIds: array(string()) }), object({ states: array(CompositionBlockStateSchema) }), { auth: "admin" });
15112
+ var NUMBER = {
15113
+ kind: "fixed",
15114
+ result: ["number"]
15115
+ };
15116
+ var BOOLEAN = {
15117
+ kind: "fixed",
15118
+ result: ["boolean"]
15119
+ };
15120
+ Object.freeze({
15121
+ min: NUMBER,
15122
+ max: NUMBER,
15123
+ abs: NUMBER,
15124
+ floor: NUMBER,
15125
+ ceil: NUMBER,
15126
+ sqrt: NUMBER,
15127
+ round: NUMBER,
15128
+ pow: NUMBER,
15129
+ clamp: NUMBER,
15130
+ avg: NUMBER,
15131
+ sum: NUMBER,
15132
+ age: NUMBER,
15133
+ convert: NUMBER,
15134
+ coalesce: { kind: "coalesce" },
15135
+ any: BOOLEAN,
15136
+ all: BOOLEAN,
15137
+ within: BOOLEAN,
15138
+ latest: {
15139
+ kind: "fixed",
15140
+ result: ["number", "null"]
15141
+ },
15142
+ number: {
15143
+ kind: "fixed",
15144
+ result: ["number", "null"]
15145
+ },
15146
+ rose: {
15147
+ kind: "fixed",
15148
+ result: ["number", "null"]
15149
+ },
15150
+ count: NUMBER
15182
15151
  });
15152
+ `${COMPOSED_DEVICE_STABLE_ID_PREFIX}`;
15183
15153
  /**
15184
- * One scalar an {@link settingsStoreCapability.methods.aggregate} call asks for.
15185
- *
15186
- * `as` names the slot in the result, so the SAME column may be asked twice with
15187
- * two operations (`MIN(startMs)` and `MAX(startMs)` in one round trip) — which
15188
- * a `Record<column, op>` shape could not express.
15154
+ * What a composition may BUILD on a device type (G2, D676): the caps a new
15155
+ * device of that type, or an existing one that lacks them, can be given. The
15156
+ * blockers are the validator's own refusal codes, and a spec compares this
15157
+ * listing with `planComposition` for every capability — the editor can never
15158
+ * offer a feature the save would refuse for the same reason.
15189
15159
  */
15190
- var AggregateFieldSchema = object({
15191
- /** Result key. */
15192
- as: string().min(1),
15193
- /** Column to aggregate. Must be a real column of a declared collection. */
15194
- field: string().min(1),
15195
- op: _enum([
15196
- "sum",
15197
- "min",
15198
- "max"
15199
- ])
15160
+ var ComposableFieldSchema = object({
15161
+ path: string(),
15162
+ kind: CompositionFieldKindSchema,
15163
+ nullable: boolean(),
15164
+ required: boolean(),
15165
+ enumValues: array(string()).optional()
15200
15166
  });
15167
+ var ComposableCapabilitiesSchema = object({ caps: array(object({
15168
+ cap: string(),
15169
+ fields: array(ComposableFieldSchema),
15170
+ /** Present for an item-array cap (`consumables.items`). */
15171
+ itemArray: object({
15172
+ path: string(),
15173
+ keyField: string()
15174
+ }).optional()
15175
+ })) });
15176
+ /** Where a block runs. The operator chooses — a block driving a device on an
15177
+ * agent is the reason placement is not fixed to the hub. */
15178
+ var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
15179
+ /** What a block's process is doing. Mirrors the addon runner's own lifecycle so
15180
+ * a failing block reads the same way a failing addon does. `degraded` is a
15181
+ * composition on an existing device with a field handed back to the native
15182
+ * provider while its source is unavailable (S1b, D663); the error names it. */
15183
+ var CoreBlockStatusSchema = _enum([
15184
+ "stopped",
15185
+ "starting",
15186
+ "running",
15187
+ "degraded",
15188
+ "failed"
15189
+ ]);
15190
+ /** path → TypeScript source for a multi-file block. */
15191
+ var CoreBlockFilesSchema = record(string().min(1).max(260), string().max(2e5));
15201
15192
  /**
15202
- * `COUNT(*)` plus one number per requested field.
15203
- *
15204
- * `null` means NO ROW MATCHED, never zero: a `SUM` over an empty set and a sum
15205
- * that really is 0 are different facts, and an accounting caller that renders
15206
- * "0 bytes, oldest = 0" for "nothing here" reports a lie about a disk.
15193
+ * A block's settings form — the same ConfigUISchema addons declare.
15194
+ * Loose on the wire (field variants are polymorphic); typed for callers.
15207
15195
  */
15208
- var AggregateResultSchema = object({
15209
- count: number().int(),
15210
- values: record(string(), number().nullable())
15211
- });
15212
- /** A single stored record: `{ id, data }`. */
15213
- var SettingsRecordSchema = object({
15214
- id: string(),
15215
- data: record(string(), unknown())
15216
- });
15196
+ var CoreBlockSettingsSchemaBridge = custom();
15217
15197
  /**
15218
- * One record of a BULK insert — {@link SettingsRecordSchema} with the id made
15219
- * optional.
15220
- *
15221
- * A separate schema rather than loosening the shared one: every other method
15222
- * on this cap addresses a row BY its id, and making that field optional
15223
- * everywhere would turn a forgotten key into a silently generated one on
15224
- * `update` and `delete` as well.
15198
+ * Client-authored fields (plain object). Kept separate from the refined create
15199
+ * schema because Zod 4 refuses `.partial()` on schemas that carry refinements —
15200
+ * `update` needs a partial of these fields.
15225
15201
  */
15226
- var BulkRecordSchema = object({
15227
- id: string().optional(),
15228
- data: record(string(), unknown())
15202
+ var CoreBlockInputFieldsSchema = object({
15203
+ name: string().min(1).max(120),
15204
+ /**
15205
+ * Single-file TypeScript source. Kept for backward compatibility: when
15206
+ * `files` is absent, this IS `files[entry]`. On write, if `files` is
15207
+ * provided, the server derives `code` from `files[entry]` so compile and
15208
+ * runtime paths that still read `code` keep working.
15209
+ */
15210
+ code: string().max(2e5).optional(),
15211
+ /** Multi-file project (path → source). Absent = legacy single-file `code`. */
15212
+ files: CoreBlockFilesSchema.optional(),
15213
+ /** Entry file inside `files`. Default {@link DEFAULT_CORE_BLOCK_ENTRY}. */
15214
+ entry: string().min(1).max(260).optional(),
15215
+ enabled: boolean(),
15216
+ placement: CoreBlockPlacementSchema,
15217
+ /**
15218
+ * Integration the block's devices hang from. Absent = the system integration
15219
+ * blocks share. A block may declare its own instead.
15220
+ */
15221
+ integrationId: string().optional(),
15222
+ /**
15223
+ * Cached settings form for this block. Prefer extracting `settingsSchema`
15224
+ * from the compiled entry after a successful load; the editor may also set
15225
+ * it. Absent ⇒ `getBlockSettings` returns null until one is known.
15226
+ */
15227
+ settingsSchema: CoreBlockSettingsSchemaBridge.optional()
15228
+ });
15229
+ /** Client-authored fields for create — requires `code` or `files[entry]`. */
15230
+ var CoreBlockInputSchema = CoreBlockInputFieldsSchema.superRefine((val, ctx) => {
15231
+ const entry = val.entry ?? "main.ts";
15232
+ if (val.files !== void 0) {
15233
+ if (val.files[entry] === void 0) ctx.addIssue({
15234
+ code: "custom",
15235
+ message: `files must include the entry "${entry}"`,
15236
+ path: ["files"]
15237
+ });
15238
+ return;
15239
+ }
15240
+ if (val.code === void 0) ctx.addIssue({
15241
+ code: "custom",
15242
+ message: "code or files is required",
15243
+ path: ["code"]
15244
+ });
15229
15245
  });
15230
15246
  /**
15231
- * The database FILES one `sqlite-settings` engine process owns (D851).
15232
- * Closed on purpose: a file is a thing the engine opens, backs up and gives a
15233
- * serving thread, so a new one is a code change, never a string a caller
15234
- * invents. `settings` is `camstack.db` and the default for every collection
15235
- * that names no file.
15236
- */
15237
- var DataStoreDatabaseSchema = _enum([
15238
- "settings",
15239
- "events",
15240
- "history"
15241
- ]);
15242
- DataStoreDatabaseSchema.options;
15243
- /**
15244
- * One write inside a {@link settingsStoreCapability.methods.transact} call.
15245
- * The three that a "replace this set, bump that revision" write needs, plus
15246
- * the batched upsert a rollup writer needs.
15247
+ * What a block IS (D659). Absent means `code` — every row stored before this
15248
+ * union keeps working unchanged.
15249
+ * - `code`: the TypeScript in `code`, compiled and run in its own runner;
15250
+ * - `recipe`: RESERVED (automations), stored shape only; refused at run by name;
15251
+ * - `composition`: declarative, run by the shared `composer`; `code` is `''`.
15247
15252
  */
15248
- var TransactOpSchema = discriminatedUnion("op", [
15249
- object({
15250
- op: literal("deleteWhere"),
15251
- collection: string(),
15252
- filter: MutationFilterSchema
15253
- }),
15254
- object({
15255
- op: literal("insertMany"),
15256
- collection: string(),
15257
- records: array(BulkRecordSchema).readonly()
15258
- }),
15253
+ var CoreBlockSourceSchema = discriminatedUnion("kind", [
15254
+ object({ kind: literal("code") }),
15259
15255
  object({
15260
- op: literal("update"),
15261
- collection: string(),
15262
- id: string(),
15263
- data: record(string(), unknown())
15256
+ kind: literal("recipe"),
15257
+ recipe: AutomationRecipeSchema
15264
15258
  }),
15265
15259
  object({
15266
- op: literal("upsertMany"),
15267
- collection: string(),
15268
- conflict: array(string()).readonly(),
15269
- records: array(BulkRecordSchema).readonly()
15260
+ kind: literal("composition"),
15261
+ composition: CompositionSchema
15270
15262
  })
15271
15263
  ]);
15272
15264
  /**
15273
- * The precondition of a transaction: the row `key` of `collection` must hold
15274
- * `equals` in `column`, read INSIDE the transaction.
15275
- */
15276
- var TransactGuardSchema = object({
15277
- collection: string(),
15278
- key: string(),
15279
- column: string(),
15280
- equals: unknown()
15281
- });
15282
- /**
15283
- * Column declaration for a structured (SQL-backed) collection.
15265
+ * The client-authored half of a composition block. No `code`, no placement (hub-only; Phase 7).
15284
15266
  *
15285
- * Logical types — the backend translates each to the matching SQLite
15286
- * storage class and handles per-type marshaling:
15287
- * - `TEXT` / `INTEGER` / `REAL` — native SQLite types, pass-through
15288
- * - `JSON` — TEXT under the hood; serialised on write, parsed on read
15289
- * - `BOOLEAN` — INTEGER 0/1 under the hood; coerced both directions
15267
+ * `name`: REQUIRED for a `new` target — the block's name IS the device's name
15268
+ * (D659). ABSENT for an `existing` target — a customization has no name of its
15269
+ * own; the server derives one (`customizationBlockName`, D663) and refuses a
15270
+ * client-sent name by name.
15290
15271
  */
15291
- var CollectionColumnSchema = object({
15292
- name: string(),
15293
- type: _enum([
15294
- "TEXT",
15295
- "INTEGER",
15296
- "REAL",
15297
- "JSON",
15298
- "BOOLEAN"
15299
- ]),
15300
- primaryKey: boolean().optional(),
15301
- notNull: boolean().optional(),
15302
- unique: boolean().optional(),
15272
+ var CompositionBlockInputSchema = object({
15273
+ name: string().min(1).max(120).optional(),
15274
+ enabled: boolean(),
15275
+ composition: CompositionSchema
15276
+ });
15277
+ /** A stored block. `code` is always present after normalize (derived from files). */
15278
+ var CoreBlockSchema = object({
15279
+ name: string().min(1).max(120),
15280
+ /** Single-file source, or `files[entry]` when multi-file. Always set on disk. */
15281
+ code: string().max(2e5),
15282
+ files: CoreBlockFilesSchema.optional(),
15283
+ entry: string().min(1).max(260).optional(),
15284
+ enabled: boolean(),
15285
+ placement: CoreBlockPlacementSchema,
15286
+ integrationId: string().optional(),
15287
+ settingsSchema: CoreBlockSettingsSchemaBridge.optional(),
15288
+ id: string(),
15289
+ createdAt: number(),
15290
+ updatedAt: number(),
15291
+ /** Server-stamped author. */
15292
+ createdBy: string(),
15293
+ status: CoreBlockStatusSchema,
15303
15294
  /**
15304
- * Column DEFAULT. Required for B2: the four `ensureTable` consumers declare
15305
- * columns like `enabled INTEGER NOT NULL DEFAULT 1`, and without this the
15306
- * collection surface simply cannot express their existing tables — which is
15307
- * why they were still on the uncapped `table*` API.
15295
+ * Why the block is not running, when it is not. The operator's ONLY window
15296
+ * into a block that failed at load — a block that is silently absent is the
15297
+ * failure mode this whole feature has to avoid.
15308
15298
  */
15309
- defaultValue: union([
15310
- string(),
15311
- number(),
15312
- boolean()
15313
- ]).optional()
15299
+ lastError: string().optional(),
15300
+ /** Ms epoch of the last state change. */
15301
+ lastChangedAt: number(),
15302
+ /** What the block is. Absent means `code` (D659). */
15303
+ source: CoreBlockSourceSchema.optional()
15314
15304
  });
15315
- var CollectionIndexSchema = object({
15316
- name: string(),
15317
- columns: array(string()).readonly(),
15318
- unique: boolean().optional()
15305
+ /** A stored composition block and what the composer says about it. */
15306
+ var CompositionViewSchema = object({
15307
+ block: CoreBlockSchema,
15308
+ state: discriminatedUnion("kind", [object({
15309
+ kind: literal("answered"),
15310
+ state: CompositionBlockStateSchema.nullable()
15311
+ }), object({
15312
+ kind: literal("unreachable"),
15313
+ error: string()
15314
+ })])
15319
15315
  });
15320
15316
  /**
15321
- * The input of `declareCollection` — ONE schema for the door and for the
15322
- * engine behind it, so the two surfaces cannot drift (the dispatcher forwards
15323
- * the payload untouched).
15324
- *
15325
- * - `database` — the file the collection lives in (D851). Absent means
15326
- * `settings`. A declaration naming a file other than the one the table is
15327
- * already in is REFUSED by the engine, never moved.
15328
- * - `primaryKey` — a COMPOSITE key, as column names. A single name is the same
15329
- * as `primaryKey: true` on that column. A composite-keyed collection has no
15330
- * `id`: it is written with `insertMany` / `upsertMany` / `transact` and read
15331
- * with `query` / `count` / `aggregate` / `deleteWhere`; the calls that
15332
- * address one row by id are refused.
15333
- * - `withoutRowid` — emit `WITHOUT ROWID`. Requires a primary key.
15317
+ * A read of the stored compositions. `not-loaded` while the `core_blocks`
15318
+ * table has not been READ: an empty answer then would make "not yet known"
15319
+ * look like "none", and invite the operator to recreate what exists (D315).
15334
15320
  */
15335
- var DeclareCollectionInputSchema = object({
15336
- namespace: string().optional(),
15337
- collection: string(),
15338
- columns: array(CollectionColumnSchema).readonly(),
15339
- indexes: array(CollectionIndexSchema).readonly().optional(),
15340
- database: DataStoreDatabaseSchema.optional(),
15341
- primaryKey: array(string()).readonly().optional(),
15342
- withoutRowid: boolean().optional()
15321
+ var CompositionsNotLoadedSchema = object({
15322
+ state: literal("not-loaded"),
15323
+ reason: string()
15324
+ });
15325
+ var CompositionListReadSchema = discriminatedUnion("state", [object({
15326
+ state: literal("loaded"),
15327
+ views: array(CompositionViewSchema)
15328
+ }), CompositionsNotLoadedSchema]);
15329
+ var CompositionGetReadSchema = discriminatedUnion("state", [object({
15330
+ state: literal("loaded"),
15331
+ view: CompositionViewSchema.nullable()
15332
+ }), CompositionsNotLoadedSchema]);
15333
+ /** The customization of one existing device, found by its TARGET, or `not-loaded` (D315). */
15334
+ var CompositionCustomizationReadSchema = discriminatedUnion("state", [object({
15335
+ state: literal("loaded"),
15336
+ view: CompositionViewSchema.nullable()
15337
+ }), CompositionsNotLoadedSchema]);
15338
+ /** What a compile attempt produced. */
15339
+ var CoreBlockCompileResultSchema = object({
15340
+ ok: boolean(),
15341
+ /** Present when `ok` is false — the first error, in the author's words. */
15342
+ error: string().optional(),
15343
+ line: number().optional(),
15344
+ column: number().optional()
15343
15345
  });
15344
- method(object({
15345
- namespace: string().optional(),
15346
- collection: string(),
15347
- key: string()
15348
- }), unknown(), { auth: "admin" }), method(object({
15349
- namespace: string().optional(),
15350
- collection: string(),
15351
- key: string(),
15352
- value: unknown()
15353
- }), _void(), {
15346
+ /**
15347
+ * Compile input: legacy single-file `{ code }`, or a multi-file project.
15348
+ * When `files` is present, it wins over `code`.
15349
+ */
15350
+ var CoreBlockCompileInputSchema = object({
15351
+ code: string().optional(),
15352
+ files: CoreBlockFilesSchema.optional(),
15353
+ entry: string().min(1).max(260).optional()
15354
+ }).superRefine((val, ctx) => {
15355
+ if (val.files !== void 0) {
15356
+ const entry = val.entry ?? "main.ts";
15357
+ if (val.files[entry] === void 0) ctx.addIssue({
15358
+ code: "custom",
15359
+ message: `files must include the entry "${entry}"`,
15360
+ path: ["files"]
15361
+ });
15362
+ return;
15363
+ }
15364
+ if (val.code === void 0) ctx.addIssue({
15365
+ code: "custom",
15366
+ message: "code or files is required",
15367
+ path: ["code"]
15368
+ });
15369
+ });
15370
+ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }), method(object({ blockId: string() }), object({ block: CoreBlockSchema.nullable() }), { auth: "admin" }), method(object({ block: CoreBlockInputSchema }), object({ block: CoreBlockSchema }), {
15354
15371
  kind: "mutation",
15355
- auth: "admin"
15372
+ auth: "admin",
15373
+ caller: "required"
15356
15374
  }), method(object({
15357
- namespace: string().optional(),
15358
- collection: string(),
15359
- filter: QueryFilterSchema.optional(),
15360
- columns: array(string()).readonly().optional()
15361
- }), array(SettingsRecordSchema).readonly(), { auth: "admin" }), method(object({
15362
- namespace: string().optional(),
15363
- collection: string(),
15364
- /** Declared columns identifying the slot; must be covered by a UNIQUE
15365
- * index on the collection. Never empty. */
15366
- conflict: array(string()).readonly(),
15367
- record: BulkRecordSchema
15368
- }), object({
15369
- /** The id the row HAS: assigned by SQLite on an insert, or the existing
15370
- * row's own id on an update. */
15371
- id: union([string(), number()]) }), {
15375
+ blockId: string(),
15376
+ block: CoreBlockInputFieldsSchema.partial()
15377
+ }), object({ block: CoreBlockSchema }), {
15372
15378
  kind: "mutation",
15373
- auth: "admin"
15374
- }), method(object({
15375
- namespace: string().optional(),
15376
- collection: string(),
15377
- conflict: array(string()).readonly(),
15378
- records: array(BulkRecordSchema).readonly()
15379
- }), object({ upserted: number().int() }), {
15379
+ auth: "admin",
15380
+ caller: "required"
15381
+ }), method(object({ blockId: string() }), object({ success: literal(true) }), {
15380
15382
  kind: "mutation",
15381
15383
  auth: "admin"
15382
15384
  }), method(object({
15383
- namespace: string().optional(),
15384
- collection: string(),
15385
- record: object({
15386
- id: string().optional(),
15387
- data: record(string(), unknown())
15388
- })
15389
- }), object({
15390
- /**
15391
- * The id the row ACTUALLY got (D473): the one supplied, the UUID minted
15392
- * for an absent one, or the ROWID SQLite assigned on an `INTEGER`
15393
- * primary key — which is the only place an auto key is knowable.
15394
- */
15395
- id: union([string(), number()]) }), {
15385
+ blockId: string(),
15386
+ enabled: boolean()
15387
+ }), object({ block: CoreBlockSchema }), {
15396
15388
  kind: "mutation",
15397
15389
  auth: "admin"
15398
- }), method(object({
15399
- namespace: string().optional(),
15400
- collection: string(),
15401
- records: array(BulkRecordSchema).readonly()
15402
- }), object({ inserted: number().int() }), {
15390
+ }), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
15403
15391
  kind: "mutation",
15404
15392
  auth: "admin"
15405
- }), method(object({
15406
- namespace: string().optional(),
15407
- guard: TransactGuardSchema.optional(),
15408
- ops: array(TransactOpSchema).readonly()
15409
- }), object({
15410
- applied: boolean(),
15411
- current: unknown().optional()
15412
- }), {
15393
+ }), method(CoreBlockCompileInputSchema, CoreBlockCompileResultSchema, {
15413
15394
  kind: "mutation",
15414
15395
  auth: "admin"
15415
- }), method(object({
15416
- namespace: string().optional(),
15417
- collection: string(),
15418
- id: string(),
15419
- data: record(string(), unknown())
15420
- }), _void(), {
15396
+ }), method(object({ blockId: string() }), SettingsSchemaWithValuesSchema.nullable(), { auth: "admin" }), method(object({
15397
+ blockId: string(),
15398
+ patch: SettingsPatchSchema
15399
+ }), SettingsUpdateResultSchema, {
15421
15400
  kind: "mutation",
15422
15401
  auth: "admin"
15423
- }), method(object({
15424
- namespace: string().optional(),
15425
- collection: string(),
15426
- key: string()
15427
- }), _void(), {
15402
+ }), method(object({}), object({ libs: array(object({
15403
+ filePath: string(),
15404
+ content: string()
15405
+ })) }), { auth: "admin" }), method(object({ block: CompositionBlockInputSchema }), object({ block: CoreBlockSchema }), {
15428
15406
  kind: "mutation",
15429
- auth: "admin"
15407
+ auth: "admin",
15408
+ caller: "required"
15430
15409
  }), method(object({
15431
- namespace: string().optional(),
15432
- collection: string(),
15433
- filter: MutationFilterSchema
15434
- }), object({ deleted: number().int() }), {
15410
+ blockId: string(),
15411
+ expectedUpdatedAt: number(),
15412
+ block: CompositionBlockInputSchema.partial()
15413
+ }), object({ block: CoreBlockSchema }), {
15435
15414
  kind: "mutation",
15436
- auth: "admin"
15437
- }), method(object({
15438
- namespace: string().optional(),
15439
- collection: string(),
15440
- filter: MutationFilterSchema,
15441
- data: record(string(), unknown())
15442
- }), object({ updated: number().int() }), {
15415
+ auth: "admin",
15416
+ caller: "required"
15417
+ }), method(object({ blockId: string() }), CompositionGetReadSchema, { auth: "admin" }), method(object({}), CompositionListReadSchema, { auth: "admin" }), method(object({
15418
+ addonId: string().min(1),
15419
+ stableId: string().min(1)
15420
+ }), CompositionCustomizationReadSchema, { auth: "admin" }), method(object({ deviceType: _enum(DeviceType) }), ComposableCapabilitiesSchema, { auth: "admin" }), method(object({
15421
+ composition: CompositionSchema,
15422
+ blockId: string().optional()
15423
+ }), CompositionValidationSchema, {
15443
15424
  kind: "mutation",
15444
- auth: "admin"
15445
- }), method(object({
15446
- namespace: string().optional(),
15447
- collection: string(),
15448
- filter: QueryFilterSchema.optional()
15449
- }), number(), { auth: "admin" }), method(object({
15450
- namespace: string().optional(),
15451
- collection: string(),
15452
- fields: array(AggregateFieldSchema).readonly(),
15453
- filter: QueryFilterSchema.optional()
15454
- }), AggregateResultSchema, { auth: "admin" }), method(object({
15455
- namespace: string().optional(),
15456
- collection: string(),
15457
- field: string(),
15458
- bucketSize: number().int().positive(),
15459
- origin: number().int(),
15460
- filter: QueryFilterSchema.optional()
15461
- }), array(object({
15462
- bucket: number().int(),
15463
- count: number().int()
15464
- })).readonly(), { auth: "admin" }), method(object({
15465
- namespace: string().optional(),
15466
- collection: string()
15467
- }), boolean(), { auth: "admin" }), method(DeclareCollectionInputSchema, _void(), {
15425
+ auth: "admin",
15426
+ access: "view"
15427
+ });
15428
+ /**
15429
+ * `custom-model-registry` — collection cap exposing operator-registered
15430
+ * custom detection models. Each provider (today: `addon-model-studio`)
15431
+ * contributes a list of `CustomModelDescriptor`s; the hub auto-concatenates
15432
+ * them across providers (`concatCollection`).
15433
+ *
15434
+ * The detection-pipeline is *aware* of this cap: when at least one provider
15435
+ * exists it unions these descriptors into the per-step model picker and the
15436
+ * runtime model-resolution path, alongside the static catalog. When no
15437
+ * provider exists the consumer no-ops entirely (identical to the catalog-only
15438
+ * behaviour).
15439
+ *
15440
+ * A descriptor carries a full `ModelCatalogEntry` directly — the same shape
15441
+ * the static catalog uses — so the existing download/resolution code consumes
15442
+ * it unchanged. `stepId` is the detection step the model targets
15443
+ * (e.g. `'object-detection'`).
15444
+ */
15445
+ var CustomModelDescriptorSchema = object({
15446
+ stepId: string(),
15447
+ entry: ModelCatalogEntrySchema
15448
+ });
15449
+ /** One regular file of an imported bundle, as the producer measured it. */
15450
+ var ModelBundleFileSchema = object({
15451
+ /** Relative path inside the bundle tar; an mlpackage member is `<x>.mlpackage/<member>`. */
15452
+ path: string().min(1),
15453
+ bytes: number().int().nonnegative(),
15454
+ sha256: string().regex(/^[0-9a-f]{64}$/)
15455
+ });
15456
+ var ImportModelBundleInputSchema = object({
15457
+ /** The registry provider that keeps the model (Model Studio). */
15458
+ addonId: string(),
15459
+ stepId: string().min(1),
15460
+ /**
15461
+ * The catalog rows to register, complete, every format URL already
15462
+ * `camstack-local://<modelId>/<file>` naming a file of the bundle. One
15463
+ * bundle may carry several rows (a base and its INT8 variant).
15464
+ */
15465
+ entries: array(ModelCatalogEntrySchema).min(1).max(4),
15466
+ /** The bundle tar, offered by the producer over `ctx.peerBytes` (same host). */
15467
+ bundle: object({
15468
+ ticket: PeerBytesTicketSchema,
15469
+ bytes: number().int().positive(),
15470
+ sha256: string().regex(/^[0-9a-f]{64}$/)
15471
+ }),
15472
+ /** Every file the tar must hold; anything missing, extra or different is refused. */
15473
+ files: array(ModelBundleFileSchema).min(1).max(64),
15474
+ /** Convert missing engine formats and push the model to every agent afterwards. */
15475
+ distribute: boolean()
15476
+ });
15477
+ var ImportModelBundleResultSchema = discriminatedUnion("kind", [object({
15478
+ kind: literal("imported"),
15479
+ modelIds: array(string()).readonly(),
15480
+ distributed: array(object({
15481
+ modelId: string(),
15482
+ nodeId: string(),
15483
+ format: _enum(MODEL_FORMATS),
15484
+ ok: boolean(),
15485
+ error: string().optional()
15486
+ })).readonly()
15487
+ }), object({
15488
+ kind: literal("refused"),
15489
+ reason: _enum([
15490
+ "transfer-failed",
15491
+ "checksum-mismatch",
15492
+ "bundle-mismatch",
15493
+ "invalid-entry",
15494
+ "id-taken"
15495
+ ]),
15496
+ detail: string()
15497
+ })]);
15498
+ method(_void(), array(CustomModelDescriptorSchema).readonly(), { auth: "admin" }), method(ImportModelBundleInputSchema, ImportModelBundleResultSchema, {
15468
15499
  kind: "mutation",
15469
- auth: "admin"
15500
+ auth: "admin",
15501
+ timeoutMs: 30 * 6e4
15470
15502
  });
15471
15503
  /**
15472
15504
  * What one engine says about itself. The orchestrator uses `kind` to pick
@@ -31891,6 +31923,18 @@ var CalendarInfoSchema = object({
31891
31923
  source: CalendarSourceKindSchema,
31892
31924
  enabled: boolean()
31893
31925
  });
31926
+ /**
31927
+ * Time contract of {@link CalendarEvent}.
31928
+ *
31929
+ * - Timed event: `startMs` / `endMs` are real instants; `endMs` is exclusive.
31930
+ * - All-day event (`allDay: true`): the event is a run of calendar DATES, not
31931
+ * instants. `startMs` is UTC midnight of the first date and `endMs` is UTC
31932
+ * midnight of the day AFTER the last date (RFC 5545 exclusive DTEND), so a
31933
+ * one-day event spans exactly 24 h. Every provider produces that and no
31934
+ * consumer may read the instants in a local timezone: use
31935
+ * {@link calendarEventLocalSpan}. Reading UTC midnight as local time put an
31936
+ * all-day event on the following day for any viewer ahead of UTC.
31937
+ */
31894
31938
  var CalendarEventSchema = object({
31895
31939
  id: string().min(1),
31896
31940
  calendarId: string().min(1),
@@ -34033,7 +34077,15 @@ var HubAddressBookSchema = object({
34033
34077
  publicEndpoint: HubAddressRoleResolutionSchema
34034
34078
  }),
34035
34079
  /** The `hub=` an app link carries (D709), or null. */
34036
- appLinkHub: string().nullable()
34080
+ appLinkHub: string().nullable(),
34081
+ /**
34082
+ * Every origin a browser can really reach this hub at, in the scheme each
34083
+ * listener serves: `https://<lan>:<httpsPort>`, and `http://<lan>:<httpPort>`
34084
+ * while LAN HTTP is on, plus the public and custom addresses. Distinct from
34085
+ * `rows`, which show `http://` only while LAN HTTP is on. Never loopback,
34086
+ * never the app link. Absent on a hub older than D794's amendment.
34087
+ */
34088
+ listenerOrigins: array(string()).readonly().optional()
34037
34089
  });
34038
34090
  var AllowedAddressesSchema = object({
34039
34091
  /**
@@ -40992,6 +41044,12 @@ Object.freeze({
40992
41044
  addonId: null,
40993
41045
  access: "view"
40994
41046
  },
41047
+ "backup.listAvailableDatabases": {
41048
+ capName: "backup",
41049
+ capScope: "system",
41050
+ addonId: null,
41051
+ access: "view"
41052
+ },
40995
41053
  "backup.listDestinations": {
40996
41054
  capName: "backup",
40997
41055
  capScope: "system",