@camstack/addon-osd-manager 0.1.231 → 0.1.232

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