@civitai/blocks-react 0.67.1 → 0.69.0

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.
@@ -0,0 +1,352 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { useBuzzWorkflow } from './useBuzzWorkflow.js';
3
+ /**
4
+ * The most workflows one {@link useWorkflowBatch} may watch.
5
+ *
6
+ * Derived from the host's poll allowance, not picked: one watched workflow makes
7
+ * at most 60 / 1.5 = 40 polls a minute (one poll in flight, `intervalMs` 1,500
8
+ * between polls), and the viewer's poll allowance is 1,200 per 60 s per block
9
+ * instance, so 1,200 / 40 = 30 workflows is the most one viewer can watch before
10
+ * polls start coming back as placeholders. The developer docs' limits table
11
+ * gives the same figure. A list longer than this throws, because an app that
12
+ * needs more cells needs a different plan than "watch them all at once".
13
+ */
14
+ export const WORKFLOW_BATCH_MAX_CELLS = 30;
15
+ /**
16
+ * How many `cancel()` requests {@link UseWorkflowBatch.cancelAll} keeps in flight.
17
+ *
18
+ * Cancels charge the ESTIMATE allowance (150 per 10 s per block instance). On a
19
+ * page app every viewer of the app shares that allowance, along with the host's
20
+ * other reads for the app (viewer, balance, image and model lookups). Four at a
21
+ * time keeps a 16-cell cancel-all from landing as one burst on an allowance other
22
+ * people are using. Not exported: it is a pacing detail, not a contract.
23
+ */
24
+ const CANCEL_CONCURRENCY = 4;
25
+ /** Statuses after which a workflow will not change again. */
26
+ const TERMINAL = new Set([
27
+ 'succeeded',
28
+ 'failed',
29
+ 'canceled',
30
+ 'expired',
31
+ ]);
32
+ /**
33
+ * Placeholder ids a resolved `submit()` can carry when no workflow was created
34
+ * (see `useBuzzWorkflow().submit`). There is nothing behind them to watch.
35
+ */
36
+ const NON_WORKFLOW_IDS = new Set(['failed', 'whatif']);
37
+ const FRESH = { snapshot: null, phase: 'watching', error: null, cancel: 'idle' };
38
+ function validateIds(ids) {
39
+ if (ids.length > WORKFLOW_BATCH_MAX_CELLS) {
40
+ throw new RangeError(`useWorkflowBatch: ${ids.length} workflow ids, but at most ${WORKFLOW_BATCH_MAX_CELLS} can be watched at once`);
41
+ }
42
+ const seen = new Set();
43
+ for (const id of ids) {
44
+ if (typeof id !== 'string' || id.length === 0) {
45
+ throw new TypeError('useWorkflowBatch: every workflow id must be a non-empty string');
46
+ }
47
+ if (NON_WORKFLOW_IDS.has(id)) {
48
+ throw new TypeError(`useWorkflowBatch: '${id}' is not a workflow id. A submit that resolved with it created nothing to watch`);
49
+ }
50
+ if (seen.has(id)) {
51
+ throw new TypeError(`useWorkflowBatch: workflow id '${id}' appears more than once`);
52
+ }
53
+ seen.add(id);
54
+ }
55
+ }
56
+ function summarise(cells) {
57
+ const summary = {
58
+ total: cells.length,
59
+ running: 0,
60
+ succeeded: 0,
61
+ failed: 0,
62
+ canceled: 0,
63
+ expired: 0,
64
+ stopped: 0,
65
+ realisedCost: 0,
66
+ costedCells: 0,
67
+ settled: true,
68
+ };
69
+ for (const cell of cells) {
70
+ const status = cell.snapshot?.status;
71
+ if (status === undefined || !TERMINAL.has(status)) {
72
+ summary.running += 1;
73
+ if (cell.phase === 'stopped')
74
+ summary.stopped += 1;
75
+ continue;
76
+ }
77
+ summary[status] += 1;
78
+ const total = cell.snapshot?.cost?.total;
79
+ if (typeof total === 'number' && Number.isFinite(total)) {
80
+ summary.realisedCost += total;
81
+ summary.costedCells += 1;
82
+ }
83
+ }
84
+ summary.settled = summary.running === 0;
85
+ return summary;
86
+ }
87
+ /**
88
+ * Watch a set of already-submitted workflows as one batch, with per-cell status,
89
+ * a summary, and cancel-all. For grid apps that submit N workflows one by one
90
+ * with `useBuzzWorkflow().submit()` (each with its own confirmation) and then
91
+ * have to follow all N.
92
+ *
93
+ * 🔴 THE APP OWNS THE LIST, AND THE LIST IS THE BATCH. Pass the workflow ids as
94
+ * an array: append each id as its `submit()` resolves, and persist the same
95
+ * array (it is plain strings) to re-attach after a reload by passing it back.
96
+ * There is no server-side batch id. The host knows nothing about the grouping,
97
+ * so nothing here changes what the host allows: every cell is one ordinary
98
+ * `watch()` and every cancel is one ordinary `cancel()`.
99
+ *
100
+ * What it guarantees, and the host limits behind each:
101
+ *
102
+ * - **One poll in flight per workflow.** Each cell is one `watch()` loop. When a
103
+ * loop is restarted (the list changes, `rewatch()`, a React StrictMode
104
+ * remount), the new loop waits for the old loop's in-flight poll to return
105
+ * first, so two polls for one workflow never overlap.
106
+ * - **At most {@link WORKFLOW_BATCH_MAX_CELLS} workflows.** That is the poll
107
+ * allowance (1,200 per 60 s, per block instance and viewer) divided by one
108
+ * watch's 40 polls a minute. A longer list throws.
109
+ * - **Cancel-all is paced and stops on refusal.** Cancels charge the estimate
110
+ * allowance (150 per 10 s per block instance, shared by every viewer of a page
111
+ * app). At most four are in flight. When the host answers one with a
112
+ * non-terminal snapshot (its answer when that allowance is used up), the rest
113
+ * of the call is not sent and comes back as `notAttempted`. Nothing is retried
114
+ * automatically. Call `cancelAll()` again after a few seconds.
115
+ * - **A host failure placeholder is not a workflow outcome.** When the host
116
+ * fails a poll or a cancel it answers with `{ workflowId: 'failed', status:
117
+ * 'failed' }`. `watch()` and `cancel()` resolve with that, but it says nothing
118
+ * about the workflow, which may still be running. This hook never counts it as
119
+ * a failed cell: a poll placeholder stops the cell (phase `'stopped'`, with
120
+ * `error` set), and a cancel placeholder is a `'failed'` cancel.
121
+ * - **Unmounting stops every poll.** No state updates happen after unmount. A
122
+ * `cancelAll()` already running keeps sending, because the viewer asked for it.
123
+ *
124
+ * Throws (at render) when the list is longer than
125
+ * {@link WORKFLOW_BATCH_MAX_CELLS}, has a duplicate, has an empty id, or has the
126
+ * `'failed'` / `'whatif'` placeholder a refused `submit()` resolves with.
127
+ *
128
+ * Needs the same `ai:write:budgeted` scope as `submit()`. A viewer who submitted
129
+ * already has it.
130
+ *
131
+ * @example
132
+ * const { submit } = useBuzzWorkflow();
133
+ * const [ids, setIds] = useState<string[]>(() => loadSavedIds());
134
+ * const batch = useWorkflowBatch(ids);
135
+ *
136
+ * async function runCell(body: WorkflowBody, key: string) {
137
+ * const snap = await submit(body, { idempotencyKey: key });
138
+ * if (snap.workflowId !== 'failed') setIds((prev) => [...prev, snap.workflowId]);
139
+ * }
140
+ *
141
+ * useEffect(() => saveIds(ids), [ids]);
142
+ *
143
+ * // batch.summary.running, batch.summary.realisedCost, batch.cells[i].snapshot …
144
+ * const result = await batch.cancelAll();
145
+ * if (!result.complete) showRetryCancel(result);
146
+ */
147
+ export function useWorkflowBatch(workflowIds, options) {
148
+ validateIds(workflowIds);
149
+ // A private `useBuzzWorkflow()` instance, so the app's own instance's
150
+ // `result`/`status` are not overwritten by 16 cells' updates.
151
+ const { watch, cancel } = useBuzzWorkflow();
152
+ const [states, setStates] = useState(() => new Map());
153
+ // Mirror of `states` for async code, which must not read a stale closure.
154
+ const statesRef = useRef(states);
155
+ statesRef.current = states;
156
+ const idsKey = workflowIds.join('\u0000');
157
+ const idsRef = useRef(workflowIds);
158
+ idsRef.current = workflowIds;
159
+ const optionsRef = useRef(options);
160
+ optionsRef.current = options;
161
+ const mountedRef = useRef(false);
162
+ /** Per workflow: the running loop's controller and a promise that settles when it ends. */
163
+ const loopsRef = useRef(new Map());
164
+ const cancelAllRef = useRef(null);
165
+ const patch = useCallback((workflowId, change) => {
166
+ if (!mountedRef.current)
167
+ return;
168
+ setStates((prev) => {
169
+ const next = new Map(prev);
170
+ next.set(workflowId, { ...(prev.get(workflowId) ?? FRESH), ...change });
171
+ return next;
172
+ });
173
+ }, []);
174
+ const startLoop = useCallback((workflowId) => {
175
+ const loops = loopsRef.current;
176
+ const previous = loops.get(workflowId);
177
+ previous?.controller.abort();
178
+ const controller = new AbortController();
179
+ // 🔴 CHAIN ON THE PREVIOUS LOOP. Aborting a `watch()` does not withdraw a
180
+ // poll already in flight (the host can hold it up to 15 s); the loop only
181
+ // sees the abort when that poll returns. Starting the new loop at once would
182
+ // put two polls for one workflow in flight.
183
+ const done = (previous?.done ?? Promise.resolve()).then(async () => {
184
+ if (controller.signal.aborted)
185
+ return;
186
+ try {
187
+ const final = await watch(workflowId, {
188
+ ...optionsRef.current,
189
+ signal: controller.signal,
190
+ onUpdate: (snapshot) => {
191
+ if (controller.signal.aborted || snapshot.workflowId !== workflowId)
192
+ return;
193
+ patch(workflowId, { snapshot });
194
+ },
195
+ });
196
+ if (controller.signal.aborted)
197
+ return;
198
+ if (final.workflowId !== workflowId) {
199
+ patch(workflowId, {
200
+ phase: 'stopped',
201
+ error: new Error(`useWorkflowBatch: the host answered a poll for ${workflowId} with a failure placeholder, not the workflow's status`),
202
+ });
203
+ return;
204
+ }
205
+ patch(workflowId, {
206
+ snapshot: final,
207
+ phase: TERMINAL.has(final.status) ? 'settled' : 'stopped',
208
+ error: null,
209
+ });
210
+ }
211
+ catch (err) {
212
+ if (controller.signal.aborted)
213
+ return;
214
+ patch(workflowId, { phase: 'stopped', error: err instanceof Error ? err : new Error(String(err)) });
215
+ }
216
+ });
217
+ loops.set(workflowId, { controller, done });
218
+ }, [watch, patch]);
219
+ useEffect(() => {
220
+ mountedRef.current = true;
221
+ const loops = loopsRef.current;
222
+ return () => {
223
+ mountedRef.current = false;
224
+ for (const loop of loops.values())
225
+ loop.controller.abort();
226
+ };
227
+ }, []);
228
+ // Diff the list against the running loops: start new ids, stop removed ones.
229
+ useEffect(() => {
230
+ const loops = loopsRef.current;
231
+ const wanted = new Set(idsRef.current);
232
+ for (const [id, loop] of loops) {
233
+ // Abort, but KEEP the entry: its `done` is what a re-added id's new loop
234
+ // must wait on, because the aborted loop's poll may still be in flight.
235
+ if (!wanted.has(id))
236
+ loop.controller.abort();
237
+ }
238
+ for (const id of idsRef.current) {
239
+ const loop = loops.get(id);
240
+ if (loop && !loop.controller.signal.aborted)
241
+ continue;
242
+ const state = statesRef.current.get(id);
243
+ // A settled or stopped cell stays as it is until `rewatch()`.
244
+ if (state && state.phase !== 'watching')
245
+ continue;
246
+ startLoop(id);
247
+ }
248
+ setStates((prev) => {
249
+ let changed = false;
250
+ const next = new Map(prev);
251
+ for (const id of prev.keys()) {
252
+ if (!wanted.has(id)) {
253
+ next.delete(id);
254
+ changed = true;
255
+ }
256
+ }
257
+ return changed ? next : prev;
258
+ });
259
+ // `idsKey` stands for the list's contents; the list itself is read from the ref.
260
+ }, [idsKey, startLoop]);
261
+ const rewatch = useCallback(() => {
262
+ for (const id of idsRef.current) {
263
+ if (statesRef.current.get(id)?.phase !== 'stopped')
264
+ continue;
265
+ patch(id, { phase: 'watching', error: null });
266
+ startLoop(id);
267
+ }
268
+ }, [patch, startLoop]);
269
+ const cancelAll = useCallback(() => {
270
+ if (cancelAllRef.current)
271
+ return cancelAllRef.current;
272
+ const run = async () => {
273
+ const ids = [...idsRef.current];
274
+ const outcome = new Map();
275
+ const targets = [];
276
+ for (const id of ids) {
277
+ const status = statesRef.current.get(id)?.snapshot?.status;
278
+ if (status !== undefined && TERMINAL.has(status))
279
+ outcome.set(id, 'alreadySettled');
280
+ else
281
+ targets.push(id);
282
+ }
283
+ let refusedSeen = false;
284
+ let next = 0;
285
+ const worker = async () => {
286
+ for (let id = targets[next]; id !== undefined; id = targets[next]) {
287
+ next += 1;
288
+ if (refusedSeen) {
289
+ outcome.set(id, 'notAttempted');
290
+ patch(id, { cancel: 'refused' });
291
+ continue;
292
+ }
293
+ patch(id, { cancel: 'pending' });
294
+ let snapshot;
295
+ try {
296
+ snapshot = await cancel(id);
297
+ }
298
+ catch {
299
+ outcome.set(id, 'failed');
300
+ patch(id, { cancel: 'failed' });
301
+ continue;
302
+ }
303
+ if (snapshot.workflowId !== id) {
304
+ // The host's failure placeholder: the request failed, the workflow's
305
+ // state is unknown.
306
+ outcome.set(id, 'failed');
307
+ patch(id, { cancel: 'failed' });
308
+ }
309
+ else if (TERMINAL.has(snapshot.status)) {
310
+ outcome.set(id, snapshot.status === 'canceled' ? 'canceled' : 'alreadySettled');
311
+ loopsRef.current.get(id)?.controller.abort();
312
+ patch(id, { cancel: 'done', snapshot, phase: 'settled', error: null });
313
+ }
314
+ else {
315
+ refusedSeen = true;
316
+ outcome.set(id, 'refused');
317
+ patch(id, { cancel: 'refused' });
318
+ }
319
+ }
320
+ };
321
+ await Promise.all(Array.from({ length: Math.min(CANCEL_CONCURRENCY, targets.length) }, () => worker()));
322
+ const result = {
323
+ canceled: [],
324
+ alreadySettled: [],
325
+ refused: [],
326
+ notAttempted: [],
327
+ failed: [],
328
+ complete: true,
329
+ };
330
+ for (const id of ids) {
331
+ const bucket = outcome.get(id);
332
+ if (bucket)
333
+ result[bucket].push(id);
334
+ }
335
+ result.complete =
336
+ result.refused.length === 0 && result.notAttempted.length === 0 && result.failed.length === 0;
337
+ return result;
338
+ };
339
+ const promise = run().finally(() => {
340
+ cancelAllRef.current = null;
341
+ });
342
+ cancelAllRef.current = promise;
343
+ return promise;
344
+ }, [cancel, patch]);
345
+ const cells = useMemo(() => workflowIds.map((workflowId) => ({ workflowId, ...(states.get(workflowId) ?? FRESH) })),
346
+ // `idsKey` stands for the list's contents, so a new array with the same ids
347
+ // does not rebuild the cells.
348
+ [idsKey, states]);
349
+ const summary = useMemo(() => summarise(cells), [cells]);
350
+ return { cells, summary, cancelAll, rewatch };
351
+ }
352
+ //# sourceMappingURL=useWorkflowBatch.js.map
package/dist/index.d.ts CHANGED
@@ -27,6 +27,10 @@ export { useHostOrigin } from './hooks/useHostOrigin.js';
27
27
  export type { UseHostOrigin } from './hooks/useHostOrigin.js';
28
28
  export { DEFAULT_WATCH_WAIT_SECONDS, useBuzzWorkflow, WorkflowEstimateError, WorkflowSubmitError, } from './hooks/useBuzzWorkflow.js';
29
29
  export type { SubmitWorkflowOptions, UseBuzzWorkflow, WatchWorkflowOptions, WorkflowSubmitErrorCode, } from './hooks/useBuzzWorkflow.js';
30
+ export { BATCH_ESTIMATE_MAX_CELLS, BatchEstimateError, useBatchEstimate, } from './hooks/useBatchEstimate.js';
31
+ export type { BatchEstimateCell, BatchEstimateErrorCode, BatchEstimateResult, BlockEstimateBatchAggregate, EstimateBatchOptions, UseBatchEstimate, } from './hooks/useBatchEstimate.js';
32
+ export { WORKFLOW_BATCH_MAX_CELLS, useWorkflowBatch } from './hooks/useWorkflowBatch.js';
33
+ export type { UseWorkflowBatch, WorkflowBatchCancelResult, WorkflowBatchCancelState, WorkflowBatchCell, WorkflowBatchCellPhase, WorkflowBatchOptions, WorkflowBatchSummary, } from './hooks/useWorkflowBatch.js';
30
34
  export { useEntitlements } from './hooks/useEntitlements.js';
31
35
  export type { UseEntitlements, Entitlement } from './hooks/useEntitlements.js';
32
36
  export { useGoodPurchase, GoodPurchaseRefusal } from './hooks/useGoodPurchase.js';
@@ -74,8 +78,8 @@ export type { UseRunTraining, QuotedTrainingBody, RunTrainingErrorCode, BlockRun
74
78
  export type { BlockBuzzTransaction, BlockBuzzAccount, BlockDailyCompensationResource, BlockWildcardPack, BlockWildcardPackErrorCode, AppWorkflow, AppWorkflowImage, AppWorkflowsParams, BlockGatedImage, BlockCollectionFollowErrorCode, BlockCollectionFollowResult, } from '@civitai/app-sdk/blocks';
75
79
  export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';
76
80
  export type { UseCheckpointPicker } from './hooks/useCheckpointPicker.js';
77
- export { useResourcePicker } from './hooks/useResourcePicker.js';
78
- export type { UseResourcePicker } from './hooks/useResourcePicker.js';
81
+ export { useResourcePicker, RESOURCE_PICKER_MULTIPLE_MAX } from './hooks/useResourcePicker.js';
82
+ export type { UseResourcePicker, ResourcePickerOpenOptions, ResourcePickerMultiple, } from './hooks/useResourcePicker.js';
79
83
  export { useImageUpload } from './hooks/useImageUpload.js';
80
84
  export type { UseImageUpload, UseImageUploadAsyncScan, UseImageUploadGenerationSource, UseImageUploadOptions, } from './hooks/useImageUpload.js';
81
85
  export { useUploadImageBytes } from './hooks/useUploadImageBytes.js';
@@ -108,6 +112,6 @@ export { useNestedDocument } from './hooks/useNestedDocument.js';
108
112
  export type { NestedDocumentStatus, UseNestedDocument, UseNestedDocumentOptions, } from './hooks/useNestedDocument.js';
109
113
  export { useAppStorage } from './hooks/useAppStorage.js';
110
114
  export type { AppStorageKeyEntry, AppStorageListResult, AppStorageQuota, UseAppStorage, } from './hooks/useAppStorage.js';
111
- export { useSharedStorage } from './hooks/useSharedStorage.js';
115
+ export { useSharedStorage, SharedStorageRateLimitError } from './hooks/useSharedStorage.js';
112
116
  export type { SharedAppendValue, SharedListItem, SharedListResult, UseSharedStorage, } from './hooks/useSharedStorage.js';
113
117
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -31,6 +31,8 @@ export { useBlockSettings } from './hooks/useBlockSettings.js';
31
31
  export { useBlockToken } from './hooks/useBlockToken.js';
32
32
  export { useHostOrigin } from './hooks/useHostOrigin.js';
33
33
  export { DEFAULT_WATCH_WAIT_SECONDS, useBuzzWorkflow, WorkflowEstimateError, WorkflowSubmitError, } from './hooks/useBuzzWorkflow.js';
34
+ export { BATCH_ESTIMATE_MAX_CELLS, BatchEstimateError, useBatchEstimate, } from './hooks/useBatchEstimate.js';
35
+ export { WORKFLOW_BATCH_MAX_CELLS, useWorkflowBatch } from './hooks/useWorkflowBatch.js';
34
36
  export { useEntitlements } from './hooks/useEntitlements.js';
35
37
  export { useGoodPurchase, GoodPurchaseRefusal } from './hooks/useGoodPurchase.js';
36
38
  export { useTip } from './hooks/useTip.js';
@@ -62,7 +64,7 @@ export { useCreatePostFromApp, CreatePostError, CREATE_POST_ERROR_CODES, isCreat
62
64
  export { usePrepareTrainingDataset, PrepareTrainingDatasetError, PREPARE_TRAINING_DATASET_ERROR_CODES, isPrepareTrainingDatasetErrorCode, } from './hooks/usePrepareTrainingDataset.js';
63
65
  export { useRunTraining, RunTrainingError, RUN_TRAINING_ERROR_CODES, isRunTrainingErrorCode, } from './hooks/useRunTraining.js';
64
66
  export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';
65
- export { useResourcePicker } from './hooks/useResourcePicker.js';
67
+ export { useResourcePicker, RESOURCE_PICKER_MULTIPLE_MAX } from './hooks/useResourcePicker.js';
66
68
  export { useImageUpload } from './hooks/useImageUpload.js';
67
69
  export { useUploadImageBytes } from './hooks/useUploadImageBytes.js';
68
70
  export { useGenerationResources } from './hooks/useGenerationResources.js';
@@ -84,5 +86,5 @@ export { hostToRunUrl, DIRECT_LOAD_TIMEOUT_MS } from './transport/directLoad.js'
84
86
  // `@civitai/app-sdk`'s `src/blocks/nestedDocument.ts` header.
85
87
  export { useNestedDocument } from './hooks/useNestedDocument.js';
86
88
  export { useAppStorage } from './hooks/useAppStorage.js';
87
- export { useSharedStorage } from './hooks/useSharedStorage.js';
89
+ export { useSharedStorage, SharedStorageRateLimitError } from './hooks/useSharedStorage.js';
88
90
  //# sourceMappingURL=index.js.map
@@ -46,8 +46,8 @@
46
46
  * --- BEGIN DERIVED ---
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
- * tRPC procedures ....... 28 = blocks.* (13) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 47, of which 10 REFUSE (enumerated under SCOPE below)
49
+ * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
+ * switch case labels .... 48, of which 10 REFUSE (enumerated under SCOPE below)
51
51
  * --- END DERIVED ---
52
52
  *
53
53
  * PICKERS (Phase 1 of "make dev:live a faithful local host"): the live host
@@ -83,6 +83,12 @@
83
83
  * the switch's `default: return` with no reply at all, so `useSharedStorage()`
84
84
  * hung to the 30s protocol timeout in dev:live for code that worked in dev:mock.
85
85
  *
86
+ * TRACK_EVENT: recorded only in production, so the live host forwards nothing
87
+ * and replies nothing (production replies nothing either). What it does is
88
+ * CHECK each event against the manifest passed as `options.manifest` and warn
89
+ * once per distinct problem production would silently drop — see
90
+ * `trackEventCheck.ts`.
91
+ *
86
92
  * 🔴 SCOPE — the capabilities live mode still cannot SERVE. Each REFUSES on its
87
93
  * own reply channel — `logOnce` (or `console.info`) plus an honest error, never
88
94
  * a fabricated success and never silence. Two guards stand behind this list:
@@ -186,6 +192,25 @@ export interface LiveHostOptions {
186
192
  * `dev:live` run this is omitted — the dev clicks a card in the overlay.
187
193
  */
188
194
  onPickerReady?: (handle: PickerOverlayHandle) => void;
195
+ /**
196
+ * The app's manifest — pass `block.manifest.json` as the harness imported it.
197
+ * Used for one thing: each `TRACK_EVENT` is checked against its
198
+ * `analytics.events` declaration, and the console warns ONCE per distinct
199
+ * problem production would silently drop — an undeclared event, an
200
+ * undeclared property, a value that does not match the declared type or enum.
201
+ * A declared, valid event logs nothing. The check never throws, never blocks
202
+ * the block, and sends no reply (production sends none either). Omit it and
203
+ * the first `TRACK_EVENT` says the check is off.
204
+ *
205
+ * Typed `unknown` because an imported JSON file widens its string literals
206
+ * (`"type": "enum"` reads as `string`), which a stricter type would reject.
207
+ *
208
+ * @example
209
+ * // `manifest` = your block.manifest.json, imported as JSON — every
210
+ * // `starters/examples/*` harness does this.
211
+ * createLiveHost({ blockToken, manifest });
212
+ */
213
+ manifest?: unknown;
189
214
  }
190
215
  /**
191
216
  * The reply both training bridges get in dev:live — see their handlers. One