@civitai/blocks-react 0.69.1 → 0.71.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.
@@ -43,6 +43,7 @@ import { requiredStorageScope, storageResultType, storageScopeDeniedMessage, sto
43
43
  import { governsIdempotencyKey, idempotencyKeyDeniedMessage, idempotencyKeyRefusal, } from './mockHostIdempotency.js';
44
44
  import { SAVE_IMAGE_INVALID_REQUEST_ERROR, prepareSaveBytes, saveImageRequestKind, } from './saveBytes.js';
45
45
  import { UPLOAD_BYTES_INVALID_ERROR, admitUploadBytes, resolveUploadBytesRequest, } from './uploadBytes.js';
46
+ import { mockUnpricedWorkflowRefusal, mockWorkflowBodyRefusal, mockWorkflowStepGenerations, mockWorkflowStepName, } from './mockWorkflowGraph.js';
46
47
  import { BATCH_ESTIMATE_CELL_TIMEOUT_ERROR } from '../hooks/useBatchEstimate.js';
47
48
  import { RUN_TRAINING_ERROR_CODES } from '../hooks/useRunTraining.js';
48
49
  import { hostContextWithTheme } from '../transport/transport.js';
@@ -87,6 +88,9 @@ const preferredAccountType = (body) => {
87
88
  // is `.strict()` with no `accountType`.
88
89
  case 'training':
89
90
  return undefined;
91
+ // Nor does a multi-step body: the kind has no `accountType` field.
92
+ case 'workflow':
93
+ return undefined;
90
94
  default: {
91
95
  // Exhaustiveness check: a new `WorkflowBody` member makes this assignment
92
96
  // fail to compile, right here, rather than defaulting somewhere.
@@ -173,8 +177,14 @@ function isValidTrainingDatasetItems(items) {
173
177
  caption.length <= MOCK_TRAINING_BOUNDS.captionMaxChars);
174
178
  });
175
179
  }
176
- /** The real server's refusal for a training request from a token without the spend scope. */
177
- export const MOCK_TRAINING_SCOPE_ERROR = 'block lacks ai:write:budgeted scope';
180
+ /**
181
+ * The real server's refusal for ANY money call from a token without the spend
182
+ * scope: civitai's `estimateWorkflow`, `estimateWorkflowBatch`, `submitWorkflow`
183
+ * and the training procedures all throw `FORBIDDEN` with exactly this message
184
+ * BEFORE any pricing, rate-limit or budget work. ONE constant for every mock
185
+ * path that refuses on `consentGranted === false`, so the copies cannot drift.
186
+ */
187
+ export const MOCK_SPEND_SCOPE_ERROR = 'block lacks ai:write:budgeted scope';
178
188
  /** The real server's refusal for an unknown, expired or spent quote on `RUN_TRAINING`. */
179
189
  export const MOCK_TRAINING_QUOTE_GONE_ERROR = 'training quote not found, expired or already used — estimate again';
180
190
  /** The real server's refusal for a training estimate naming a dataset it does not hold. */
@@ -984,10 +994,35 @@ export function createMockHost(options = {}) {
984
994
  viewerVoted: row.voters.has(mockUserId),
985
995
  });
986
996
  // Resolve a per-gen cost from the scenario (or legacy `cost`).
997
+ // A `kind: 'workflow'` body is the SUM of its steps — see `workflowStepCosts`.
987
998
  const costFor = (body) => {
999
+ if (body.kind === 'workflow')
1000
+ return workflowStepCosts(body).reduce((sum, c) => sum + c, 0);
988
1001
  const spec = gen.costPerGen ?? legacyCost;
989
1002
  return typeof spec === 'function' ? spec(body) : (spec ?? legacyCost);
990
1003
  };
1004
+ /**
1005
+ * `costPerGen`, called once per step of a `kind: 'workflow'` body and
1006
+ * multiplied by that step's generations — 1, or a `repeat`'s iteration count —
1007
+ * because the orchestrator's quote prices every child of a bounded repeat.
1008
+ */
1009
+ const workflowStepCosts = (body) => {
1010
+ // Unpriced: reserved at the ceiling, one per-generation budget per step.
1011
+ if (gen.workflowUnpriced === true)
1012
+ return body.steps.map(() => buzzBudget);
1013
+ const spec = gen.costPerGen ?? legacyCost;
1014
+ return (Array.isArray(body.steps) ? body.steps : []).map((step, index) => {
1015
+ const each = typeof spec === 'function'
1016
+ ? spec(body, {
1017
+ index,
1018
+ name: mockWorkflowStepName(step, index),
1019
+ $type: step.$type,
1020
+ input: step.input,
1021
+ })
1022
+ : spec;
1023
+ return each * mockWorkflowStepGenerations(step.$type, step.input, body.arguments);
1024
+ });
1025
+ };
991
1026
  // `null` = this quote is not itemised (a fee-less kind, or `authorFee: false`).
992
1027
  const authorFeeFor = (body) => {
993
1028
  if (gen.authorFee === false)
@@ -995,9 +1030,16 @@ export function createMockHost(options = {}) {
995
1030
  // A REGISTERED step is keyed on the VALUE of `step`, as the host keys it —
996
1031
  // never `'step' in body`: `{ kind: 'step', step: undefined, ... }` is a legal
997
1032
  // pass-through body, and a pass-through quote prices no fee.
998
- const quotesFee = body.kind === 'textToImage' || (body.kind === 'step' && typeof body.step === 'string');
1033
+ // A `workflow` body quotes ONE fee for the whole workflow: this function is
1034
+ // called once per body and never per step.
1035
+ const quotesFee = body.kind === 'textToImage' ||
1036
+ body.kind === 'workflow' ||
1037
+ (body.kind === 'step' && typeof body.step === 'string');
999
1038
  if (!quotesFee)
1000
1039
  return null;
1040
+ // An unpriced workflow has no quoted base, so no fee is priced on it.
1041
+ if (body.kind === 'workflow' && gen.workflowUnpriced === true)
1042
+ return 0;
1001
1043
  const spec = gen.authorFee ?? 0;
1002
1044
  return typeof spec === 'function' ? spec(body) : spec;
1003
1045
  };
@@ -1143,10 +1185,88 @@ export function createMockHost(options = {}) {
1143
1185
  ...(gen.trainingPublishedModel ? { publishedModel: { ...gen.trainingPublishedModel } } : {}),
1144
1186
  };
1145
1187
  };
1188
+ /**
1189
+ * The `steps` (and the top-level `imageUrls` they add up to) of a
1190
+ * `kind: 'workflow'` run, given each step's status. A step reports images
1191
+ * only once it has `'succeeded'` and only if it did not set
1192
+ * `suppressOutput`; the top-level list is the union of those, in step order.
1193
+ */
1194
+ const workflowStepsView = (workflowId, body, statusOf, failedError) => {
1195
+ const lastVisible = body.steps.reduce((last, s, i) => (s.suppressOutput === true ? last : i), -1);
1196
+ const steps = body.steps.map((step, index) => {
1197
+ const status = statusOf(index);
1198
+ const name = mockWorkflowStepName(step, index);
1199
+ const visible = status === 'succeeded' && step.suppressOutput !== true;
1200
+ return {
1201
+ name,
1202
+ $type: step.$type,
1203
+ status,
1204
+ ...(visible
1205
+ ? {
1206
+ imageUrls: index === lastVisible
1207
+ ? imagesFor(workflowId, body)
1208
+ : [
1209
+ `https://placehold.co/512x512/1971c2/ffffff/png?text=MOCK%0A${encodeURIComponent(name)}`,
1210
+ ],
1211
+ }
1212
+ : {}),
1213
+ ...(status === 'failed' && failedError !== undefined ? { error: failedError } : {}),
1214
+ };
1215
+ });
1216
+ const imageUrls = steps.flatMap((s) => s.imageUrls ?? []);
1217
+ return { steps, ...(imageUrls.length > 0 ? { imageUrls } : {}) };
1218
+ };
1219
+ /** A RUNNING `kind: 'workflow'` run after `polls` polls: steps finish in order. */
1220
+ const workflowProgressView = (workflowId, body, polls) => {
1221
+ const total = body.steps.length;
1222
+ const failing = failingWorkflowStep(body);
1223
+ const done = Math.min(Math.floor((total * polls) / Math.max(1, pollsUntilDone)), total - 1, failing === -1 ? total - 1 : failing);
1224
+ return workflowStepsView(workflowId, body, (i) => i < done ? 'succeeded' : i === done ? 'processing' : 'pending');
1225
+ };
1226
+ /** Index of the step `generation.failWorkflowStep` names, or `-1`. */
1227
+ const failingWorkflowStep = (body) => {
1228
+ const target = gen.failWorkflowStep;
1229
+ if (target === undefined)
1230
+ return -1;
1231
+ return body.steps.findIndex((s, i) => mockWorkflowStepName(s, i) === target);
1232
+ };
1233
+ /**
1234
+ * The terminal snapshot of a `kind: 'workflow'` run whose step
1235
+ * `generation.failWorkflowStep` names failed, or `null` when none does.
1236
+ * Earlier steps stay `'succeeded'` and CHARGED, so `cost.total` is their sum
1237
+ * and their visible images are still there.
1238
+ */
1239
+ const workflowPartialFailure = (workflowId) => {
1240
+ const wf = workflows.get(workflowId);
1241
+ const body = wf?.body;
1242
+ if (!wf || body?.kind !== 'workflow')
1243
+ return null;
1244
+ const failing = failingWorkflowStep(body);
1245
+ if (failing === -1)
1246
+ return null;
1247
+ const error = `mock: step "${mockWorkflowStepName(body.steps[failing], failing)}" failed`;
1248
+ const charged = (wf.stepCosts ?? []).slice(0, failing).reduce((sum, c) => sum + c, 0);
1249
+ return {
1250
+ workflowId,
1251
+ status: 'failed',
1252
+ cost: { total: charged },
1253
+ error,
1254
+ ...workflowStepsView(workflowId, body, (i) => (i < failing ? 'succeeded' : i === failing ? 'failed' : 'canceled'), error),
1255
+ };
1256
+ };
1146
1257
  const succeededSnapshot = (workflowId) => {
1147
1258
  const wf = workflows.get(workflowId);
1148
1259
  const cost = wf?.cost ?? legacyCost;
1149
1260
  const body = wf?.body ?? {};
1261
+ if (body.kind === 'workflow') {
1262
+ return {
1263
+ workflowId,
1264
+ status: 'succeeded',
1265
+ cost: { total: cost },
1266
+ ...workflowStepsView(workflowId, body, () => 'succeeded'),
1267
+ spentAccountType: primaryFunder(buzzBalance),
1268
+ };
1269
+ }
1150
1270
  return {
1151
1271
  ...trainingFields(body),
1152
1272
  workflowId,
@@ -1262,20 +1382,39 @@ export function createMockHost(options = {}) {
1262
1382
  // two to diagnose from the block side.
1263
1383
  return { workflowId: 'wf_estimate', status: 'pending' };
1264
1384
  }
1385
+ // 🔴 THE SPEND-SCOPE GATE — EVERY body kind, not just training.
1386
+ // civitai's `estimateWorkflow` throws `FORBIDDEN` with exactly this
1387
+ // message as its FIRST act after authenticating the token, before any
1388
+ // pricing, and the page host catches the throw into
1389
+ // `failureSnapshot(err)` — the `'failed'` sentinel id, no `cost`. That
1390
+ // is the signal a block answers with `requestConsent(...)` and a retry.
1391
+ // Until #608 only the training arm checked it, so the refusal → consent
1392
+ // → retry loop was unreachable for every other estimate in `pnpm dev`.
1393
+ //
1394
+ // Sits AFTER the `failEstimate` knobs on purpose: an explicitly forced
1395
+ // failure is what the author asked to see, and `estimate()` is not
1396
+ // consent-retried, so a scope refusal ahead of it would hide the knob
1397
+ // behind a message the author never asked for.
1398
+ //
1399
+ // The batch path refuses the WHOLE call before it reaches this function
1400
+ // (see ESTIMATE_WORKFLOW_BATCH), so this branch is the single-estimate
1401
+ // one in practice.
1402
+ if (!consentGranted) {
1403
+ return { workflowId: 'failed', status: 'failed', error: MOCK_SPEND_SCOPE_ERROR };
1404
+ }
1265
1405
  // TRAINING ESTIMATE — KIND-FAITHFUL, the one exception to the
1266
1406
  // kind-agnostic rule above, because the real arm is not a priced
1267
- // pass-through: it needs the spend scope, names a dataset the server
1268
- // must hold, refuses a price above the per-run ceiling, and STORES a
1269
- // quote the run is later checked against. Each refusal is the
1270
- // server's message in the host's `failureSnapshot` shape.
1407
+ // pass-through: it names a dataset the server must hold, refuses a
1408
+ // price above the per-run ceiling, and STORES a quote the run is later
1409
+ // checked against. (The spend scope is checked above, for every kind.)
1410
+ // Each refusal is the server's message in the host's `failureSnapshot`
1411
+ // shape.
1271
1412
  if (body.kind === 'training') {
1272
1413
  const refuse = (error) => ({
1273
1414
  workflowId: 'failed',
1274
1415
  status: 'failed',
1275
1416
  error,
1276
1417
  });
1277
- if (!consentGranted)
1278
- return refuse(MOCK_TRAINING_SCOPE_ERROR);
1279
1418
  const imageCount = trainingDatasets.get(body.datasetId);
1280
1419
  if (imageCount === undefined)
1281
1420
  return refuse(MOCK_TRAINING_DATASET_GONE_ERROR);
@@ -1303,6 +1442,16 @@ export function createMockHost(options = {}) {
1303
1442
  },
1304
1443
  };
1305
1444
  }
1445
+ // MULTI-STEP — the second kind-faithful arm: the host refuses a body
1446
+ // it will not run BEFORE pricing it, with an `errorCode`, in the
1447
+ // `failureSnapshot` shape (no `cost`). A body that passes is priced
1448
+ // below like any other: `quotedCost` sums the steps and adds ONE fee.
1449
+ if (body.kind === 'workflow') {
1450
+ const refusal = mockWorkflowBodyRefusal(body) ??
1451
+ (gen.workflowUnpriced === true ? mockUnpricedWorkflowRefusal(body) : null);
1452
+ if (refusal !== null)
1453
+ return { workflowId: 'failed', status: 'failed', ...refusal };
1454
+ }
1306
1455
  return {
1307
1456
  workflowId: 'wf_estimate',
1308
1457
  status: 'pending',
@@ -1458,6 +1607,21 @@ export function createMockHost(options = {}) {
1458
1607
  });
1459
1608
  return;
1460
1609
  }
1610
+ // 🔴 THE SPEND-SCOPE GATE, WHOLE-CALL. civitai's
1611
+ // `estimateWorkflowBatch` throws `FORBIDDEN` before pricing a single
1612
+ // cell, and the host (`estimateBatchGate.ts`) catches the throw into
1613
+ // `{ requestId, error: err.message }` — NOT a per-cell failure, and
1614
+ // no `snapshots`/`aggregate`. The SDK surfaces it as
1615
+ // `BatchEstimateError` code `'failed'` with this text on `hostError`.
1616
+ // After the request-shape checks above, because those are the host's
1617
+ // own gate and run before the server is consulted.
1618
+ if (!consentGranted) {
1619
+ dispatchToBlock({
1620
+ type: 'ESTIMATE_BATCH_RESULT',
1621
+ payload: { requestId, error: MOCK_SPEND_SCOPE_ERROR },
1622
+ });
1623
+ return;
1624
+ }
1461
1625
  const snapshots = bodies.map((cell, index) => {
1462
1626
  // The host's snapshot for a cell its call budget ran out on.
1463
1627
  if (gen.batchEstimateTimedOutCells?.includes(index)) {
@@ -1510,13 +1674,14 @@ export function createMockHost(options = {}) {
1510
1674
  // the largest-wallet stamp. No recipe-registry validation.
1511
1675
  submitCount += 1;
1512
1676
  const body = typed.payload?.body ?? {};
1513
- // A `kind: 'training'` body is never started by SUBMIT_WORKFLOW: the
1514
- // server charges a training run only against a quote a signed-in
1515
- // session confirmed, which only RUN_TRAINING's dialog records. The
1516
- // server's own refusal, in the host's `failureSnapshot` shape.
1517
- // (`useBuzzWorkflow().submit()` refuses before sending; this covers a
1518
- // raw transport send.)
1519
- if (body.kind === 'training') {
1677
+ // CAUGHT-SERVER-EXCEPTION simulation (`generation.failSubmitException`).
1678
+ // Reproduces the host's `failureSnapshot(err)` byte-for-byte: the
1679
+ // 'failed' sentinel id and NO `cost`, which is what separates an
1680
+ // errored submit from a priced budget rejection
1681
+ // (civitai/civitai-app-starters#251). Checked FIRST — a forced
1682
+ // host-side throw pre-empts every server-side decision below,
1683
+ // including the spend-scope gate.
1684
+ if (gen.failSubmitException === true) {
1520
1685
  dispatchToBlock({
1521
1686
  type: 'WORKFLOW_SUBMITTED',
1522
1687
  payload: {
@@ -1524,22 +1689,39 @@ export function createMockHost(options = {}) {
1524
1689
  snapshot: {
1525
1690
  workflowId: 'failed',
1526
1691
  status: 'failed',
1527
- error: 'this training run has not been confirmed by the viewer in this session',
1692
+ error: gen.failSubmitExceptionMessage ?? 'mock: submit failed',
1528
1693
  },
1529
1694
  },
1530
1695
  });
1531
1696
  return;
1532
1697
  }
1533
- const cost = costFor(body);
1534
- // What the viewer pays: the generation plus the app fee.
1535
- const charged = cost + (authorFeeFor(body) ?? 0);
1536
- // CAUGHT-SERVER-EXCEPTION simulation (`generation.failSubmitException`).
1537
- // Reproduces the host's `failureSnapshot(err)` byte-for-byte: the
1538
- // 'failed' sentinel id and NO `cost`, which is what separates an
1539
- // errored submit from a priced budget rejection
1540
- // (civitai/civitai-app-starters#251). Checked FIRST — a host-side
1541
- // throw pre-empts every server-side decision below.
1542
- if (gen.failSubmitException === true) {
1698
+ // 🔴 THE SPEND-SCOPE GATE. civitai's `submitWorkflow` throws
1699
+ // `FORBIDDEN` with this message as its first act after
1700
+ // authenticating the token — before the budget, the training
1701
+ // confirmation, the currency resolution and any debit — and the page
1702
+ // host's SUBMIT_WORKFLOW handler catches the rejected mutation into
1703
+ // `failureSnapshot(err)`: the `'failed'` sentinel, NO `cost`. So
1704
+ // `submit()` rejects as `'exception'`, and `withConsentRetry` (the
1705
+ // token lacks `ai:write:budgeted`) prompts, this mock grants and
1706
+ // pushes TOKEN_REFRESH, and the SAME-KEY retry lands below. Nothing
1707
+ // is debited and no workflow is recorded on this arm.
1708
+ if (!consentGranted) {
1709
+ dispatchToBlock({
1710
+ type: 'WORKFLOW_SUBMITTED',
1711
+ payload: {
1712
+ requestId,
1713
+ snapshot: { workflowId: 'failed', status: 'failed', error: MOCK_SPEND_SCOPE_ERROR },
1714
+ },
1715
+ });
1716
+ return;
1717
+ }
1718
+ // A `kind: 'training'` body is never started by SUBMIT_WORKFLOW: the
1719
+ // server charges a training run only against a quote a signed-in
1720
+ // session confirmed, which only RUN_TRAINING's dialog records. The
1721
+ // server's own refusal, in the host's `failureSnapshot` shape.
1722
+ // (`useBuzzWorkflow().submit()` refuses before sending; this covers a
1723
+ // raw transport send.)
1724
+ if (body.kind === 'training') {
1543
1725
  dispatchToBlock({
1544
1726
  type: 'WORKFLOW_SUBMITTED',
1545
1727
  payload: {
@@ -1547,12 +1729,32 @@ export function createMockHost(options = {}) {
1547
1729
  snapshot: {
1548
1730
  workflowId: 'failed',
1549
1731
  status: 'failed',
1550
- error: gen.failSubmitExceptionMessage ?? 'mock: submit failed',
1732
+ error: 'this training run has not been confirmed by the viewer in this session',
1551
1733
  },
1552
1734
  },
1553
1735
  });
1554
1736
  return;
1555
1737
  }
1738
+ // A `kind: 'workflow'` body the host will not run is refused here,
1739
+ // before any cap, balance or debit — the same refusal, with the same
1740
+ // `errorCode`, the estimate gives. No `cost`, so `submit()` rejects.
1741
+ if (body.kind === 'workflow') {
1742
+ const refusal = mockWorkflowBodyRefusal(body) ??
1743
+ (gen.workflowUnpriced === true ? mockUnpricedWorkflowRefusal(body) : null);
1744
+ if (refusal !== null) {
1745
+ dispatchToBlock({
1746
+ type: 'WORKFLOW_SUBMITTED',
1747
+ payload: {
1748
+ requestId,
1749
+ snapshot: { workflowId: 'failed', status: 'failed', ...refusal },
1750
+ },
1751
+ });
1752
+ return;
1753
+ }
1754
+ }
1755
+ const cost = costFor(body);
1756
+ // What the viewer pays: the generation plus the app fee.
1757
+ const charged = cost + (authorFeeFor(body) ?? 0);
1556
1758
  // Disallowed-account path (content-rating clamp): the real backend
1557
1759
  // rejects a picked pool outside the app's maturity policy at the
1558
1760
  // currency-resolution boundary — BEFORE any Buzz spend — so this is
@@ -1697,7 +1899,12 @@ export function createMockHost(options = {}) {
1697
1899
  if (balanceSimulated)
1698
1900
  buzz.balance = buzz.balance - charged;
1699
1901
  const workflowId = `wf_${submitCount}_${Date.now()}`;
1700
- workflows.set(workflowId, { polls: 0, cost, body });
1902
+ workflows.set(workflowId, {
1903
+ polls: 0,
1904
+ cost,
1905
+ body,
1906
+ ...(body.kind === 'workflow' ? { stepCosts: workflowStepCosts(body) } : {}),
1907
+ });
1701
1908
  dispatchToBlock({
1702
1909
  type: 'WORKFLOW_SUBMITTED',
1703
1910
  payload: { requestId, snapshot: { workflowId, status: 'pending' } },
@@ -1723,6 +1930,16 @@ export function createMockHost(options = {}) {
1723
1930
  }
1724
1931
  const polls = wf.polls + 1;
1725
1932
  wf.polls = polls;
1933
+ // A `kind: 'workflow'` run with a step told to fail ends `failed`
1934
+ // with its earlier steps intact (`generation.failWorkflowStep`).
1935
+ const partialFailure = polls >= pollsUntilDone ? workflowPartialFailure(workflowId) : null;
1936
+ if (partialFailure !== null) {
1937
+ dispatchToBlock({
1938
+ type: 'WORKFLOW_STATUS',
1939
+ payload: { requestId, snapshot: partialFailure },
1940
+ });
1941
+ return;
1942
+ }
1726
1943
  const outcome = polls >= pollsUntilDone ? (gen.workflowOutcome?.(workflowId, wf.body) ?? 'succeeded') : null;
1727
1944
  if (outcome === 'failed' || outcome === 'expired') {
1728
1945
  dispatchToBlock({
@@ -1753,7 +1970,17 @@ export function createMockHost(options = {}) {
1753
1970
  else {
1754
1971
  dispatchToBlock({
1755
1972
  type: 'WORKFLOW_STATUS',
1756
- payload: { requestId, snapshot: { workflowId, status: 'processing' } },
1973
+ payload: {
1974
+ requestId,
1975
+ snapshot: {
1976
+ workflowId,
1977
+ status: 'processing',
1978
+ // A multi-step run reports how far its steps have got.
1979
+ ...(wf.body?.kind === 'workflow'
1980
+ ? workflowProgressView(workflowId, wf.body, polls)
1981
+ : {}),
1982
+ },
1983
+ },
1757
1984
  });
1758
1985
  }
1759
1986
  return;
@@ -1774,6 +2001,14 @@ export function createMockHost(options = {}) {
1774
2001
  if (wf && !wf.canceled && wf.polls >= pollsUntilDone) {
1775
2002
  // Already finished: like the real host, report the real terminal
1776
2003
  // status rather than pretending the cancel took.
2004
+ const partialFailure = workflowPartialFailure(workflowId);
2005
+ if (partialFailure !== null) {
2006
+ dispatchToBlock({
2007
+ type: 'WORKFLOW_CANCELED',
2008
+ payload: { requestId, snapshot: partialFailure },
2009
+ });
2010
+ return;
2011
+ }
1777
2012
  const outcome = gen.workflowOutcome?.(workflowId, wf.body) ?? 'succeeded';
1778
2013
  dispatchToBlock({
1779
2014
  type: 'WORKFLOW_CANCELED',
@@ -2111,7 +2346,7 @@ export function createMockHost(options = {}) {
2111
2346
  return reply({ error: 'invalid training dataset' });
2112
2347
  }
2113
2348
  if (!consentGranted)
2114
- return reply({ error: MOCK_TRAINING_SCOPE_ERROR });
2349
+ return reply({ error: MOCK_SPEND_SCOPE_ERROR });
2115
2350
  if (trainingDatasetError !== undefined)
2116
2351
  return reply({ error: trainingDatasetError });
2117
2352
  const named = new Set(items.map((it) => it.imageId));
@@ -2154,7 +2389,7 @@ export function createMockHost(options = {}) {
2154
2389
  return reply({ error: 'invalid training request' });
2155
2390
  }
2156
2391
  if (!consentGranted)
2157
- return reply({ error: MOCK_TRAINING_SCOPE_ERROR });
2392
+ return reply({ error: MOCK_SPEND_SCOPE_ERROR });
2158
2393
  const quote = trainingQuotes.get(b.quoteId);
2159
2394
  if (!quote || quote.spent || Date.now() >= quote.expiresAtMs) {
2160
2395
  return reply({ error: MOCK_TRAINING_QUOTE_GONE_ERROR });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The mock host's checks on a multi-step `kind: 'workflow'` body: its shape,
3
+ * its step cap, its step names and reference graph, and the step-type denylist.
4
+ *
5
+ * A MIRROR of the host's rules, kept in its own pure module so the estimate, the
6
+ * batch estimate and the submit all refuse the same bodies, in the same order,
7
+ * with the same `errorCode`.
8
+ *
9
+ * 🔴 AN APPROXIMATION, NOT THE HOST. It checks WHERE a reference points, never
10
+ * whether its `path` exists, and it prices nothing. A body this accepts can
11
+ * still be refused by the real host or by the orchestrator.
12
+ */
13
+ /**
14
+ * The bounds the mock enforces. `maxSteps` is a COPY of
15
+ * `@civitai/app-sdk/blocks`'s `BLOCK_WORKFLOW_MAX_STEPS`, deliberately not
16
+ * value-imported: a value import of a symbol the newest PUBLISHED app-sdk lacks
17
+ * raises this package's peer floor
18
+ * (`tests/guards/blocks-react-peer-floor.test.mjs`). It is held to the SDK's
19
+ * value by `test/mockHostWorkflowKind.test.tsx`, which imports both.
20
+ */
21
+ export declare const MOCK_WORKFLOW_BOUNDS: {
22
+ /** Steps in one body. */
23
+ readonly maxSteps: 8;
24
+ /** Reference objects across every step input of one body. */
25
+ readonly maxRefs: 64;
26
+ /** Nesting depth of one step `input`. */
27
+ readonly maxInputDepth: 32;
28
+ /** How deep a `repeat` template may nest another `repeat`. */
29
+ readonly maxRepeatNesting: 4;
30
+ /** Serialized bytes of every step `input` plus `arguments`, summed. */
31
+ readonly maxInputBytes: 262144;
32
+ /** Characters of a step `$type`, and of a step `name` on the wire. */
33
+ readonly maxTypeChars: 64;
34
+ /**
35
+ * Iterations of one top-level `repeat` step, nested repeats MULTIPLIED. A
36
+ * COPY of `@civitai/app-sdk/blocks`'s `BLOCK_WORKFLOW_REPEAT_MAX_ITERATIONS`,
37
+ * held to it by the same test as `maxSteps`.
38
+ */
39
+ readonly maxRepeatIterations: 16;
40
+ };
41
+ /**
42
+ * Orchestrator `$type`s a `kind: 'workflow'` step may not name: the host's
43
+ * platform-internal list (scanners, moderation classifiers, hashing and model
44
+ * ingestion, the catalog's platform internals, web scrape/search and the
45
+ * retired `textToImage` step type) plus `promptModeration`. Matched
46
+ * case-insensitively, as the host matches. Anything else is accepted,
47
+ * including a `$type` this list has never heard of.
48
+ */
49
+ export declare const MOCK_WORKFLOW_DENIED_STEP_TYPES: readonly string[];
50
+ /** A refused body: the reply's `error` and, where one is defined, its `errorCode`. */
51
+ export interface MockWorkflowRefusal {
52
+ error: string;
53
+ errorCode?: 'workflow_step_denied' | 'workflow_ref_invalid' | 'workflow_too_many_steps' | 'workflow_repeat_unbounded' | 'training-quote-unavailable';
54
+ }
55
+ /** What a step is called in a reference and on the snapshot: its `name`, or `'$<index>'`. */
56
+ export declare const mockWorkflowStepName: (step: {
57
+ name?: unknown;
58
+ }, index: number) => string;
59
+ /** The refusal for a `repeat` whose shape or nesting the mock cannot follow. */
60
+ export declare const MOCK_REPEAT_SHAPE_ERROR: string;
61
+ /** The refusal for a `merge` whose sources the mock cannot read. */
62
+ export declare const MOCK_MERGE_SHAPE_ERROR = "a merge step needs a \"fields\" object whose entries each carry a string \"$ref\" and a \"reduce\"";
63
+ /**
64
+ * How many generations a step runs: 1, or a `repeat`'s iteration count with
65
+ * nested repeats multiplied (never less than 1). Only meaningful for a body
66
+ * {@link mockWorkflowBodyRefusal} accepted.
67
+ */
68
+ export declare function mockWorkflowStepGenerations(stepType: string, input: unknown, args: unknown): number;
69
+ /** The host's refusal text for an unquoted training step (`UNQUOTED_TRAINING_ERROR`). */
70
+ export declare const MOCK_UNQUOTED_TRAINING_ERROR = "Training needs a price quote and none could be obtained; try again.";
71
+ /**
72
+ * The refusal for a body {@link mockWorkflowBodyRefusal} ACCEPTED when the host
73
+ * could get NO price quote for it, or `null` when it would proceed (reserved at
74
+ * the ceiling). In the host's order: a training step anywhere (its own
75
+ * `training-quote-*` refusal — the mock reports `'training-quote-unavailable'`,
76
+ * the code for "no usable answer"), then a `repeat` anywhere
77
+ * (`workflow_repeat_unbounded`, naming the first such step).
78
+ */
79
+ export declare function mockUnpricedWorkflowRefusal(body: {
80
+ steps: ReadonlyArray<{
81
+ $type: string;
82
+ input: unknown;
83
+ }>;
84
+ }): MockWorkflowRefusal | null;
85
+ /**
86
+ * The refusal for a `kind: 'workflow'` body, or `null` for one the mock will
87
+ * price and run. In the host's order:
88
+ *
89
+ * 1. the wire shape — no `errorCode`, the host's schema rejection;
90
+ * 2. the step cap — `workflow_too_many_steps`;
91
+ * 3. step by step: the name rules and a `repeat`'s exact shape and nesting
92
+ * (`workflow_ref_invalid`); the repeat bound — only over an array in
93
+ * `arguments`, at most 16 iterations with nested repeats multiplied
94
+ * (`workflow_repeat_unbounded`); then the reference graph
95
+ * (`workflow_ref_invalid`). A name is `[a-z][a-z0-9_-]{0,31}` (or the
96
+ * step's own `$<index>`), unique, and not `block-step`; every source — a
97
+ * reference, a `repeat`'s `for`, a `merge` field — names `$arguments` or an
98
+ * EARLIER step, and a `merge` field never names a repeat variable;
99
+ * 4. a repeat `as` name that is also a step name — `workflow_ref_invalid`;
100
+ * 5. the step-type denylist on every step and on the step a `repeat` runs —
101
+ * `workflow_step_denied`.
102
+ */
103
+ export declare function mockWorkflowBodyRefusal(body: unknown): MockWorkflowRefusal | null;
104
+ //# sourceMappingURL=mockWorkflowGraph.d.ts.map