@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.
@@ -0,0 +1,212 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
3
+ import { getTransport } from '../transport/singleton.js';
4
+ import { RequestTimeoutError, sendTypedRequest } from '../transport/transport.js';
5
+ /**
6
+ * The host's CLOSED refusal codes for `RUN_TRAINING`, as a runtime list. Mirrors
7
+ * civitai/civitai's `RUN_TRAINING_HOST_ERRORS`.
8
+ *
9
+ * πŸ”΄ THE ERROR CHANNEL IS NOT AN ENUM: server messages (an expired or used quote,
10
+ * a body that differs from the quoted one) arrive in the same field β€” see
11
+ * {@link isRunTrainingErrorCode}.
12
+ */
13
+ export const RUN_TRAINING_ERROR_CODES = [
14
+ 'review-mode',
15
+ 'block is not ready',
16
+ 'sign in to train',
17
+ 'invalid training request',
18
+ 'no block token',
19
+ 'declined',
20
+ 'submission-unconfirmed',
21
+ ];
22
+ const CODE_SET = new Set(RUN_TRAINING_ERROR_CODES);
23
+ /**
24
+ * The `workflowId` the host's training submit stamps on a snapshot that is NOT a
25
+ * run β€” its cap refusals resolve `{ workflowId: 'failed', status: 'failed', cost,
26
+ * error }` (`submitTrainingWorkflow` in civitai/civitai `blocks.router.ts`).
27
+ * Compared with `===`: a real orchestrator id is a workflow that may exist.
28
+ */
29
+ const HOST_REFUSAL_WORKFLOW_ID = 'failed';
30
+ /** `true` when `error` is one of the host's closed refusal codes rather than a server message. */
31
+ export function isRunTrainingErrorCode(error) {
32
+ return CODE_SET.has(error);
33
+ }
34
+ /**
35
+ * A training run that did not come back with a snapshot.
36
+ *
37
+ * πŸ”΄ READ THE FLAGS BEFORE SAYING ANYTHING ABOUT MONEY β€” they are the only part of
38
+ * this error with a money meaning, and they point in opposite directions:
39
+ *
40
+ * - `.declined` β€” the viewer dismissed the consent dialog. NO RUN WAS
41
+ * SUBMITTED, guaranteed by the host's consent latch. Render nothing.
42
+ * - `.unconfirmed` β€” the run MAY BE RUNNING AND CHARGED: the host sent the
43
+ * submit and could not learn its outcome (`submission-unconfirmed`), or no
44
+ * reply arrived at all (`.timedOut` β€” the viewer may have clicked Train on a
45
+ * dialog this block had stopped waiting for). Do NOT retry automatically and
46
+ * do NOT tell the viewer it failed: check their trainings first
47
+ * (`useAppWorkflows()` lists this app's runs), because re-running the same
48
+ * body after the server did start it is a SECOND, separately charged run.
49
+ * - `.refused` (`code: 'refused'`) β€” the server REFUSED the submit at a spend
50
+ * cap after the viewer confirmed (the viewer's daily or private-run Buzz cap,
51
+ * the per-app consent budget, the app's daily spend or rate limit, a dev
52
+ * session cap). Its reservation was refunded: NO RUN, nothing charged.
53
+ * `.message` is the server's reason (e.g. `daily Buzz cap reached: …`) and
54
+ * `.snapshot` the refusal it arrived in. Retrying needs a new estimate and
55
+ * usually a later time; buying Buzz does not lift these caps.
56
+ * - `.signInRequired` β€” no session. Route into `useRequestSignIn()`.
57
+ * - anything else β€” a refusal before any submit (an expired or used quote, a
58
+ * body that no longer matches its quote, an ineligible image). Estimate again
59
+ * before retrying; `.message` is the host's or server's text.
60
+ */
61
+ export class RunTrainingError extends Error {
62
+ /**
63
+ * The closed host refusal code, `'refused'` for a cap refusal, or `undefined`
64
+ * for a server/transport error.
65
+ */
66
+ code;
67
+ /**
68
+ * The server refused the submit (a spend cap, or a temporary-availability deny)
69
+ * and refunded it β€” no run exists. The quote is used up: estimate again.
70
+ * See the class docs; `.message` is the server's reason.
71
+ */
72
+ refused;
73
+ /** The refusal snapshot the host replied with, when {@link refused}. */
74
+ snapshot;
75
+ /** The viewer dismissed the consent dialog β€” no run was submitted. */
76
+ declined;
77
+ /**
78
+ * The run may exist and may have been charged β€” `submission-unconfirmed`, or a
79
+ * transport timeout. Check the viewer's trainings before retrying.
80
+ */
81
+ unconfirmed;
82
+ /** No reply arrived within the 10-minute consent bound. Implies {@link unconfirmed}. */
83
+ timedOut;
84
+ /** There is no session (`sign in to train`, or a bare `UNAUTHORIZED`). */
85
+ signInRequired;
86
+ constructor(error, opts) {
87
+ super(error);
88
+ this.name = 'RunTrainingError';
89
+ this.timedOut = opts?.timedOut === true;
90
+ this.refused = opts?.refusedSnapshot !== undefined;
91
+ if (opts?.refusedSnapshot !== undefined) {
92
+ this.code = 'refused';
93
+ this.snapshot = opts.refusedSnapshot;
94
+ }
95
+ else if (isRunTrainingErrorCode(error)) {
96
+ this.code = error;
97
+ }
98
+ this.declined = error === 'declined';
99
+ this.unconfirmed = this.timedOut || error === 'submission-unconfirmed';
100
+ this.signInRequired = error === 'sign in to train' || error === 'UNAUTHORIZED';
101
+ }
102
+ }
103
+ /**
104
+ * Run an App Blocks `kind: 'training'` LoRA training β€” the only way one starts β€”
105
+ * through the host-mediated `RUN_TRAINING` β†’ `TRAINING_RESULT` bridge.
106
+ *
107
+ * πŸ”΄ AVAILABILITY: page apps only, behind the host flag `app-blocks-training-kind`
108
+ * (ships off), with `ai:write:budgeted` declared and granted by a signed-in
109
+ * viewer. Refused from `dev:live` and from review sessions β€” build the flow
110
+ * against the mock host. See `WorkflowBodyTraining` in `@civitai/app-sdk`.
111
+ *
112
+ * The flow: `usePrepareTrainingDataset()` β†’ `useBuzzWorkflow().estimate(body)`
113
+ * (its `trainingQuote.quoteId`) β†’ `runTraining({ ...body, quoteId })` β†’
114
+ * `useBuzzWorkflow().watch(workflowId)`. Change nothing in the body between the
115
+ * estimate and the run: the server checks it against the quoted one.
116
+ *
117
+ * πŸ”΄ THE CONSENT DIALOG IS CIVITAI'S, AND IT IS THE CHARGE. Every number on it is
118
+ * read back from the server's stored quote β€” never from your body. A run may cost
119
+ * more than the token's per-call budget (the viewer confirms the exact price), up
120
+ * to `BLOCK_TRAINING_MAX_BUZZ_PER_RUN` (5,000 Buzz). Images are the viewer's own.
121
+ *
122
+ * πŸ”΄ NO AUTOMATIC CONSENT RETRY, unlike the other consent-gated hooks β€” on
123
+ * purpose. `RUN_TRAINING` carries no idempotency key and has an outcome
124
+ * (`submission-unconfirmed`) where a run may exist, so this hook never re-sends.
125
+ * It is also unreachable in practice: a block holding a `quoteId` got it from an
126
+ * estimate that already required `ai:write:budgeted`.
127
+ *
128
+ * Sent under the 10-minute human-interaction bound: the host replies only when
129
+ * the viewer clicks or dismisses its dialog.
130
+ *
131
+ * @example
132
+ * const { estimate, watch } = useBuzzWorkflow();
133
+ * const { runTraining } = useRunTraining();
134
+ * const quote = (await estimate(body)).trainingQuote!;
135
+ * try {
136
+ * const started = await runTraining({ ...body, quoteId: quote.quoteId });
137
+ * await watch(started.workflowId, { onUpdate: render });
138
+ * } catch (e) {
139
+ * if (!(e instanceof RunTrainingError)) throw e;
140
+ * if (e.declined) return; // no run β€” say nothing
141
+ * if (e.signInRequired) return requestSignIn();
142
+ * if (e.unconfirmed) return showCheckYourTrainings(); // may be running β€” never auto-retry
143
+ * if (e.refused) return showError(e.message); // a cap / availability refusal; no run, nothing charged
144
+ * showError('Could not start training. Get a new price and try again.');
145
+ * }
146
+ */
147
+ export function useRunTraining() {
148
+ const [pending, setPending] = useState(false);
149
+ const [error, setError] = useState(null);
150
+ // This request can outlive the component by up to ten minutes (the dialog may
151
+ // stay open), so a `pending` toggled on an unmounted control is state nobody
152
+ // can clear.
153
+ const mountedRef = useRef(true);
154
+ useEffect(() => {
155
+ mountedRef.current = true;
156
+ return () => {
157
+ mountedRef.current = false;
158
+ };
159
+ }, []);
160
+ const runTraining = useCallback(async (body) => {
161
+ if (mountedRef.current) {
162
+ setPending(true);
163
+ setError(null);
164
+ }
165
+ try {
166
+ const reply = await sendTypedRequest(getTransport(), { type: 'RUN_TRAINING', payload: { body } }, 'TRAINING_RESULT', { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS }).catch((err) => {
167
+ if (err instanceof RequestTimeoutError) {
168
+ throw new RunTrainingError(err.message, { timedOut: true });
169
+ }
170
+ throw err instanceof Error ? err : new Error(String(err));
171
+ });
172
+ if (reply.error || !reply.snapshot) {
173
+ // `||`, not `??`: `error: ''` is a shape-valid reply. Its fallback is the
174
+ // CAUTIOUS code β€” with neither a snapshot nor a reason, nothing says a
175
+ // run was not started, so the block must not be told it was safe to
176
+ // retry.
177
+ throw new RunTrainingError(reply.error || 'submission-unconfirmed');
178
+ }
179
+ // πŸ”΄ A RESOLVED SNAPSHOT IS NOT ALWAYS A RUN. The host's training submit
180
+ // answers its spend-cap and temporary-availability refusals (refunded,
181
+ // nothing started) with a
182
+ // resolved `{ workflowId: 'failed', status: 'failed', cost, error }`.
183
+ // Resolving that would hand the caller a workflow id to `watch()` that
184
+ // names nothing, and drop the reason. Exact match on the host's
185
+ // sentinel: a failed snapshot with a REAL id is a workflow that may
186
+ // exist, and keeps resolving so it can be watched.
187
+ if (reply.snapshot.workflowId === HOST_REFUSAL_WORKFLOW_ID &&
188
+ reply.snapshot.status === 'failed') {
189
+ throw new RunTrainingError(reply.snapshot.error || 'training run refused', {
190
+ refusedSnapshot: reply.snapshot,
191
+ });
192
+ }
193
+ return reply.snapshot;
194
+ }
195
+ catch (err) {
196
+ const wrapped = err instanceof RunTrainingError
197
+ ? err
198
+ : new RunTrainingError(err instanceof Error ? err.message : String(err));
199
+ if (mountedRef.current)
200
+ setError(wrapped);
201
+ // Thrown even when unmounted: the caller's `await` must still learn the
202
+ // outcome of a possibly-charged run.
203
+ throw wrapped;
204
+ }
205
+ finally {
206
+ if (mountedRef.current)
207
+ setPending(false);
208
+ }
209
+ }, []);
210
+ return { runTraining, pending, error };
211
+ }
212
+ //# sourceMappingURL=useRunTraining.js.map
package/dist/index.d.ts CHANGED
@@ -67,6 +67,10 @@ export { useCollectionFollow, CollectionFollowError, COLLECTION_FOLLOW_ERROR_COD
67
67
  export type { UseCollectionFollow } from './hooks/useCollectionFollow.js';
68
68
  export { useCreatePostFromApp, CreatePostError, CREATE_POST_ERROR_CODES, isCreatePostErrorCode, } from './hooks/useCreatePostFromApp.js';
69
69
  export type { UseCreatePostFromApp, BlockPostSource, BlockCreatePostRequest, BlockCreatePostResult, BlockCreatePostHostError, } from './hooks/useCreatePostFromApp.js';
70
+ export { usePrepareTrainingDataset, PrepareTrainingDatasetError, PREPARE_TRAINING_DATASET_ERROR_CODES, isPrepareTrainingDatasetErrorCode, } from './hooks/usePrepareTrainingDataset.js';
71
+ export type { UsePrepareTrainingDataset, BlockPrepareTrainingDatasetHostError, BlockTrainingDatasetItem, BlockTrainingDatasetResult, BlockTrainingRejectionReason, } from './hooks/usePrepareTrainingDataset.js';
72
+ export { useRunTraining, RunTrainingError, RUN_TRAINING_ERROR_CODES, isRunTrainingErrorCode, } from './hooks/useRunTraining.js';
73
+ export type { UseRunTraining, QuotedTrainingBody, RunTrainingErrorCode, BlockRunTrainingHostError, BlockTrainingQuote, WorkflowBodyTraining, } from './hooks/useRunTraining.js';
70
74
  export type { BlockBuzzTransaction, BlockBuzzAccount, BlockDailyCompensationResource, BlockWildcardPack, BlockWildcardPackErrorCode, AppWorkflow, AppWorkflowImage, AppWorkflowsParams, BlockGatedImage, BlockCollectionFollowErrorCode, BlockCollectionFollowResult, } from '@civitai/app-sdk/blocks';
71
75
  export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';
72
76
  export type { UseCheckpointPicker } from './hooks/useCheckpointPicker.js';
package/dist/index.js CHANGED
@@ -59,6 +59,8 @@ export { RequestTimeoutError } from './transport/transport.js';
59
59
  export { InvalidIdempotencyKeyError } from './transport/transport.js';
60
60
  export { useCollectionFollow, CollectionFollowError, COLLECTION_FOLLOW_ERROR_CODES, isCollectionFollowErrorCode, } from './hooks/useCollectionFollow.js';
61
61
  export { useCreatePostFromApp, CreatePostError, CREATE_POST_ERROR_CODES, isCreatePostErrorCode, } from './hooks/useCreatePostFromApp.js';
62
+ export { usePrepareTrainingDataset, PrepareTrainingDatasetError, PREPARE_TRAINING_DATASET_ERROR_CODES, isPrepareTrainingDatasetErrorCode, } from './hooks/usePrepareTrainingDataset.js';
63
+ export { useRunTraining, RunTrainingError, RUN_TRAINING_ERROR_CODES, isRunTrainingErrorCode, } from './hooks/useRunTraining.js';
62
64
  export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';
63
65
  export { useResourcePicker } from './hooks/useResourcePicker.js';
64
66
  export { useImageUpload } from './hooks/useImageUpload.js';
@@ -47,7 +47,7 @@
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
49
  * tRPC procedures ....... 28 = blocks.* (13) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 45, of which 8 REFUSE (enumerated under SCOPE below)
50
+ * switch case labels .... 47, 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
@@ -101,6 +101,12 @@
101
101
  * host-chrome consent. Replies `collection-unavailable`.
102
102
  * β€’ CREATE_POST_FROM_APP β€” needs the server-resolved preview plus host-chrome
103
103
  * confirm before a PUBLIC post is written. Replies with a refusal.
104
+ * β€’ PREPARE_TRAINING_DATASET β€” the server refuses every `kind: 'training'`
105
+ * request from a dev token, so there is nothing to forward. Replies with a
106
+ * refusal.
107
+ * β€’ RUN_TRAINING β€” the same dev-token refusal, plus the host-chrome consent
108
+ * dialog a run is confirmed in, which this harness does not have. Replies
109
+ * with a refusal.
104
110
  * β€’ PUBLISH_GENERATION_OUTPUTS β€” publishing requires the viewer's signed-in
105
111
  * civitai.com session, which the local harness does not have; test
106
112
  * publishing against the mock host β€” `createMockHost` or `Harness` from
@@ -181,6 +187,11 @@ export interface LiveHostOptions {
181
187
  */
182
188
  onPickerReady?: (handle: PickerOverlayHandle) => void;
183
189
  }
190
+ /**
191
+ * The reply both training bridges get in dev:live β€” see their handlers. One
192
+ * string so the two cannot drift.
193
+ */
194
+ export declare const TRAINING_UNSUPPORTED_ERROR = "training is not supported in dev:live \u2014 use dev:mock";
184
195
  /**
185
196
  * Decoded payload of a block-token JWT (the claims `BlockTokenService.sign`
186
197
  * stamps β€” see civitai `block-token.service.ts`). Everything is optional/unknown
@@ -47,7 +47,7 @@
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
49
  * tRPC procedures ....... 28 = blocks.* (13) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 45, of which 8 REFUSE (enumerated under SCOPE below)
50
+ * switch case labels .... 47, 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
@@ -101,6 +101,12 @@
101
101
  * host-chrome consent. Replies `collection-unavailable`.
102
102
  * β€’ CREATE_POST_FROM_APP β€” needs the server-resolved preview plus host-chrome
103
103
  * confirm before a PUBLIC post is written. Replies with a refusal.
104
+ * β€’ PREPARE_TRAINING_DATASET β€” the server refuses every `kind: 'training'`
105
+ * request from a dev token, so there is nothing to forward. Replies with a
106
+ * refusal.
107
+ * β€’ RUN_TRAINING β€” the same dev-token refusal, plus the host-chrome consent
108
+ * dialog a run is confirmed in, which this harness does not have. Replies
109
+ * with a refusal.
104
110
  * β€’ PUBLISH_GENERATION_OUTPUTS β€” publishing requires the viewer's signed-in
105
111
  * civitai.com session, which the local harness does not have; test
106
112
  * publishing against the mock host β€” `createMockHost` or `Harness` from
@@ -130,6 +136,15 @@ const DEFAULT_BACKEND_BASE_URL = 'https://civitai.com';
130
136
  const PAGE_SLOT_ID = 'app.page';
131
137
  /** The budgeted-spend scope the workflow procedures require. */
132
138
  const BUDGETED_SCOPE = 'ai:write:budgeted';
139
+ /**
140
+ * The reply both training bridges get in dev:live β€” see their handlers. One
141
+ * string so the two cannot drift.
142
+ */
143
+ export const TRAINING_UNSUPPORTED_ERROR = 'training is not supported in dev:live β€” use dev:mock';
144
+ /** The once-per-session console line for a refused training bridge. */
145
+ const trainingUnsupportedLog = (type) => `${type} is not supported in dev:live (the server refuses every training request from a ` +
146
+ 'dev token, and a run needs the host-chrome consent dialog). Replying with a refusal. ' +
147
+ 'Use dev:mock to exercise the training flow.';
133
148
  /**
134
149
  * Decode the middle (payload) segment of a JWT (base64url) WITHOUT verifying
135
150
  * the signature β€” client-side we only need the claims to build BLOCK_INIT; the
@@ -967,6 +982,44 @@ export function createLiveHost(options) {
967
982
  });
968
983
  return;
969
984
  }
985
+ case 'PREPARE_TRAINING_DATASET': {
986
+ // UNSUPPORTED in live, and here the SERVER decides it, not this
987
+ // harness: every `kind: 'training'` procedure refuses a dev token
988
+ // ("training is not available from a development or review session"
989
+ // β€” `assertTrainingRequestAllowed` in civitai/civitai), because there
990
+ // is no host page whose chrome can show the viewer a price. A
991
+ // training ESTIMATE is still forwarded (ESTIMATE_WORKFLOW above) and
992
+ // comes back carrying that same server refusal.
993
+ //
994
+ // FREE TEXT on the message's own reply channel; no host code is
995
+ // honest about "this harness cannot do it". Use dev:mock β€” its
996
+ // `trainingDatasetError` / `trainingDatasetRejected` knobs cover the
997
+ // refusal set.
998
+ if (!isRoutableRequestId(requestId))
999
+ return;
1000
+ logOnce('training-dataset', trainingUnsupportedLog('PREPARE_TRAINING_DATASET'));
1001
+ dispatchToBlock({
1002
+ type: 'TRAINING_DATASET_RESULT',
1003
+ payload: { requestId, error: TRAINING_UNSUPPORTED_ERROR },
1004
+ });
1005
+ return;
1006
+ }
1007
+ case 'RUN_TRAINING': {
1008
+ // UNSUPPORTED in live for the same server-side reason as
1009
+ // PREPARE_TRAINING_DATASET directly above β€” and doubly so: a run is
1010
+ // CHARGED and confirmed in host chrome this harness does not have, so
1011
+ // faking either half would let dev prove out a flow production does
1012
+ // not have. Use dev:mock β€” its `runTrainingError` knob covers
1013
+ // `declined`, `submission-unconfirmed` and the rest.
1014
+ if (!isRoutableRequestId(requestId))
1015
+ return;
1016
+ logOnce('training-run', trainingUnsupportedLog('RUN_TRAINING'));
1017
+ dispatchToBlock({
1018
+ type: 'TRAINING_RESULT',
1019
+ payload: { requestId, error: TRAINING_UNSUPPORTED_ERROR },
1020
+ });
1021
+ return;
1022
+ }
970
1023
  case 'GET_WILDCARD_PACK': {
971
1024
  // UNSUPPORTED in live v1 (honest-by-design, like OPEN_BUZZ_PURCHASE):
972
1025
  // the real host resolves the pack via a SESSION-authed proc and does
@@ -37,7 +37,7 @@
37
37
  * called). Only the postMessage bridge round-trips are exercised. Never import
38
38
  * this from production code.
39
39
  */
40
- import { type BlockContext, type BlockResourceInfo, type BlockResourcePickerType, type BlockUploadedImageInfo, type BlockGenerationSourceImageInfo, type BuzzAccountType, type BlockBuzzTransaction, type BlockBuzzAccount, type BlockDailyCompensationResource, type BlockViewer, type BlockWildcardPack, type BlockWildcardPackErrorCode, type AppWorkflow, type BlockPublishedModel, type BlockGatedImage, type BlockCollectionFollowErrorCode, type BlockCreatePostHostError, type BlockCreatePostResult, type ColorDomain, type SharedStorageValue, type Theme, type ViewerInfo, type WorkflowBody } from '@civitai/app-sdk/blocks';
40
+ import { type BlockContext, type BlockResourceInfo, type BlockResourcePickerType, type BlockUploadedImageInfo, type BlockGenerationSourceImageInfo, type BuzzAccountType, type BlockBuzzTransaction, type BlockBuzzAccount, type BlockDailyCompensationResource, type BlockViewer, type BlockWildcardPack, type BlockWildcardPackErrorCode, type AppWorkflow, type BlockPublishedModel, type BlockGatedImage, type BlockCollectionFollowErrorCode, type BlockCreatePostHostError, type BlockCreatePostResult, type BlockPrepareTrainingDatasetHostError, type BlockRunTrainingHostError, type BlockTrainingRejectionReason, type ColorDomain, type SharedStorageValue, type Theme, type ViewerInfo, type WorkflowBody } from '@civitai/app-sdk/blocks';
41
41
  /**
42
42
  * How submits behave. `'none'` = everything succeeds; `'all'` / `'insufficient'`
43
43
  * = every submit RESOLVES an insufficient-Buzz `failed` snapshot, priced
@@ -179,10 +179,13 @@ export interface MockGenerationScenario {
179
179
  */
180
180
  images?: string[] | ((req: WorkflowBody) => string[]);
181
181
  /**
182
- * How many trained epochs a succeeded PASS-THROUGH training step reports on
182
+ * How many trained epochs a succeeded training run reports on
183
183
  * {@link BlockWorkflowSnapshot.trainedEpochs} β€” epochs `1…n`, each tagged with
184
- * the submitted `$type`. Applies only to a `{ kind: 'step', $type: 'training' }`
185
- * or `'imageResourceTraining'` body; every other body is unaffected.
184
+ * the run's `$type`. Applies only to a PASS-THROUGH `{ kind: 'step', $type:
185
+ * 'training' }` / `'imageResourceTraining'` body (tagged with that `$type`) and
186
+ * to a `kind: 'training'` run started through `RUN_TRAINING` (tagged
187
+ * `'training'`, the step the host builds for it); every other body is
188
+ * unaffected.
186
189
  *
187
190
  * Default `0`: the field is omitted, as the host omits it for a run that
188
191
  * produced no checkpoint. Opt in with `n > 0` to simulate a finished run β€”
@@ -200,6 +203,11 @@ export interface MockGenerationScenario {
200
203
  * NOT simulated: the mock neither quotes nor times out, so a success here
201
204
  * says nothing about whether your run is quoted, what it costs, or whether
202
205
  * it finishes.
206
+ *
207
+ * Snapshot-only: the app-queue read (`QUERY_APP_WORKFLOWS`) replies with the
208
+ * canned {@link MockHostOptions.appWorkflows} rows verbatim β€” they carry no
209
+ * body, so the mock cannot tell a training row from any other. To exercise
210
+ * {@link AppWorkflow.trainedEpochs}, put it on the rows you pass there.
203
211
  */
204
212
  trainedEpochs?: number;
205
213
  /**
@@ -207,10 +215,38 @@ export interface MockGenerationScenario {
207
215
  * {@link BlockWorkflowSnapshot.publishedModel} β€” simulating a run the viewer
208
216
  * has started (`published: false`) or finished (`published: true`) publishing
209
217
  * through the model wizard. Default: unset (field omitted). For the app-queue
210
- * read, put `publishedModel` on the rows you pass as `appWorkflows`.
218
+ * read, put `publishedModel` (and `trainedEpochs`) on the rows you pass as
219
+ * `appWorkflows`.
211
220
  */
212
221
  trainingPublishedModel?: BlockPublishedModel;
213
222
  }
223
+ /**
224
+ * The training bounds the mock enforces β€” COPIES of `@civitai/app-sdk/blocks`'s
225
+ * `BLOCK_TRAINING_DATASET_MAX_ITEMS`, `BLOCK_TRAINING_CAPTION_MAX_CHARS` and
226
+ * `BLOCK_TRAINING_MAX_BUZZ_PER_RUN`, deliberately not value-imported: a value
227
+ * import of a symbol the newest PUBLISHED app-sdk lacks raises this package's
228
+ * peer floor (`tests/guards/blocks-react-peer-floor.test.mjs`). The copy is held
229
+ * to the SDK's value by `test/trainingKindFlow.test.tsx`, which imports both.
230
+ */
231
+ export declare const MOCK_TRAINING_BOUNDS: {
232
+ readonly datasetMaxItems: 50;
233
+ readonly captionMaxChars: 1000;
234
+ readonly maxBuzzPerRun: 5000;
235
+ };
236
+ /** The real server's refusal for a training request from a token without the spend scope. */
237
+ export declare const MOCK_TRAINING_SCOPE_ERROR = "block lacks ai:write:budgeted scope";
238
+ /** The real server's refusal for an unknown, expired or spent quote on `RUN_TRAINING`. */
239
+ export declare const MOCK_TRAINING_QUOTE_GONE_ERROR = "training quote not found, expired or already used \u2014 estimate again";
240
+ /** The real server's refusal for a training estimate naming a dataset it does not hold. */
241
+ export declare const MOCK_TRAINING_DATASET_GONE_ERROR = "training dataset not found or expired";
242
+ /**
243
+ * The real server's two refusals for a dataset with NOTHING admitted
244
+ * (`prepareBlockTrainingDataset`): every image failed eligibility, or every
245
+ * eligible image failed its import. The server THROWS rather than returning
246
+ * `count: 0`, so a successful reply always admits at least one image.
247
+ */
248
+ export declare const MOCK_TRAINING_NONE_ELIGIBLE_ERROR = "none of the requested images can be used for training";
249
+ export declare const MOCK_TRAINING_NONE_IMPORTED_ERROR = "none of the requested images could be prepared for training";
214
250
  /**
215
251
  * BUZZ scenario controls β€” simulate a balance so the insufficient-Buzz / top-up
216
252
  * UX is exercisable. The mock host treats `balance` as a spendable wallet:
@@ -619,6 +655,82 @@ export interface MockHostOptions {
619
655
  * exercised by `declined`, never by the timing or the content.
620
656
  */
621
657
  createPostError?: BlockCreatePostHostError | string;
658
+ /**
659
+ * Images `PREPARE_TRAINING_DATASET` leaves out, with their reasons β€” what
660
+ * `usePrepareTrainingDataset()` reports on `rejected`. Only entries whose
661
+ * `imageId` the request actually named are reported, and `count` is the rest,
662
+ * exactly as the server derives it. Absent β†’ every image is admitted.
663
+ * Live-tunable via {@link MockHost.setScenario}.
664
+ *
665
+ * When this rejects EVERY named image the reply is an `error`, as on the real
666
+ * server β€” {@link MOCK_TRAINING_NONE_ELIGIBLE_ERROR}, or
667
+ * {@link MOCK_TRAINING_NONE_IMPORTED_ERROR} when every image that passed
668
+ * eligibility was rejected with an `import-*` reason. A success never has
669
+ * `count: 0`.
670
+ *
671
+ * KIND-FAITHFUL, like the rest of the training path: the mock holds the
672
+ * datasets it prepared, an estimate naming any other `datasetId` fails as the
673
+ * server's does, a quote is run at most once, and every training call needs
674
+ * `ai:write:budgeted` on the token ({@link MockHostOptions.consentGranted}) and
675
+ * a signed-in viewer. What it cannot model: image ownership and moderation (it
676
+ * has no images), the flag, and the page-only / dev-token refusals.
677
+ *
678
+ * ⚠️ The quote↔body check is NARROWER than the server's: the mock compares only
679
+ * `datasetId`, while the server hashes the whole body (minus `quoteId`) and
680
+ * refuses any change. A body edited between estimate and run passes here and
681
+ * fails in production.
682
+ */
683
+ trainingDatasetRejected?: Array<{
684
+ imageId: number;
685
+ reason: BlockTrainingRejectionReason;
686
+ }>;
687
+ /**
688
+ * Force `PREPARE_TRAINING_DATASET` to reply with an `error` β€” a
689
+ * {@link BlockPrepareTrainingDatasetHostError} for a host refusal, or any other
690
+ * string for the free-text server-message variant. Absent β†’ it prepares.
691
+ * Live-tunable via {@link MockHost.setScenario}.
692
+ */
693
+ trainingDatasetError?: BlockPrepareTrainingDatasetHostError | string;
694
+ /**
695
+ * The Buzz total a `kind: 'training'` estimate quotes. Default `500`. Above
696
+ * `BLOCK_TRAINING_MAX_BUZZ_PER_RUN` (5,000) the estimate fails exactly as the
697
+ * server's does. The run's terminal snapshot reports this as its `cost`.
698
+ * Live-tunable via {@link MockHost.setScenario}.
699
+ */
700
+ trainingQuoteTotal?: number;
701
+ /**
702
+ * Force `RUN_TRAINING` to reply with an `error` instead of starting a run β€” a
703
+ * {@link BlockRunTrainingHostError} (`'declined'`: the viewer dismissed the
704
+ * dialog, no run; `'submission-unconfirmed'`: the run MAY exist) or any other
705
+ * string for a server message. Absent β†’ the run starts.
706
+ *
707
+ * Whether the QUOTE survives follows the real flow: the host's own gate and
708
+ * dialog codes (`declined`, `review-mode`, `block is not ready`, `sign in to
709
+ * train`, `invalid training request`, `no block token`) settle before the
710
+ * server claims the quote, so it stays runnable; `submission-unconfirmed` and
711
+ * any server message settle after the claim, so it is used up β€” re-running it
712
+ * gets the quote-gone error. Live-tunable via {@link MockHost.setScenario};
713
+ * `undefined` clears it.
714
+ *
715
+ * πŸ”΄ Same real gap as {@link createPostError}: the consent dialog is HOST
716
+ * chrome, so the mock settles immediately where the real host waits on a click.
717
+ */
718
+ runTrainingError?: BlockRunTrainingHostError | string;
719
+ /**
720
+ * Make `RUN_TRAINING` resolve the server's spend-cap or temporary-availability
721
+ * refusal instead of a run:
722
+ * a snapshot `{ workflowId: 'failed', status: 'failed', cost: { total: <quote> },
723
+ * error: <this string> }` β€” the shape the real training submit returns when
724
+ * the viewer's daily / private-run Buzz cap, the per-app consent budget, the
725
+ * app's spend or rate limit (or its "temporarily unavailable" deny), or a
726
+ * dev-session cap stops the run (refunded, no run). `useRunTraining()` rejects
727
+ * it with `.refused`. Pass the server's text, e.g. `'daily Buzz cap reached: …'`.
728
+ * πŸ”΄ The quote is CONSUMED, as on the server (it is claimed before the cap
729
+ * checks): running the same `quoteId` again gets the quote-gone error β€” estimate
730
+ * again. Absent β†’ the run starts. Live-tunable via {@link MockHost.setScenario};
731
+ * `setScenario({ runTrainingCapRefusal: undefined })` clears it.
732
+ */
733
+ runTrainingCapRefusal?: string;
622
734
  /**
623
735
  * Buzz pools a `SUBMIT_WORKFLOW` must REJECT when named in `body.accountType`
624
736
  * β€” simulates the real backend's content-rating clamp. The real host throws a
@@ -783,7 +895,7 @@ export interface MockHostOptions {
783
895
  * {@link MockHost.setTheme}, which pushes a real `THEME_CHANGE` β€” the mock of
784
896
  * the viewer toggling dark mode with the block already mounted.)
785
897
  */
786
- export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'createPostResult' | 'createPostError' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes'>;
898
+ export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'createPostResult' | 'createPostError' | 'trainingDatasetRejected' | 'trainingDatasetError' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes'>;
787
899
  /** Runtime Buzz-balance handle exposed on {@link MockHost.buzz}. */
788
900
  export interface MockBuzzHandle {
789
901
  /** Current simulated balance, or `undefined` when balance isn't simulated. */