@civitai/blocks-react 0.70.0 → 0.72.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.
@@ -39,11 +39,14 @@
39
39
  */
40
40
  import { APP_STORAGE_ERROR_REQUEST_FAILED, APP_STORAGE_ERROR_USER_QUOTA_EXCEEDED, APP_STORAGE_ERROR_USER_ROW_LIMIT, APP_STORAGE_ERROR_VALUE_TOO_LARGE, APP_STORAGE_MAX_BYTES, APP_STORAGE_MAX_ROWS, APP_STORAGE_MAX_VALUE_BYTES, BrowsingLevel, SFW_LEVELS, } from '@civitai/app-sdk/blocks';
41
41
  import { consentUnavailablePayload, isKnownBlockScope, resolveUngrantableConsentNotice, } from './consent.js';
42
+ import { PAGE_CHECKPOINT_PERSIST_ERROR, emulatesPageHost } from './hostSurface.js';
42
43
  import { requiredStorageScope, storageResultType, storageScopeDeniedMessage, storageScopeDeniedPayload, } from './mockHostScopes.js';
43
44
  import { governsIdempotencyKey, idempotencyKeyDeniedMessage, idempotencyKeyRefusal, } from './mockHostIdempotency.js';
44
45
  import { SAVE_IMAGE_INVALID_REQUEST_ERROR, prepareSaveBytes, saveImageRequestKind, } from './saveBytes.js';
45
46
  import { UPLOAD_BYTES_INVALID_ERROR, admitUploadBytes, resolveUploadBytesRequest, } from './uploadBytes.js';
47
+ import { mockUnpricedWorkflowRefusal, mockUnquotedWorkflowEstimateRefusal, mockWorkflowBodyRefusal, mockWorkflowStepGenerations, mockWorkflowStepName, } from './mockWorkflowGraph.js';
46
48
  import { BATCH_ESTIMATE_CELL_TIMEOUT_ERROR } from '../hooks/useBatchEstimate.js';
49
+ import { RUN_CONFIRMED_STEP_ERROR_CODES, STEP_QUOTE_ID_PATTERN, } from '../hooks/useRunConfirmedStep.js';
47
50
  import { RUN_TRAINING_ERROR_CODES } from '../hooks/useRunTraining.js';
48
51
  import { hostContextWithTheme } from '../transport/transport.js';
49
52
  import { isRoutableRequestId } from '../transport/requestId.js';
@@ -87,6 +90,9 @@ const preferredAccountType = (body) => {
87
90
  // is `.strict()` with no `accountType`.
88
91
  case 'training':
89
92
  return undefined;
93
+ // Nor does a multi-step body: the kind has no `accountType` field.
94
+ case 'workflow':
95
+ return undefined;
90
96
  default: {
91
97
  // Exhaustiveness check: a new `WorkflowBody` member makes this assignment
92
98
  // fail to compile, right here, rather than defaulting somewhere.
@@ -215,6 +221,152 @@ const MOCK_TRAINING_IN_BATCH_ERROR = 'a training estimate cannot be part of a ba
215
221
  const DEFAULT_TRAINING_QUOTE_TOTAL = 500;
216
222
  /** The real quote lifetime (`BLOCK_TRAINING_QUOTE_TTL_SECONDS`, 15 min). */
217
223
  const TRAINING_QUOTE_TTL_MS = 15 * 60_000;
224
+ /**
225
+ * The per-job ceiling of a confirmed step — a COPY of `@civitai/app-sdk/blocks`'s
226
+ * `BLOCK_CONFIRMED_STEP_MAX_BUZZ_PER_JOB`, not value-imported for the reason
227
+ * given on {@link MOCK_TRAINING_BOUNDS}. Pinned to the SDK's value by
228
+ * `test/confirmedStepFlow.test.tsx`, which imports both.
229
+ */
230
+ export const MOCK_CONFIRMED_STEP_MAX_BUZZ_PER_JOB = 5000;
231
+ /** The real quote lifetime (`BLOCK_QUOTE_GRANT_TTL_SECONDS`, 15 min) — the training quote's, shared. */
232
+ const STEP_QUOTE_TTL_MS = TRAINING_QUOTE_TTL_MS;
233
+ /**
234
+ * The real server's refusal when the host reads back a step quote it does not
235
+ * hold — unknown, expired, or already used by an earlier run. This is the
236
+ * PREVIEW's message, which is the one `RUN_CONFIRMED_STEP` reaches first.
237
+ */
238
+ export const MOCK_STEP_QUOTE_GONE_ERROR = 'step quote not found or expired';
239
+ /** The real server's refusal for a confirmed run whose body is not the quoted one. */
240
+ export const MOCK_STEP_BODY_MISMATCH_ERROR = 'the step body differs from the one that was quoted — estimate again';
241
+ /**
242
+ * The real server's refusal for a `quoteId` body sent as a plain
243
+ * `SUBMIT_WORKFLOW`: nothing but `RUN_CONFIRMED_STEP`'s dialog records the
244
+ * viewer's confirmation.
245
+ */
246
+ export const MOCK_STEP_NOT_CONFIRMED_ERROR = 'this step has not been confirmed by the viewer in this session';
247
+ /**
248
+ * The forced `RUN_CONFIRMED_STEP` errors that settle BEFORE the server claims
249
+ * (and so consumes) the quote: the host's own gate, dialog and daily-limit
250
+ * codes. Derived from the host-code list — every code except
251
+ * `submission-unconfirmed`, which the host emits only after a submit was sent.
252
+ */
253
+ const PRE_CLAIM_RUN_CONFIRMED_STEP_ERRORS = new Set(RUN_CONFIRMED_STEP_ERROR_CODES.filter((c) => c !== 'submission-unconfirmed'));
254
+ /**
255
+ * The orchestrator's training `$type`s, which never run as a confirmed step —
256
+ * matched CASE-INSENSITIVELY, as the server refuses a caller-supplied `$type`
257
+ * (`isTrainingStepTypeIgnoringCase`): `Training` or `IMAGERESOURCETRAINING` is
258
+ * refused like the canonical spelling. Only for refusing a submitted `$type`;
259
+ * the trained-epochs simulation keys on the exact spelling, as the host does.
260
+ */
261
+ const TRAINING_STEP_TYPES_FOLDED = new Set([
262
+ 'training',
263
+ 'imageresourcetraining',
264
+ ]);
265
+ const isTrainingStepTypeName = (stepType) => TRAINING_STEP_TYPES_FOLDED.has(stepType.toLowerCase());
266
+ /**
267
+ * The orchestrator `$type` of a PASS-THROUGH step body, else `undefined`. Keyed
268
+ * on the VALUE of `step`, as the host keys it — a registered step names a
269
+ * `step` id and is a different arm.
270
+ */
271
+ const passThroughStepType = (body) => {
272
+ const b = body;
273
+ return b.kind === 'step' && b.step === undefined && typeof b.$type === 'string'
274
+ ? b.$type
275
+ : undefined;
276
+ };
277
+ /**
278
+ * The real server's refusal for a TRAINING `$type` (`training`,
279
+ * `imageResourceTraining`) on the confirmed-step path — at the estimate, where a
280
+ * quote would otherwise be granted, and at the confirmed submit. Training runs
281
+ * have their own kind.
282
+ */
283
+ export const MOCK_STEP_TRAINING_TYPE_ERROR = "a training step type cannot run as a confirmed step — use kind:'training'";
284
+ /**
285
+ * The fixed PREFIX of the real server's refusal for a confirmed-step body whose
286
+ * `input` is not plain JSON. The full message continues with the offending path
287
+ * and what was found there (`body.input.when is a Date`), which varies — match on
288
+ * this prefix.
289
+ */
290
+ export const MOCK_STEP_NON_JSON_INPUT_ERROR_PREFIX = 'a confirmed step needs plain JSON input (objects, arrays, strings, finite numbers, booleans, null) — ';
291
+ /** Thrown by {@link canonicalStepJson} for a value that is not plain JSON. */
292
+ class NonJsonStepBodyError extends Error {
293
+ }
294
+ /** How the server names a non-JSON value in its refusal. */
295
+ const describeNonJson = (value) => {
296
+ if (value === undefined)
297
+ return 'undefined';
298
+ if (typeof value === 'number')
299
+ return `the non-finite number ${String(value)}`;
300
+ if (typeof value === 'object' && value !== null) {
301
+ const name = Object.getPrototypeOf(value)
302
+ ?.constructor?.name;
303
+ return name ? `a ${name}` : 'a non-plain object';
304
+ }
305
+ return `a ${typeof value}`;
306
+ };
307
+ /**
308
+ * The canonical form the server hashes a quoted body in, mirrored: JSON with
309
+ * object keys sorted at EVERY depth, so two bodies with the same values compare
310
+ * equal however they were written. EXACT, OR IT THROWS — only plain objects,
311
+ * arrays, strings, finite numbers, booleans and `null` are accepted. A `Date`,
312
+ * `Map`, `Set`, class instance, `BigInt`, `NaN`/`Infinity`, an `undefined`
313
+ * array element or an empty slot in a sparse array throws {@link NonJsonStepBodyError} naming the path, because a
314
+ * looser form would give two different such values the same bytes. An object
315
+ * key whose value is `undefined` is skipped, as JSON drops it.
316
+ */
317
+ const canonicalStepJson = (value, path) => {
318
+ if (value === null)
319
+ return 'null';
320
+ if (typeof value === 'string' || typeof value === 'boolean')
321
+ return JSON.stringify(value);
322
+ if (typeof value === 'number') {
323
+ if (!Number.isFinite(value)) {
324
+ throw new NonJsonStepBodyError(`${path} is ${describeNonJson(value)}`);
325
+ }
326
+ return JSON.stringify(value);
327
+ }
328
+ if (Array.isArray(value)) {
329
+ // BY INDEX, not `map`: `map` and `join` skip a hole, so a sparse array would
330
+ // bind as if its empty slots were not there. The server refuses a hole.
331
+ const entries = [];
332
+ for (let i = 0; i < value.length; i += 1) {
333
+ if (!(i in value))
334
+ throw new NonJsonStepBodyError(`${path}[${i}] is an empty array slot`);
335
+ entries.push(canonicalStepJson(value[i], `${path}[${i}]`));
336
+ }
337
+ return `[${entries.join(',')}]`;
338
+ }
339
+ if (typeof value === 'object') {
340
+ const proto = Object.getPrototypeOf(value);
341
+ if (proto !== Object.prototype && proto !== null) {
342
+ throw new NonJsonStepBodyError(`${path} is ${describeNonJson(value)}`);
343
+ }
344
+ const record = value;
345
+ const entries = Object.keys(record)
346
+ .filter((k) => record[k] !== undefined)
347
+ .sort()
348
+ .map((k) => `${JSON.stringify(k)}:${canonicalStepJson(record[k], `${path}.${k}`)}`);
349
+ return `{${entries.join(',')}}`;
350
+ }
351
+ throw new NonJsonStepBodyError(`${path} is ${describeNonJson(value)}`);
352
+ };
353
+ /**
354
+ * What the server binds a step quote to: the body WITHOUT its `quoteId` (the one
355
+ * field the run adds), in canonical form. Returns the fingerprint, or the
356
+ * server's refusal message when the body is not plain JSON.
357
+ */
358
+ const stepBodyFingerprint = (body) => {
359
+ const { quoteId: _quoteId, ...rest } = body;
360
+ try {
361
+ return { fingerprint: canonicalStepJson(rest, 'body') };
362
+ }
363
+ catch (e) {
364
+ if (e instanceof NonJsonStepBodyError) {
365
+ return { refusal: `${MOCK_STEP_NON_JSON_INPUT_ERROR_PREFIX}${e.message}` };
366
+ }
367
+ throw e;
368
+ }
369
+ };
218
370
  // The canned picks carry the WIDENED BlockResourceInfo projection (PR-C) — the
219
371
  // public recommended settings a real host now returns — so dev:mock mirrors prod
220
372
  // (a picked resource seeds a weight slider + trigger words). Defaults match the
@@ -639,6 +791,8 @@ function jsonByteSize(value) {
639
791
  * map onto the new scenario groups so a dev can flip out-of-Buzz /
640
792
  * failures / latency without editing code. `?capRefusal=1` (or `=<text>`) sets
641
793
  * {@link MockGenerationScenario.submitCapRefusal}.
794
+ * `?confirmedStep=declined|daily-limit|unconfirmed` sets
795
+ * {@link MockHostOptions.runConfirmedStepError}.
642
796
  *
643
797
  * Returns a partial overlay applied ON TOP of explicit {@link MockHostOptions}
644
798
  * (URL wins — it's the interactive dev knob). No-op outside a browser.
@@ -751,6 +905,17 @@ export function readMockHostUrlOptions(win = globalThis
751
905
  generation.submitCapRefusal =
752
906
  capRefusal === '1' || capRefusal === 'true' ? true : capRefusal;
753
907
  }
908
+ // ?confirmedStep=declined|daily-limit|unconfirmed → how RUN_CONFIRMED_STEP
909
+ // settles (see `runConfirmedStepError`). Absent, or any other value: the viewer
910
+ // "confirms" and the job starts. Pair it with a `?costPerGen=` above the
911
+ // per-call budget, which is what makes the estimate quote a confirmed step.
912
+ const confirmedStep = params.get('confirmedStep');
913
+ if (confirmedStep === 'declined')
914
+ out.runConfirmedStepError = 'declined';
915
+ else if (confirmedStep === 'daily-limit')
916
+ out.runConfirmedStepError = 'daily-limit-exceeded';
917
+ else if (confirmedStep === 'unconfirmed')
918
+ out.runConfirmedStepError = 'submission-unconfirmed';
754
919
  if (Object.keys(generation).length > 0)
755
920
  out.generation = generation;
756
921
  if (Object.keys(buzz).length > 0)
@@ -903,6 +1068,9 @@ export function createMockHost(options = {}) {
903
1068
  // image is never added: production leaves it unstamped, so it is not postable.
904
1069
  const postableImageIds = new Set(options.postableImageIds ?? []);
905
1070
  let runTrainingCapRefusal = options.runTrainingCapRefusal;
1071
+ // Confirmed step: forced-refusal knobs. The quotes themselves live per install.
1072
+ let runConfirmedStepError = options.runConfirmedStepError;
1073
+ let runConfirmedStepCapRefusal = options.runConfirmedStepCapRefusal;
906
1074
  // App-subqueue bridge data + forced free-text-error knob. `appWorkflows` is
907
1075
  // MUTABLE — CANCEL_APP_WORKFLOW marks the matching row canceled in place so a
908
1076
  // follow-up QUERY reflects it.
@@ -989,11 +1157,43 @@ export function createMockHost(options = {}) {
989
1157
  // Per-viewer vote state (the mock's `mockUserId` is the "requesting viewer").
990
1158
  viewerVoted: row.voters.has(mockUserId),
991
1159
  });
1160
+ /** Why a `kind: 'workflow'` body has no price quote, or `null` when it is quoted. */
1161
+ const workflowQuoteFailure = () => {
1162
+ const knob = gen.workflowUnpriced;
1163
+ if (knob === true)
1164
+ return 'unavailable';
1165
+ return knob === 'rejected' || knob === 'unavailable' || knob === 'unpriced' ? knob : null;
1166
+ };
992
1167
  // Resolve a per-gen cost from the scenario (or legacy `cost`).
1168
+ // A `kind: 'workflow'` body is the SUM of its steps — see `workflowStepCosts`.
993
1169
  const costFor = (body) => {
1170
+ if (body.kind === 'workflow')
1171
+ return workflowStepCosts(body).reduce((sum, c) => sum + c, 0);
994
1172
  const spec = gen.costPerGen ?? legacyCost;
995
1173
  return typeof spec === 'function' ? spec(body) : (spec ?? legacyCost);
996
1174
  };
1175
+ /**
1176
+ * `costPerGen`, called once per step of a `kind: 'workflow'` body and
1177
+ * multiplied by that step's generations — 1, or a `repeat`'s iteration count —
1178
+ * because the orchestrator's quote prices every child of a bounded repeat.
1179
+ */
1180
+ const workflowStepCosts = (body) => {
1181
+ // Unpriced: reserved at the ceiling, one per-generation budget per step.
1182
+ if (workflowQuoteFailure() !== null)
1183
+ return body.steps.map(() => buzzBudget);
1184
+ const spec = gen.costPerGen ?? legacyCost;
1185
+ return (Array.isArray(body.steps) ? body.steps : []).map((step, index) => {
1186
+ const each = typeof spec === 'function'
1187
+ ? spec(body, {
1188
+ index,
1189
+ name: mockWorkflowStepName(step, index),
1190
+ $type: step.$type,
1191
+ input: step.input,
1192
+ })
1193
+ : spec;
1194
+ return each * mockWorkflowStepGenerations(step.$type, step.input, body.arguments);
1195
+ });
1196
+ };
997
1197
  // `null` = this quote is not itemised (a fee-less kind, or `authorFee: false`).
998
1198
  const authorFeeFor = (body) => {
999
1199
  if (gen.authorFee === false)
@@ -1001,9 +1201,16 @@ export function createMockHost(options = {}) {
1001
1201
  // A REGISTERED step is keyed on the VALUE of `step`, as the host keys it —
1002
1202
  // never `'step' in body`: `{ kind: 'step', step: undefined, ... }` is a legal
1003
1203
  // pass-through body, and a pass-through quote prices no fee.
1004
- const quotesFee = body.kind === 'textToImage' || (body.kind === 'step' && typeof body.step === 'string');
1204
+ // A `workflow` body quotes ONE fee for the whole workflow: this function is
1205
+ // called once per body and never per step.
1206
+ const quotesFee = body.kind === 'textToImage' ||
1207
+ body.kind === 'workflow' ||
1208
+ (body.kind === 'step' && typeof body.step === 'string');
1005
1209
  if (!quotesFee)
1006
1210
  return null;
1211
+ // An unpriced workflow has no quoted base, so no fee is priced on it.
1212
+ if (body.kind === 'workflow' && workflowQuoteFailure() !== null)
1213
+ return 0;
1007
1214
  const spec = gen.authorFee ?? 0;
1008
1215
  return typeof spec === 'function' ? spec(body) : spec;
1009
1216
  };
@@ -1098,6 +1305,10 @@ export function createMockHost(options = {}) {
1098
1305
  // quotes its estimates stored (id → what a run is checked against).
1099
1306
  const trainingDatasets = new Map();
1100
1307
  const trainingQuotes = new Map();
1308
+ // Confirmed steps: the quotes this install's estimates stored (id → what a
1309
+ // run is checked against). Same serial as the training handles, so every
1310
+ // handle this install mints is distinct.
1311
+ const stepQuotes = new Map();
1101
1312
  let trainingSerial = 0;
1102
1313
  /** `<prefix>` + 32 hex characters — the real handles' shape. */
1103
1314
  const trainingHandle = (prefix) => {
@@ -1149,16 +1360,102 @@ export function createMockHost(options = {}) {
1149
1360
  ...(gen.trainingPublishedModel ? { publishedModel: { ...gen.trainingPublishedModel } } : {}),
1150
1361
  };
1151
1362
  };
1363
+ /**
1364
+ * The `steps` (and the top-level `imageUrls` they add up to) of a
1365
+ * `kind: 'workflow'` run, given each step's status. A step reports images
1366
+ * only once it has `'succeeded'` and only if it did not set
1367
+ * `suppressOutput`; the top-level list is the union of those, in step order.
1368
+ */
1369
+ const workflowStepsView = (workflowId, body, statusOf, failedError) => {
1370
+ const lastVisible = body.steps.reduce((last, s, i) => (s.suppressOutput === true ? last : i), -1);
1371
+ const steps = body.steps.map((step, index) => {
1372
+ const status = statusOf(index);
1373
+ const name = mockWorkflowStepName(step, index);
1374
+ const visible = status === 'succeeded' && step.suppressOutput !== true;
1375
+ return {
1376
+ name,
1377
+ $type: step.$type,
1378
+ status,
1379
+ ...(visible
1380
+ ? {
1381
+ imageUrls: index === lastVisible
1382
+ ? imagesFor(workflowId, body)
1383
+ : [
1384
+ `https://placehold.co/512x512/1971c2/ffffff/png?text=MOCK%0A${encodeURIComponent(name)}`,
1385
+ ],
1386
+ }
1387
+ : {}),
1388
+ ...(status === 'failed' && failedError !== undefined ? { error: failedError } : {}),
1389
+ };
1390
+ });
1391
+ const imageUrls = steps.flatMap((s) => s.imageUrls ?? []);
1392
+ return { steps, ...(imageUrls.length > 0 ? { imageUrls } : {}) };
1393
+ };
1394
+ /** A RUNNING `kind: 'workflow'` run after `polls` polls: steps finish in order. */
1395
+ const workflowProgressView = (workflowId, body, polls) => {
1396
+ const total = body.steps.length;
1397
+ const failing = failingWorkflowStep(body);
1398
+ const done = Math.min(Math.floor((total * polls) / Math.max(1, pollsUntilDone)), total - 1, failing === -1 ? total - 1 : failing);
1399
+ return workflowStepsView(workflowId, body, (i) => i < done ? 'succeeded' : i === done ? 'processing' : 'pending');
1400
+ };
1401
+ /** Index of the step `generation.failWorkflowStep` names, or `-1`. */
1402
+ const failingWorkflowStep = (body) => {
1403
+ const target = gen.failWorkflowStep;
1404
+ if (target === undefined)
1405
+ return -1;
1406
+ return body.steps.findIndex((s, i) => mockWorkflowStepName(s, i) === target);
1407
+ };
1408
+ /**
1409
+ * The terminal snapshot of a `kind: 'workflow'` run whose step
1410
+ * `generation.failWorkflowStep` names failed, or `null` when none does.
1411
+ * Earlier steps stay `'succeeded'` and CHARGED, so `cost.total` is their sum
1412
+ * and their visible images are still there.
1413
+ */
1414
+ const workflowPartialFailure = (workflowId) => {
1415
+ const wf = workflows.get(workflowId);
1416
+ const body = wf?.body;
1417
+ if (!wf || body?.kind !== 'workflow')
1418
+ return null;
1419
+ const failing = failingWorkflowStep(body);
1420
+ if (failing === -1)
1421
+ return null;
1422
+ const error = `mock: step "${mockWorkflowStepName(body.steps[failing], failing)}" failed`;
1423
+ const charged = (wf.stepCosts ?? []).slice(0, failing).reduce((sum, c) => sum + c, 0);
1424
+ return {
1425
+ workflowId,
1426
+ status: 'failed',
1427
+ cost: { total: charged },
1428
+ error,
1429
+ ...workflowStepsView(workflowId, body, (i) => (i < failing ? 'succeeded' : i === failing ? 'failed' : 'canceled'), error),
1430
+ };
1431
+ };
1152
1432
  const succeededSnapshot = (workflowId) => {
1153
1433
  const wf = workflows.get(workflowId);
1154
1434
  const cost = wf?.cost ?? legacyCost;
1155
1435
  const body = wf?.body ?? {};
1436
+ if (body.kind === 'workflow') {
1437
+ return {
1438
+ workflowId,
1439
+ status: 'succeeded',
1440
+ cost: { total: cost },
1441
+ ...workflowStepsView(workflowId, body, () => 'succeeded'),
1442
+ spentAccountType: primaryFunder(buzzBalance),
1443
+ };
1444
+ }
1445
+ // 3D labels: a pass-through step only (see `generation.model3dOutputs`).
1446
+ const model3d = gen.model3dOutputs && gen.model3dOutputs.length > 0 && passThroughStepType(body)
1447
+ ? gen.model3dOutputs.map((m) => ({ ...m }))
1448
+ : undefined;
1156
1449
  return {
1157
1450
  ...trainingFields(body),
1451
+ ...(model3d ? { model3dOutputs: model3d } : {}),
1158
1452
  workflowId,
1159
1453
  status: 'succeeded',
1160
1454
  cost: { total: cost },
1161
- imageUrls: imagesFor(workflowId, body),
1455
+ // Every labelled model url is also an `imageUrls` entry on the real host.
1456
+ imageUrls: model3d && !gen.images && !gen.image
1457
+ ? model3d.map((m) => m.url)
1458
+ : imagesFor(workflowId, body),
1162
1459
  // Pick-aware APPROXIMATION of the real backend's
1163
1460
  // BlockWorkflowSnapshot.spentAccountType. The real backend stamps the
1164
1461
  // LARGEST realized debit (`primaryDebitedAccountType`), and its currency
@@ -1247,7 +1544,11 @@ export function createMockHost(options = {}) {
1247
1544
  * ESTIMATE_WORKFLOW and ESTIMATE_WORKFLOW_BATCH so a batch cell is priced
1248
1545
  * exactly as a single estimate of the same body.
1249
1546
  */
1250
- const estimateSnapshotFor = (body) => {
1547
+ const estimateSnapshotFor = (body,
1548
+ // TRUE only from the single ESTIMATE_WORKFLOW. The server's batch
1549
+ // estimate persists nothing, so a batch cell never stores a
1550
+ // confirmed-step quote and never carries a `stepQuote`.
1551
+ opts) => {
1251
1552
  // UNUSABLE-ESTIMATE simulation (`generation.failEstimate`). Reproduces
1252
1553
  // the two real producers byte-for-byte so a block's `catch` around
1253
1554
  // `estimate()` is exercisable locally — see the knob's docs for why
@@ -1328,6 +1629,68 @@ export function createMockHost(options = {}) {
1328
1629
  },
1329
1630
  };
1330
1631
  }
1632
+ // CONFIRMED-STEP QUOTE — kind-faithful, like the training arm above. A
1633
+ // pass-through step whose (fixed) price is above the token's per-call
1634
+ // budget and within the per-job ceiling gets a stored, viewer-
1635
+ // confirmable quote beside its ordinary estimate; every other estimate
1636
+ // is exactly what it was. The server grants it only to a signed-in
1637
+ // viewer's page token, which is what `viewer !== null` stands for here.
1638
+ const stepType = passThroughStepType(body);
1639
+ const stepPrice = stepType === undefined ? 0 : costFor(body);
1640
+ if (opts.storeQuotes &&
1641
+ stepType !== undefined &&
1642
+ viewer !== null &&
1643
+ stepPrice > buzzBudget &&
1644
+ stepPrice <= MOCK_CONFIRMED_STEP_MAX_BUZZ_PER_JOB) {
1645
+ // From here a quote WOULD be granted, so the two bodies that must
1646
+ // never get one are refused out loud, as the server refuses them: it
1647
+ // throws `BAD_REQUEST`, which the page host catches into
1648
+ // `failureSnapshot(err)` — so `estimate()` rejects instead of
1649
+ // returning a price. Below the budget (or in a batch) neither check
1650
+ // runs and the estimate is the ordinary one.
1651
+ if (isTrainingStepTypeName(stepType)) {
1652
+ return { workflowId: 'failed', status: 'failed', error: MOCK_STEP_TRAINING_TYPE_ERROR };
1653
+ }
1654
+ const bound = stepBodyFingerprint(body);
1655
+ if ('refusal' in bound) {
1656
+ return { workflowId: 'failed', status: 'failed', error: bound.refusal };
1657
+ }
1658
+ const quoteId = trainingHandle('sq_');
1659
+ const expiresAtMs = Date.now() + STEP_QUOTE_TTL_MS;
1660
+ stepQuotes.set(quoteId, {
1661
+ fingerprint: bound.fingerprint,
1662
+ total: stepPrice,
1663
+ expiresAtMs,
1664
+ spent: false,
1665
+ });
1666
+ return {
1667
+ workflowId: 'wf_estimate',
1668
+ status: 'pending',
1669
+ cost: quotedCost(body),
1670
+ stepQuote: {
1671
+ quoteId,
1672
+ total: stepPrice,
1673
+ stepType,
1674
+ expiresAt: new Date(expiresAtMs).toISOString(),
1675
+ },
1676
+ };
1677
+ }
1678
+ // MULTI-STEP — the second kind-faithful arm: the host refuses a body
1679
+ // it will not run BEFORE pricing it, with an `errorCode`, in the
1680
+ // `failureSnapshot` shape (no `cost`). A body that passes is priced
1681
+ // below like any other: `quotedCost` sums the steps and adds ONE fee.
1682
+ if (body.kind === 'workflow') {
1683
+ // With no quote the estimate has no price to show: it fails with the
1684
+ // cause, never the ceiling. (The submit of the same body proceeds.)
1685
+ const failure = workflowQuoteFailure();
1686
+ const refusal = mockWorkflowBodyRefusal(body) ??
1687
+ (failure === null
1688
+ ? null
1689
+ : (mockUnpricedWorkflowRefusal(body, failure) ??
1690
+ mockUnquotedWorkflowEstimateRefusal(failure)));
1691
+ if (refusal !== null)
1692
+ return { workflowId: 'failed', status: 'failed', ...refusal };
1693
+ }
1331
1694
  return {
1332
1695
  workflowId: 'wf_estimate',
1333
1696
  status: 'pending',
@@ -1445,7 +1808,7 @@ export function createMockHost(options = {}) {
1445
1808
  const body = typed.payload?.body ?? {};
1446
1809
  dispatchToBlock({
1447
1810
  type: 'ESTIMATE_RESULT',
1448
- payload: { requestId, snapshot: estimateSnapshotFor(body) },
1811
+ payload: { requestId, snapshot: estimateSnapshotFor(body, { storeQuotes: true }) },
1449
1812
  });
1450
1813
  return;
1451
1814
  }
@@ -1518,7 +1881,7 @@ export function createMockHost(options = {}) {
1518
1881
  error: MOCK_TRAINING_IN_BATCH_ERROR,
1519
1882
  };
1520
1883
  }
1521
- return estimateSnapshotFor(cell ?? {});
1884
+ return estimateSnapshotFor(cell ?? {}, { storeQuotes: false });
1522
1885
  });
1523
1886
  // The server's aggregate rule: the cells that priced, i.e. not
1524
1887
  // `'failed'` and carrying a numeric `cost.total`.
@@ -1611,6 +1974,45 @@ export function createMockHost(options = {}) {
1611
1974
  });
1612
1975
  return;
1613
1976
  }
1977
+ // A pass-through body naming a `quoteId` asks to be charged against a
1978
+ // viewer-confirmed quote, and only RUN_CONFIRMED_STEP's dialog records
1979
+ // that confirmation — so as a plain submit the server refuses it. Its
1980
+ // own message, in the host's `failureSnapshot` shape.
1981
+ // (`useBuzzWorkflow().submit()` refuses before sending; this covers a
1982
+ // raw transport send.)
1983
+ if (body.kind === 'step' &&
1984
+ body.quoteId !== undefined) {
1985
+ dispatchToBlock({
1986
+ type: 'WORKFLOW_SUBMITTED',
1987
+ payload: {
1988
+ requestId,
1989
+ snapshot: {
1990
+ workflowId: 'failed',
1991
+ status: 'failed',
1992
+ error: MOCK_STEP_NOT_CONFIRMED_ERROR,
1993
+ },
1994
+ },
1995
+ });
1996
+ return;
1997
+ }
1998
+ // A `kind: 'workflow'` body the host will not run is refused here,
1999
+ // before any cap, balance or debit — the same refusal, with the same
2000
+ // `errorCode`, the estimate gives. No `cost`, so `submit()` rejects.
2001
+ if (body.kind === 'workflow') {
2002
+ const failure = workflowQuoteFailure();
2003
+ const refusal = mockWorkflowBodyRefusal(body) ??
2004
+ (failure === null ? null : mockUnpricedWorkflowRefusal(body, failure));
2005
+ if (refusal !== null) {
2006
+ dispatchToBlock({
2007
+ type: 'WORKFLOW_SUBMITTED',
2008
+ payload: {
2009
+ requestId,
2010
+ snapshot: { workflowId: 'failed', status: 'failed', ...refusal },
2011
+ },
2012
+ });
2013
+ return;
2014
+ }
2015
+ }
1614
2016
  const cost = costFor(body);
1615
2017
  // What the viewer pays: the generation plus the app fee.
1616
2018
  const charged = cost + (authorFeeFor(body) ?? 0);
@@ -1758,7 +2160,12 @@ export function createMockHost(options = {}) {
1758
2160
  if (balanceSimulated)
1759
2161
  buzz.balance = buzz.balance - charged;
1760
2162
  const workflowId = `wf_${submitCount}_${Date.now()}`;
1761
- workflows.set(workflowId, { polls: 0, cost, body });
2163
+ workflows.set(workflowId, {
2164
+ polls: 0,
2165
+ cost,
2166
+ body,
2167
+ ...(body.kind === 'workflow' ? { stepCosts: workflowStepCosts(body) } : {}),
2168
+ });
1762
2169
  dispatchToBlock({
1763
2170
  type: 'WORKFLOW_SUBMITTED',
1764
2171
  payload: { requestId, snapshot: { workflowId, status: 'pending' } },
@@ -1784,6 +2191,16 @@ export function createMockHost(options = {}) {
1784
2191
  }
1785
2192
  const polls = wf.polls + 1;
1786
2193
  wf.polls = polls;
2194
+ // A `kind: 'workflow'` run with a step told to fail ends `failed`
2195
+ // with its earlier steps intact (`generation.failWorkflowStep`).
2196
+ const partialFailure = polls >= pollsUntilDone ? workflowPartialFailure(workflowId) : null;
2197
+ if (partialFailure !== null) {
2198
+ dispatchToBlock({
2199
+ type: 'WORKFLOW_STATUS',
2200
+ payload: { requestId, snapshot: partialFailure },
2201
+ });
2202
+ return;
2203
+ }
1787
2204
  const outcome = polls >= pollsUntilDone ? (gen.workflowOutcome?.(workflowId, wf.body) ?? 'succeeded') : null;
1788
2205
  if (outcome === 'failed' || outcome === 'expired') {
1789
2206
  dispatchToBlock({
@@ -1814,7 +2231,17 @@ export function createMockHost(options = {}) {
1814
2231
  else {
1815
2232
  dispatchToBlock({
1816
2233
  type: 'WORKFLOW_STATUS',
1817
- payload: { requestId, snapshot: { workflowId, status: 'processing' } },
2234
+ payload: {
2235
+ requestId,
2236
+ snapshot: {
2237
+ workflowId,
2238
+ status: 'processing',
2239
+ // A multi-step run reports how far its steps have got.
2240
+ ...(wf.body?.kind === 'workflow'
2241
+ ? workflowProgressView(workflowId, wf.body, polls)
2242
+ : {}),
2243
+ },
2244
+ },
1818
2245
  });
1819
2246
  }
1820
2247
  return;
@@ -1835,6 +2262,14 @@ export function createMockHost(options = {}) {
1835
2262
  if (wf && !wf.canceled && wf.polls >= pollsUntilDone) {
1836
2263
  // Already finished: like the real host, report the real terminal
1837
2264
  // status rather than pretending the cancel took.
2265
+ const partialFailure = workflowPartialFailure(workflowId);
2266
+ if (partialFailure !== null) {
2267
+ dispatchToBlock({
2268
+ type: 'WORKFLOW_CANCELED',
2269
+ payload: { requestId, snapshot: partialFailure },
2270
+ });
2271
+ return;
2272
+ }
1838
2273
  const outcome = gen.workflowOutcome?.(workflowId, wf.body) ?? 'succeeded';
1839
2274
  dispatchToBlock({
1840
2275
  type: 'WORKFLOW_CANCELED',
@@ -2259,6 +2694,84 @@ export function createMockHost(options = {}) {
2259
2694
  });
2260
2695
  return reply({ snapshot: { workflowId, status: 'pending' } });
2261
2696
  }
2697
+ case 'RUN_CONFIRMED_STEP': {
2698
+ // The host's gate (`runConfirmedStepGate.ts`): no requestId → drop;
2699
+ // signed-out → `sign in to run`; not a pass-through `kind:'step'`
2700
+ // body naming an `sq_`-shaped `quoteId` → `invalid confirmed step
2701
+ // request`. Then the server: the spend scope, then the quote the host
2702
+ // reads back for its dialog (unknown, expired or already spent).
2703
+ if (!isRoutableRequestId(requestId))
2704
+ return;
2705
+ const reply = (payload) => dispatchToBlock({
2706
+ type: 'CONFIRMED_STEP_RESULT',
2707
+ payload: { requestId, ...payload },
2708
+ });
2709
+ if (viewer === null)
2710
+ return reply({ error: 'sign in to run' });
2711
+ const raw = typed.payload?.body;
2712
+ const b = raw && typeof raw === 'object' && !Array.isArray(raw)
2713
+ ? raw
2714
+ : null;
2715
+ if (!b ||
2716
+ b.kind !== 'step' ||
2717
+ typeof b.$type !== 'string' ||
2718
+ typeof b.quoteId !== 'string' ||
2719
+ !STEP_QUOTE_ID_PATTERN.test(b.quoteId)) {
2720
+ return reply({ error: 'invalid confirmed step request' });
2721
+ }
2722
+ if (!consentGranted)
2723
+ return reply({ error: MOCK_SPEND_SCOPE_ERROR });
2724
+ const quote = stepQuotes.get(b.quoteId);
2725
+ if (!quote || quote.spent || Date.now() >= quote.expiresAtMs) {
2726
+ return reply({ error: MOCK_STEP_QUOTE_GONE_ERROR });
2727
+ }
2728
+ // Which outcomes consume the quote — the same split as RUN_TRAINING
2729
+ // above: the host's gate, its daily-limit explanation and its confirm
2730
+ // come first (quote kept); the submit then claims the quote before any
2731
+ // of its own checks, so every server outcome consumes it.
2732
+ if (runConfirmedStepError !== undefined) {
2733
+ if (!PRE_CLAIM_RUN_CONFIRMED_STEP_ERRORS.has(runConfirmedStepError)) {
2734
+ quote.spent = true;
2735
+ }
2736
+ return reply({ error: runConfirmedStepError });
2737
+ }
2738
+ // The submit's preflight refuses a training `$type` BEFORE it touches
2739
+ // the quote, so the quote is kept.
2740
+ if (isTrainingStepTypeName(b.$type)) {
2741
+ return reply({ error: MOCK_STEP_TRAINING_TYPE_ERROR });
2742
+ }
2743
+ quote.spent = true;
2744
+ // The body is hashed AFTER the claim, so a body the server cannot bind
2745
+ // (non-plain-JSON input) and a body that differs both consume the quote.
2746
+ const bound = stepBodyFingerprint(b);
2747
+ if ('refusal' in bound)
2748
+ return reply({ error: bound.refusal });
2749
+ if (bound.fingerprint !== quote.fingerprint) {
2750
+ return reply({ error: MOCK_STEP_BODY_MISMATCH_ERROR });
2751
+ }
2752
+ // The server's cap refusals RESOLVE (refunded, no job started) — after
2753
+ // the quote was consumed above.
2754
+ if (runConfirmedStepCapRefusal !== undefined) {
2755
+ return reply({
2756
+ snapshot: {
2757
+ workflowId: 'failed',
2758
+ status: 'failed',
2759
+ cost: { total: quote.total },
2760
+ error: runConfirmedStepCapRefusal,
2761
+ },
2762
+ });
2763
+ }
2764
+ // Every confirmation is its own job: the charge is the confirmed
2765
+ // price, never `maxBuzz`.
2766
+ submitCount += 1;
2767
+ const workflowId = `wf_${submitCount}_${Date.now()}`;
2768
+ workflows.set(workflowId, {
2769
+ polls: 0,
2770
+ cost: quote.total,
2771
+ body: raw,
2772
+ });
2773
+ return reply({ snapshot: { workflowId, status: 'pending' } });
2774
+ }
2262
2775
  case 'PUBLISH_GENERATION_OUTPUTS': {
2263
2776
  // Publish selected outputs of one of the app's OWN workflows as bare,
2264
2777
  // real-scanned public Image rows. Drop a request with no requestId
@@ -2938,13 +3451,27 @@ export function createMockHost(options = {}) {
2938
3451
  return saveReply({ ok: true });
2939
3452
  }
2940
3453
  case 'SET_USER_CHECKPOINT': {
2941
- // Persist the checkpoint override (mirrors the real host's
2942
- // block_user_settings write). Accept a numeric versionId OR an
2943
- // explicit null (clear); anything else is a bad-input NACK, same as
2944
- // the real IframeHost. Without this reply `useCheckpointPicker().
2945
- // persist()` hung to its 30s timeout under the mock host.
3454
+ // Without a reply `useCheckpointPicker().persist()` hung to its 30s
3455
+ // timeout under the mock host.
2946
3456
  if (!isRoutableRequestId(requestId))
2947
3457
  return;
3458
+ // 🔴 A PAGE REFUSES, ALWAYS — and the default context is a page.
3459
+ // The page host has nowhere to write the override (a page token is
3460
+ // bound to no model), so it answers every request with this exact
3461
+ // reply, whatever `versionId` was sent. Until this branch the mock
3462
+ // answered `ok: true` on a page, so a page app's `persist()` passed
3463
+ // here and failed in production. See ./hostSurface.ts.
3464
+ if (emulatesPageHost(options.context)) {
3465
+ dispatchToBlock({
3466
+ type: 'USER_CHECKPOINT_SET',
3467
+ payload: { requestId, ok: false, error: PAGE_CHECKPOINT_PERSIST_ERROR },
3468
+ });
3469
+ return;
3470
+ }
3471
+ // MODEL SLOT: persist the checkpoint override (mirrors the real
3472
+ // host's block_user_settings write). Accept a numeric versionId OR
3473
+ // an explicit null (clear); anything else is a bad-input NACK, same
3474
+ // as the real IframeHost.
2948
3475
  const rawVersionId = typed.payload?.versionId;
2949
3476
  const versionId = rawVersionId === null
2950
3477
  ? null
@@ -2965,6 +3492,11 @@ export function createMockHost(options = {}) {
2965
3492
  return;
2966
3493
  }
2967
3494
  default:
3495
+ // No reply, as in production. What reaches here is a type with no
3496
+ // reply channel (BLOCK_READY, NAVIGATE, TRACK_EVENT, …) or a type
3497
+ // this protocol does not declare. The real host's `unsupported on
3498
+ // this host` reply is built only for a request type it KNOWS and
3499
+ // has no handler for; a type it does not know gets nothing.
2968
3500
  return;
2969
3501
  }
2970
3502
  },
@@ -3121,6 +3653,11 @@ export function createMockHost(options = {}) {
3121
3653
  uploadImageBytesError = patch.uploadImageBytesError;
3122
3654
  if ('runTrainingCapRefusal' in patch)
3123
3655
  runTrainingCapRefusal = patch.runTrainingCapRefusal;
3656
+ if ('runConfirmedStepError' in patch)
3657
+ runConfirmedStepError = patch.runConfirmedStepError;
3658
+ if ('runConfirmedStepCapRefusal' in patch) {
3659
+ runConfirmedStepCapRefusal = patch.runConfirmedStepCapRefusal;
3660
+ }
3124
3661
  if (patch.appWorkflows !== undefined) {
3125
3662
  appWorkflows = {
3126
3663
  workflows: patch.appWorkflows.workflows,