@civitai/blocks-react 0.64.0 → 0.65.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.
@@ -41,6 +41,7 @@ import { APP_STORAGE_ERROR_REQUEST_FAILED, APP_STORAGE_ERROR_USER_QUOTA_EXCEEDED
41
41
  import { consentUnavailablePayload, isKnownBlockScope, resolveUngrantableConsentNotice, } from './consent.js';
42
42
  import { requiredStorageScope, storageResultType, storageScopeDeniedMessage, storageScopeDeniedPayload, } from './mockHostScopes.js';
43
43
  import { governsIdempotencyKey, idempotencyKeyDeniedMessage, idempotencyKeyRefusal, } from './mockHostIdempotency.js';
44
+ import { RUN_TRAINING_ERROR_CODES } from '../hooks/useRunTraining.js';
44
45
  import { hostContextWithTheme } from '../transport/transport.js';
45
46
  import { isRoutableRequestId } from '../transport/requestId.js';
46
47
  /**
@@ -79,6 +80,10 @@ const preferredAccountType = (body) => {
79
80
  return body.mode === 'inline' ? undefined : body.params.accountType;
80
81
  case 'step':
81
82
  return undefined;
83
+ // A training body names no pool either: the host's `blockTrainingBodySchema`
84
+ // is `.strict()` with no `accountType`.
85
+ case 'training':
86
+ return undefined;
82
87
  default: {
83
88
  // Exhaustiveness check: a new `WorkflowBody` member makes this assignment
84
89
  // fail to compile, right here, rather than defaulting somewhere.
@@ -121,6 +126,77 @@ const passThroughTrainingType = (body) => {
121
126
  const t = body.$type;
122
127
  return t === 'training' || t === 'imageResourceTraining' ? t : undefined;
123
128
  };
129
+ /**
130
+ * The orchestrator `$type` a body's run trains with, if it trains at all: the
131
+ * pass-through training `$type`s above, and `'training'` for a
132
+ * `kind: 'training'` body — the host builds an ai-toolkit `training` step for it.
133
+ */
134
+ const trainingStepType = (body) => body.kind === 'training' ? 'training' : passThroughTrainingType(body);
135
+ /**
136
+ * The training bounds the mock enforces — COPIES of `@civitai/app-sdk/blocks`'s
137
+ * `BLOCK_TRAINING_DATASET_MAX_ITEMS`, `BLOCK_TRAINING_CAPTION_MAX_CHARS` and
138
+ * `BLOCK_TRAINING_MAX_BUZZ_PER_RUN`, deliberately not value-imported: a value
139
+ * import of a symbol the newest PUBLISHED app-sdk lacks raises this package's
140
+ * peer floor (`tests/guards/blocks-react-peer-floor.test.mjs`). The copy is held
141
+ * to the SDK's value by `test/trainingKindFlow.test.tsx`, which imports both.
142
+ */
143
+ export const MOCK_TRAINING_BOUNDS = {
144
+ datasetMaxItems: 50,
145
+ captionMaxChars: 1000,
146
+ maxBuzzPerRun: 5000,
147
+ };
148
+ /**
149
+ * The host's `PREPARE_TRAINING_DATASET` item check, mirrored: the server's
150
+ * `blockTrainingDatasetItemsSchema` — 1…50 items, each EXACTLY `{ imageId:
151
+ * positive integer, caption: string ≤ 1000 }`. The real host runs this BEFORE any
152
+ * server call and refuses with `invalid training dataset`, so the mock does too.
153
+ */
154
+ function isValidTrainingDatasetItems(items) {
155
+ if (!Array.isArray(items))
156
+ return false;
157
+ if (items.length < 1 || items.length > MOCK_TRAINING_BOUNDS.datasetMaxItems)
158
+ return false;
159
+ return items.every((it) => {
160
+ if (typeof it !== 'object' || it === null || Array.isArray(it))
161
+ return false;
162
+ const keys = Object.keys(it);
163
+ if (keys.length !== 2 || !keys.includes('imageId') || !keys.includes('caption'))
164
+ return false;
165
+ const { imageId, caption } = it;
166
+ return (typeof imageId === 'number' &&
167
+ Number.isInteger(imageId) &&
168
+ imageId > 0 &&
169
+ typeof caption === 'string' &&
170
+ caption.length <= MOCK_TRAINING_BOUNDS.captionMaxChars);
171
+ });
172
+ }
173
+ /** The real server's refusal for a training request from a token without the spend scope. */
174
+ export const MOCK_TRAINING_SCOPE_ERROR = 'block lacks ai:write:budgeted scope';
175
+ /** The real server's refusal for an unknown, expired or spent quote on `RUN_TRAINING`. */
176
+ export const MOCK_TRAINING_QUOTE_GONE_ERROR = 'training quote not found, expired or already used — estimate again';
177
+ /** The real server's refusal for a training estimate naming a dataset it does not hold. */
178
+ export const MOCK_TRAINING_DATASET_GONE_ERROR = 'training dataset not found or expired';
179
+ /**
180
+ * The real server's two refusals for a dataset with NOTHING admitted
181
+ * (`prepareBlockTrainingDataset`): every image failed eligibility, or every
182
+ * eligible image failed its import. The server THROWS rather than returning
183
+ * `count: 0`, so a successful reply always admits at least one image.
184
+ */
185
+ export const MOCK_TRAINING_NONE_ELIGIBLE_ERROR = 'none of the requested images can be used for training';
186
+ export const MOCK_TRAINING_NONE_IMPORTED_ERROR = 'none of the requested images could be prepared for training';
187
+ /**
188
+ * The forced `RUN_TRAINING` errors that settle BEFORE the server claims (and so
189
+ * consumes) the quote: the host's own gate and dialog codes. Derived from the
190
+ * host-code list — every code except `submission-unconfirmed`, which the host
191
+ * emits only after a submit was sent. Any other forced error (a server message
192
+ * such as "Not enough Buzz for this training run.") stands for a refusal the
193
+ * server made AFTER its claim, so it consumes the quote.
194
+ */
195
+ const PRE_CLAIM_RUN_TRAINING_ERRORS = new Set(RUN_TRAINING_ERROR_CODES.filter((c) => c !== 'submission-unconfirmed'));
196
+ /** Default quote total for a `kind: 'training'` estimate when {@link MockHostOptions.trainingQuoteTotal} is unset. */
197
+ const DEFAULT_TRAINING_QUOTE_TOTAL = 500;
198
+ /** The real quote lifetime (`BLOCK_TRAINING_QUOTE_TTL_SECONDS`, 15 min). */
199
+ const TRAINING_QUOTE_TTL_MS = 15 * 60_000;
124
200
  // The canned picks carry the WIDENED BlockResourceInfo projection (PR-C) — the
125
201
  // public recommended settings a real host now returns — so dev:mock mirrors prod
126
202
  // (a picked resource seeds a weight slider + trigger words). Defaults match the
@@ -731,6 +807,13 @@ export function createMockHost(options = {}) {
731
807
  // App Blocks → Post bridge: the canned post + a forced-refusal knob.
732
808
  let createPostResult = options.createPostResult ?? DEFAULT_CREATE_POST_RESULT;
733
809
  let createPostError = options.createPostError;
810
+ // App Blocks training bridges: canned dataset rejections, quote total, and
811
+ // forced-refusal knobs. The datasets/quotes themselves live per install.
812
+ let trainingDatasetRejected = options.trainingDatasetRejected ?? [];
813
+ let trainingDatasetError = options.trainingDatasetError;
814
+ let trainingQuoteTotal = options.trainingQuoteTotal ?? DEFAULT_TRAINING_QUOTE_TOTAL;
815
+ let runTrainingError = options.runTrainingError;
816
+ let runTrainingCapRefusal = options.runTrainingCapRefusal;
734
817
  // App-subqueue bridge data + forced free-text-error knob. `appWorkflows` is
735
818
  // MUTABLE — CANCEL_APP_WORKFLOW marks the matching row canceled in place so a
736
819
  // follow-up QUERY reflects it.
@@ -882,6 +965,16 @@ export function createMockHost(options = {}) {
882
965
  let submitCount = 0;
883
966
  // body + cost remembered per workflow so the succeeded snapshot can echo them.
884
967
  const workflows = new Map();
968
+ // Training: datasets this install prepared (id → admitted count) and the
969
+ // quotes its estimates stored (id → what a run is checked against).
970
+ const trainingDatasets = new Map();
971
+ const trainingQuotes = new Map();
972
+ let trainingSerial = 0;
973
+ /** `<prefix>` + 32 hex characters — the real handles' shape. */
974
+ const trainingHandle = (prefix) => {
975
+ trainingSerial += 1;
976
+ return `${prefix}${trainingSerial.toString(16).padStart(32, '0')}`;
977
+ };
885
978
  const timers = new Set();
886
979
  const dispatchToBlock = (data) => {
887
980
  win.dispatchEvent(new MessageEvent('message', { data, origin: parentOrigin }));
@@ -911,7 +1004,7 @@ export function createMockHost(options = {}) {
911
1004
  * a checkpoint. Empty for every other body, so their snapshots are unchanged.
912
1005
  */
913
1006
  const trainingFields = (body) => {
914
- const $type = passThroughTrainingType(body);
1007
+ const $type = trainingStepType(body);
915
1008
  if (!$type)
916
1009
  return {};
917
1010
  const count = Math.max(0, Math.floor(gen.trainedEpochs ?? 0));
@@ -1159,6 +1252,53 @@ export function createMockHost(options = {}) {
1159
1252
  });
1160
1253
  return;
1161
1254
  }
1255
+ // TRAINING ESTIMATE — KIND-FAITHFUL, the one exception to the
1256
+ // kind-agnostic rule above, because the real arm is not a priced
1257
+ // pass-through: it needs the spend scope, names a dataset the server
1258
+ // must hold, refuses a price above the per-run ceiling, and STORES a
1259
+ // quote the run is later checked against. Each refusal is the
1260
+ // server's message in the host's `failureSnapshot` shape.
1261
+ if (body.kind === 'training') {
1262
+ const refuse = (error) => dispatchToBlock({
1263
+ type: 'ESTIMATE_RESULT',
1264
+ payload: { requestId, snapshot: { workflowId: 'failed', status: 'failed', error } },
1265
+ });
1266
+ if (!consentGranted)
1267
+ return refuse(MOCK_TRAINING_SCOPE_ERROR);
1268
+ const imageCount = trainingDatasets.get(body.datasetId);
1269
+ if (imageCount === undefined)
1270
+ return refuse(MOCK_TRAINING_DATASET_GONE_ERROR);
1271
+ const total = trainingQuoteTotal;
1272
+ if (total > MOCK_TRAINING_BOUNDS.maxBuzzPerRun) {
1273
+ return refuse(`this training run costs ${total} Buzz, above the per-run limit of ${MOCK_TRAINING_BOUNDS.maxBuzzPerRun} for apps`);
1274
+ }
1275
+ const quoteId = trainingHandle('tq_');
1276
+ const expiresAtMs = Date.now() + TRAINING_QUOTE_TTL_MS;
1277
+ trainingQuotes.set(quoteId, {
1278
+ datasetId: body.datasetId,
1279
+ total,
1280
+ expiresAtMs,
1281
+ spent: false,
1282
+ });
1283
+ dispatchToBlock({
1284
+ type: 'ESTIMATE_RESULT',
1285
+ payload: {
1286
+ requestId,
1287
+ snapshot: {
1288
+ workflowId: 'wf_estimate',
1289
+ status: 'pending',
1290
+ cost: { total },
1291
+ trainingQuote: {
1292
+ quoteId,
1293
+ total,
1294
+ imageCount,
1295
+ expiresAt: new Date(expiresAtMs).toISOString(),
1296
+ },
1297
+ },
1298
+ },
1299
+ });
1300
+ return;
1301
+ }
1162
1302
  dispatchToBlock({
1163
1303
  type: 'ESTIMATE_RESULT',
1164
1304
  payload: {
@@ -1182,6 +1322,26 @@ export function createMockHost(options = {}) {
1182
1322
  // the largest-wallet stamp. No recipe-registry validation.
1183
1323
  submitCount += 1;
1184
1324
  const body = typed.payload?.body ?? {};
1325
+ // A `kind: 'training'` body is never started by SUBMIT_WORKFLOW: the
1326
+ // server charges a training run only against a quote a signed-in
1327
+ // session confirmed, which only RUN_TRAINING's dialog records. The
1328
+ // server's own refusal, in the host's `failureSnapshot` shape.
1329
+ // (`useBuzzWorkflow().submit()` refuses before sending; this covers a
1330
+ // raw transport send.)
1331
+ if (body.kind === 'training') {
1332
+ dispatchToBlock({
1333
+ type: 'WORKFLOW_SUBMITTED',
1334
+ payload: {
1335
+ requestId,
1336
+ snapshot: {
1337
+ workflowId: 'failed',
1338
+ status: 'failed',
1339
+ error: 'this training run has not been confirmed by the viewer in this session',
1340
+ },
1341
+ },
1342
+ });
1343
+ return;
1344
+ }
1185
1345
  const cost = costFor(body);
1186
1346
  // CAUGHT-SERVER-EXCEPTION simulation (`generation.failSubmitException`).
1187
1347
  // Reproduces the host's `failureSnapshot(err)` byte-for-byte: the
@@ -1633,6 +1793,108 @@ export function createMockHost(options = {}) {
1633
1793
  });
1634
1794
  return;
1635
1795
  }
1796
+ case 'PREPARE_TRAINING_DATASET': {
1797
+ // The host's gate order (`prepareTrainingDatasetGate.ts`): no
1798
+ // requestId → drop; signed-out → `sign in to train`; items outside
1799
+ // the server's schema → `invalid training dataset`, before any call.
1800
+ // Then the server: the spend scope, then the dataset.
1801
+ if (!isRoutableRequestId(requestId))
1802
+ return;
1803
+ const reply = (payload) => dispatchToBlock({ type: 'TRAINING_DATASET_RESULT', payload: { requestId, ...payload } });
1804
+ if (viewer === null)
1805
+ return reply({ error: 'sign in to train' });
1806
+ const items = typed.payload?.items;
1807
+ if (!isValidTrainingDatasetItems(items)) {
1808
+ return reply({ error: 'invalid training dataset' });
1809
+ }
1810
+ if (!consentGranted)
1811
+ return reply({ error: MOCK_TRAINING_SCOPE_ERROR });
1812
+ if (trainingDatasetError !== undefined)
1813
+ return reply({ error: trainingDatasetError });
1814
+ const named = new Set(items.map((it) => it.imageId));
1815
+ const rejected = trainingDatasetRejected
1816
+ .filter((r) => named.has(r.imageId))
1817
+ .map(({ imageId, reason }) => ({ imageId, reason }));
1818
+ const rejectedIds = new Set(rejected.map((r) => r.imageId));
1819
+ const count = [...named].filter((id) => !rejectedIds.has(id)).length;
1820
+ if (count === 0) {
1821
+ // The server THROWS here, eligibility before import: an `import-*`
1822
+ // rejection means that image PASSED eligibility, so the server
1823
+ // reached the import and refuses there.
1824
+ const anyImported = rejected.some((r) => r.reason.startsWith('import-'));
1825
+ return reply({
1826
+ error: anyImported
1827
+ ? MOCK_TRAINING_NONE_IMPORTED_ERROR
1828
+ : MOCK_TRAINING_NONE_ELIGIBLE_ERROR,
1829
+ });
1830
+ }
1831
+ const datasetId = trainingHandle('tds_');
1832
+ trainingDatasets.set(datasetId, count);
1833
+ const result = { datasetId, count, rejected };
1834
+ return reply({ result });
1835
+ }
1836
+ case 'RUN_TRAINING': {
1837
+ // The host's gate (`runTrainingGate.ts`): no requestId → drop;
1838
+ // signed-out → `sign in to train`; not a `kind:'training'` body with
1839
+ // a `quoteId` → `invalid training request`. Then the server: the
1840
+ // spend scope, then the quote (unknown, expired or already spent).
1841
+ if (!isRoutableRequestId(requestId))
1842
+ return;
1843
+ const reply = (payload) => dispatchToBlock({ type: 'TRAINING_RESULT', payload: { requestId, ...payload } });
1844
+ if (viewer === null)
1845
+ return reply({ error: 'sign in to train' });
1846
+ const raw = typed.payload?.body;
1847
+ const b = raw && typeof raw === 'object' && !Array.isArray(raw)
1848
+ ? raw
1849
+ : null;
1850
+ if (!b || b.kind !== 'training' || typeof b.quoteId !== 'string' || !b.quoteId) {
1851
+ return reply({ error: 'invalid training request' });
1852
+ }
1853
+ if (!consentGranted)
1854
+ return reply({ error: MOCK_TRAINING_SCOPE_ERROR });
1855
+ const quote = trainingQuotes.get(b.quoteId);
1856
+ if (!quote || quote.spent || Date.now() >= quote.expiresAtMs) {
1857
+ return reply({ error: MOCK_TRAINING_QUOTE_GONE_ERROR });
1858
+ }
1859
+ // Which outcomes consume the quote, modelled on the real flow: the
1860
+ // host's gate and consent dialog come first (quote kept); the
1861
+ // server's submit then claims the quote with a GETDEL
1862
+ // (`claimTrainingQuote`) BEFORE any of its own checks, so every
1863
+ // server outcome — a refusal, a body mismatch, a cap refusal,
1864
+ // `submission-unconfirmed`, a run — consumes it. The mock's check
1865
+ // ORDER below is its own; only the spent/kept split is the server's.
1866
+ if (runTrainingError !== undefined) {
1867
+ if (!PRE_CLAIM_RUN_TRAINING_ERRORS.has(runTrainingError))
1868
+ quote.spent = true;
1869
+ return reply({ error: runTrainingError });
1870
+ }
1871
+ quote.spent = true;
1872
+ if (b.datasetId !== quote.datasetId) {
1873
+ return reply({
1874
+ error: 'the training body differs from the one that was quoted — estimate again',
1875
+ });
1876
+ }
1877
+ // The server's cap / availability refusals RESOLVE (refunded, no run
1878
+ // started) — after the quote was consumed above.
1879
+ if (runTrainingCapRefusal !== undefined) {
1880
+ return reply({
1881
+ snapshot: {
1882
+ workflowId: 'failed',
1883
+ status: 'failed',
1884
+ cost: { total: quote.total },
1885
+ error: runTrainingCapRefusal,
1886
+ },
1887
+ });
1888
+ }
1889
+ submitCount += 1;
1890
+ const workflowId = `wf_${submitCount}_${Date.now()}`;
1891
+ workflows.set(workflowId, {
1892
+ polls: 0,
1893
+ cost: quote.total,
1894
+ body: raw,
1895
+ });
1896
+ return reply({ snapshot: { workflowId, status: 'pending' } });
1897
+ }
1636
1898
  case 'PUBLISH_GENERATION_OUTPUTS': {
1637
1899
  // Publish selected outputs of one of the app's OWN workflows as bare,
1638
1900
  // real-scanned public Image rows. Drop a request with no requestId
@@ -2347,6 +2609,19 @@ export function createMockHost(options = {}) {
2347
2609
  createPostResult = patch.createPostResult;
2348
2610
  if (patch.createPostError !== undefined)
2349
2611
  createPostError = patch.createPostError;
2612
+ if (patch.trainingDatasetRejected !== undefined) {
2613
+ trainingDatasetRejected = patch.trainingDatasetRejected;
2614
+ }
2615
+ // The training error knobs clear on an explicit `undefined` (key PRESENT in
2616
+ // the patch), so a harness can turn a refusal off without re-installing.
2617
+ if ('trainingDatasetError' in patch)
2618
+ trainingDatasetError = patch.trainingDatasetError;
2619
+ if (patch.trainingQuoteTotal !== undefined)
2620
+ trainingQuoteTotal = patch.trainingQuoteTotal;
2621
+ if ('runTrainingError' in patch)
2622
+ runTrainingError = patch.runTrainingError;
2623
+ if ('runTrainingCapRefusal' in patch)
2624
+ runTrainingCapRefusal = patch.runTrainingCapRefusal;
2350
2625
  if (patch.appWorkflows !== undefined) {
2351
2626
  appWorkflows = {
2352
2627
  workflows: patch.appWorkflows.workflows,
@@ -28,6 +28,25 @@ export declare const DEFAULT_REQUEST_TIMEOUT_MS = 30000;
28
28
  * no deep hook paths, so the rename touches no reachable API.
29
29
  */
30
30
  export declare const HUMAN_INTERACTION_TIMEOUT_MS: number;
31
+ /**
32
+ * The bound for a `'protocol'` request whose SERVER WORK is itself slow — the
33
+ * host forwards it to the orchestrator and replies only once that answers. No
34
+ * person is in the loop, so it stays in the `'protocol'` bucket below; it just
35
+ * needs more than {@link DEFAULT_REQUEST_TIMEOUT_MS}.
36
+ *
37
+ * Callers: the workflow trio in `hooks/useBuzzWorkflow.ts` (a submit is a whatif
38
+ * preflight plus the real submit plus a prompt audit), and
39
+ * `PREPARE_TRAINING_DATASET` in `hooks/usePrepareTrainingDataset.ts`, whose
40
+ * server moderates the captions and imports up to 50 images into the
41
+ * orchestrator, the import alone under a 60s budget
42
+ * (`BLOCK_TRAINING_IMPORT_BUDGET_MS` in civitai/civitai's
43
+ * `block-training-dataset.service.ts`) — so the 30s default would reject a
44
+ * healthy prepare mid-import.
45
+ *
46
+ * Was a module-local constant in `useBuzzWorkflow.ts`; moved here so the second
47
+ * caller shares the number instead of re-typing it.
48
+ */
49
+ export declare const WORKFLOW_REQUEST_TIMEOUT_MS = 120000;
31
50
  /** Which bound a block→parent message is answered under. */
32
51
  export type RequestTimeoutClass =
33
52
  /** Reply waits on a PERSON — must pass {@link HUMAN_INTERACTION_TIMEOUT_MS}. */
@@ -27,6 +27,25 @@ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
27
27
  * no deep hook paths, so the rename touches no reachable API.
28
28
  */
29
29
  export const HUMAN_INTERACTION_TIMEOUT_MS = 10 * 60_000;
30
+ /**
31
+ * The bound for a `'protocol'` request whose SERVER WORK is itself slow — the
32
+ * host forwards it to the orchestrator and replies only once that answers. No
33
+ * person is in the loop, so it stays in the `'protocol'` bucket below; it just
34
+ * needs more than {@link DEFAULT_REQUEST_TIMEOUT_MS}.
35
+ *
36
+ * Callers: the workflow trio in `hooks/useBuzzWorkflow.ts` (a submit is a whatif
37
+ * preflight plus the real submit plus a prompt audit), and
38
+ * `PREPARE_TRAINING_DATASET` in `hooks/usePrepareTrainingDataset.ts`, whose
39
+ * server moderates the captions and imports up to 50 images into the
40
+ * orchestrator, the import alone under a 60s budget
41
+ * (`BLOCK_TRAINING_IMPORT_BUDGET_MS` in civitai/civitai's
42
+ * `block-training-dataset.service.ts`) — so the 30s default would reject a
43
+ * healthy prepare mid-import.
44
+ *
45
+ * Was a module-local constant in `useBuzzWorkflow.ts`; moved here so the second
46
+ * caller shares the number instead of re-typing it.
47
+ */
48
+ export const WORKFLOW_REQUEST_TIMEOUT_MS = 120_000;
30
49
  /**
31
50
  * 🔴 THE TOTALITY GATE — every block→parent message type, bucketed.
32
51
  *
@@ -69,6 +88,12 @@ const REQUEST_TIMEOUT_CLASS = {
69
88
  // failure, with nothing to reconcile the two. Unlike the publish bridge there
70
89
  // is no "outputs were lost" framing available here: the post exists.
71
90
  CREATE_POST_FROM_APP: 'human',
91
+ // 🔴 `'human'`: the host opens a training CONSENT dialog (price, base model,
92
+ // length, dataset size) and replies only on the viewer's click or dismiss. At
93
+ // the 30s default the request would reject with the dialog still open, and a
94
+ // viewer who then clicked Train would start a CHARGED run the block had
95
+ // already reported as failed.
96
+ RUN_TRAINING: 'human',
72
97
  // ── Fire-and-forget: no requestId, so no pending promise to time out ────
73
98
  BLOCK_ERROR: 'no-reply',
74
99
  BLOCK_HELLO: 'no-reply',
@@ -97,6 +122,11 @@ const REQUEST_TIMEOUT_CLASS = {
97
122
  APP_STORAGE_SET: 'protocol',
98
123
  CANCEL_APP_WORKFLOW: 'protocol',
99
124
  CANCEL_WORKFLOW: 'protocol',
125
+ // No dialog — preparing a dataset charges nothing and the host replies when
126
+ // the server does. Server-bound, so `'protocol'`; but sent under
127
+ // `WORKFLOW_REQUEST_TIMEOUT_MS`, not the 30s default, because the server's
128
+ // image import alone may take 60s. Same reasoning as the workflow trio below.
129
+ PREPARE_TRAINING_DATASET: 'protocol',
100
130
  // The workflow trio is server-bound, not person-bound. It carries its OWN
101
131
  // longer bound (`WORKFLOW_REQUEST_TIMEOUT_MS` in `hooks/useBuzzWorkflow.ts`)
102
132
  // for orchestrator latency — a different reason for a different number.
@@ -399,6 +399,33 @@ export declare function isValidCollectionFollowResult(p: unknown): boolean;
399
399
  * so a drop at least times out visibly.
400
400
  */
401
401
  export declare function isValidCreatePostResult(p: unknown): boolean;
402
+ /**
403
+ * Reply to `PREPARE_TRAINING_DATASET`. A well-formed reply carries EITHER a
404
+ * `result` (`{ datasetId: non-empty string, count: integer ≥ 0, rejected: [{
405
+ * imageId: number, reason: string }] }`) OR an `error`; one with neither is
406
+ * malformed and dropped.
407
+ *
408
+ * 🔴 `error` IS SHAPE-CHECKED ONLY — the same decision as
409
+ * {@link isValidCreatePostResult}: the host's closed codes share the field with
410
+ * any server message it forwards, and a membership check would drop those
411
+ * replies before correlation, leaving the request to its timer.
412
+ *
413
+ * `rejected[].reason` is checked as a STRING, not against today's six members —
414
+ * the host adding a reason must not make every prepare reply that carries it a
415
+ * dropped one (the forward-compatibility rule in {@link isValidWorkflowSnapshot}).
416
+ */
417
+ export declare function isValidTrainingDatasetResult(p: unknown): boolean;
418
+ /**
419
+ * Reply to `RUN_TRAINING`. A well-formed reply carries EITHER a `snapshot` (a
420
+ * valid {@link isValidWorkflowSnapshot} — the block polls its `workflowId`) OR an
421
+ * `error`; one with neither is malformed and dropped.
422
+ *
423
+ * 🔴 `error` IS SHAPE-CHECKED ONLY, and here a membership check would cost the
424
+ * most: this request carries the TEN-MINUTE consent bound, and the host forwards
425
+ * server refusals (an expired quote, a changed body, an ineligible image) in this
426
+ * field. A dropped refusal would leave a Train button spinning for ten minutes.
427
+ */
428
+ export declare function isValidTrainingResult(p: unknown): boolean;
402
429
  /**
403
430
  * Reply to `APP_STORAGE_GET`. `value` is arbitrary JSON (incl. `null` for an
404
431
  * unset key / anon viewer), so there is nothing to type-check on it — the guard
@@ -315,18 +315,47 @@ export function isValidWorkflowSnapshot(s) {
315
315
  return false;
316
316
  }
317
317
  }
318
- if (s.trainedEpochs !== undefined) {
319
- if (!Array.isArray(s.trainedEpochs))
320
- return false;
321
- for (const e of s.trainedEpochs) {
322
- if (!isObject(e) || typeof e.$type !== 'string')
323
- return false;
324
- if (!Number.isInteger(e.epochNumber))
325
- return false;
326
- }
327
- }
318
+ if (s.trainedEpochs !== undefined && !isValidTrainedEpochs(s.trainedEpochs))
319
+ return false;
328
320
  if (s.publishedModel !== undefined && !isValidPublishedModel(s.publishedModel))
329
321
  return false;
322
+ if (s.trainingQuote !== undefined && !isValidTrainingQuote(s.trainingQuote))
323
+ return false;
324
+ return true;
325
+ }
326
+ /**
327
+ * Shape check for `trainingQuote` — the quote a `kind: 'training'` estimate
328
+ * returns. The block passes `quoteId` back verbatim and renders `total`, so both
329
+ * are pinned: a non-empty string, a finite number. `quoteId`'s `tq_…` FORMAT is
330
+ * deliberately not pinned — it is opaque, and a host changing it must not turn
331
+ * every estimate reply into a dropped one.
332
+ */
333
+ function isValidTrainingQuote(q) {
334
+ if (!isObject(q))
335
+ return false;
336
+ if (!isNonEmptyString(q.quoteId))
337
+ return false;
338
+ if (!isFiniteNumber(q.total))
339
+ return false;
340
+ if (!Number.isInteger(q.imageCount) || q.imageCount < 0)
341
+ return false;
342
+ return typeof q.expiresAt === 'string';
343
+ }
344
+ /**
345
+ * Shape check for `trainedEpochs` — shared by {@link isValidWorkflowSnapshot}
346
+ * and the `AppWorkflow` row guard, which carry the same field. `$type` is
347
+ * checked as a STRING, not against today's members (see the forward-
348
+ * compatibility note in {@link isValidWorkflowSnapshot}).
349
+ */
350
+ function isValidTrainedEpochs(epochs) {
351
+ if (!Array.isArray(epochs))
352
+ return false;
353
+ for (const e of epochs) {
354
+ if (!isObject(e) || typeof e.$type !== 'string')
355
+ return false;
356
+ if (!Number.isInteger(e.epochNumber))
357
+ return false;
358
+ }
330
359
  return true;
331
360
  }
332
361
  /**
@@ -906,9 +935,11 @@ function isValidAppWorkflow(w) {
906
935
  if (img.nsfwLevel !== null && !isFiniteNumber(img.nsfwLevel))
907
936
  return false;
908
937
  }
909
- // OPTIONAL — absent on hosts that predate it; checked only when present.
938
+ // OPTIONAL — absent on hosts that predate them; checked only when present.
910
939
  if (w.publishedModel !== undefined && !isValidPublishedModel(w.publishedModel))
911
940
  return false;
941
+ if (w.trainedEpochs !== undefined && !isValidTrainedEpochs(w.trainedEpochs))
942
+ return false;
912
943
  return true;
913
944
  }
914
945
  /**
@@ -1246,6 +1277,71 @@ export function isValidCreatePostResult(p) {
1246
1277
  return false;
1247
1278
  return true;
1248
1279
  }
1280
+ /**
1281
+ * Reply to `PREPARE_TRAINING_DATASET`. A well-formed reply carries EITHER a
1282
+ * `result` (`{ datasetId: non-empty string, count: integer ≥ 0, rejected: [{
1283
+ * imageId: number, reason: string }] }`) OR an `error`; one with neither is
1284
+ * malformed and dropped.
1285
+ *
1286
+ * 🔴 `error` IS SHAPE-CHECKED ONLY — the same decision as
1287
+ * {@link isValidCreatePostResult}: the host's closed codes share the field with
1288
+ * any server message it forwards, and a membership check would drop those
1289
+ * replies before correlation, leaving the request to its timer.
1290
+ *
1291
+ * `rejected[].reason` is checked as a STRING, not against today's six members —
1292
+ * the host adding a reason must not make every prepare reply that carries it a
1293
+ * dropped one (the forward-compatibility rule in {@link isValidWorkflowSnapshot}).
1294
+ */
1295
+ export function isValidTrainingDatasetResult(p) {
1296
+ if (!isObject(p))
1297
+ return false;
1298
+ if (!isWireRequestIdShape(p.requestId))
1299
+ return false;
1300
+ if (p.error !== undefined && typeof p.error !== 'string')
1301
+ return false;
1302
+ if (p.result !== undefined) {
1303
+ const r = p.result;
1304
+ if (!isObject(r))
1305
+ return false;
1306
+ if (!isNonEmptyString(r.datasetId))
1307
+ return false;
1308
+ if (!Number.isInteger(r.count) || r.count < 0)
1309
+ return false;
1310
+ if (!Array.isArray(r.rejected))
1311
+ return false;
1312
+ for (const x of r.rejected) {
1313
+ if (!isObject(x) || !isFiniteNumber(x.imageId) || typeof x.reason !== 'string') {
1314
+ return false;
1315
+ }
1316
+ }
1317
+ }
1318
+ if (p.result === undefined && p.error === undefined)
1319
+ return false;
1320
+ return true;
1321
+ }
1322
+ /**
1323
+ * Reply to `RUN_TRAINING`. A well-formed reply carries EITHER a `snapshot` (a
1324
+ * valid {@link isValidWorkflowSnapshot} — the block polls its `workflowId`) OR an
1325
+ * `error`; one with neither is malformed and dropped.
1326
+ *
1327
+ * 🔴 `error` IS SHAPE-CHECKED ONLY, and here a membership check would cost the
1328
+ * most: this request carries the TEN-MINUTE consent bound, and the host forwards
1329
+ * server refusals (an expired quote, a changed body, an ineligible image) in this
1330
+ * field. A dropped refusal would leave a Train button spinning for ten minutes.
1331
+ */
1332
+ export function isValidTrainingResult(p) {
1333
+ if (!isObject(p))
1334
+ return false;
1335
+ if (!isWireRequestIdShape(p.requestId))
1336
+ return false;
1337
+ if (p.error !== undefined && typeof p.error !== 'string')
1338
+ return false;
1339
+ if (p.snapshot !== undefined && !isValidWorkflowSnapshot(p.snapshot))
1340
+ return false;
1341
+ if (p.snapshot === undefined && p.error === undefined)
1342
+ return false;
1343
+ return true;
1344
+ }
1249
1345
  // ============================================================
1250
1346
  // App-storage (per-viewer KV) reply validators
1251
1347
  // ============================================================
@@ -1789,6 +1885,13 @@ export function payloadValidatorFor(type) {
1789
1885
  // `validate.test.ts`.
1790
1886
  case 'CREATE_POST_RESULT':
1791
1887
  return isValidCreatePostResult;
1888
+ // Same caveat as CREATE_POST_RESULT: presence is compiler-enforced, the
1889
+ // identity is pinned by test (`validate.test.ts`). TRAINING_RESULT settles a
1890
+ // charged run.
1891
+ case 'TRAINING_DATASET_RESULT':
1892
+ return isValidTrainingDatasetResult;
1893
+ case 'TRAINING_RESULT':
1894
+ return isValidTrainingResult;
1792
1895
  case 'IMAGE_UPLOAD_RESULT':
1793
1896
  return isValidImageUploadResult;
1794
1897
  case 'IMAGE_SCAN_RESOLVED':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/blocks-react",
3
- "version": "0.64.0",
3
+ "version": "0.65.0",
4
4
  "description": "React hooks and iframe transport for Civitai Apps. Pairs with @civitai/app-sdk/blocks.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,8 +37,8 @@
37
37
  "node": ">=20"
38
38
  },
39
39
  "dependencies": {
40
- "@civitai/components": "^0.9.2",
41
- "@civitai/theme": "^0.5.2"
40
+ "@civitai/theme": "^0.5.2",
41
+ "@civitai/components": "^0.9.2"
42
42
  },
43
43
  "comment-peerDependencies": "This floor is DERIVED, not chosen — the lowest published app-sdk that exports every symbol this package imports. Do not edit it without reading ./PEER_FLOOR.md (in-repo, unpublished) and tests/guards/blocks-react-peer-floor.test.mjs.",
44
44
  "peerDependencies": {
@@ -58,7 +58,7 @@
58
58
  "typescript": "^5.9.2",
59
59
  "vite": "^8.0.14",
60
60
  "vitest": "^4.1.11",
61
- "@civitai/app-sdk": "^0.57.0"
61
+ "@civitai/app-sdk": "^0.58.0"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public"