@camstack/addon-training 0.1.39 → 0.1.41

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