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