@civitai/blocks-react 0.67.1 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 { BATCH_ESTIMATE_CELL_TIMEOUT_ERROR } from '../hooks/useBatchEstimate.js';
46
47
  import { RUN_TRAINING_ERROR_CODES } from '../hooks/useRunTraining.js';
47
48
  import { hostContextWithTheme } from '../transport/transport.js';
48
49
  import { isRoutableRequestId } from '../transport/requestId.js';
@@ -195,6 +196,15 @@ export const MOCK_TRAINING_NONE_IMPORTED_ERROR = 'none of the requested images c
195
196
  * server made AFTER its claim, so it consumes the quote.
196
197
  */
197
198
  const PRE_CLAIM_RUN_TRAINING_ERRORS = new Set(RUN_TRAINING_ERROR_CODES.filter((c) => c !== 'submission-unconfirmed'));
199
+ /**
200
+ * The host's cell cap for one `ESTIMATE_WORKFLOW_BATCH` (civitai/civitai
201
+ * `BLOCK_ESTIMATE_BATCH_MAX_CELLS`). The hook's `BATCH_ESTIMATE_MAX_CELLS` is the
202
+ * same number; the mock keeps its own copy so it refuses exactly as the host does
203
+ * when a block bypasses the hook.
204
+ */
205
+ const MOCK_ESTIMATE_BATCH_MAX_CELLS = 16;
206
+ /** The server's per-cell refusal of a `kind: 'training'` body inside a batch. */
207
+ const MOCK_TRAINING_IN_BATCH_ERROR = 'a training estimate cannot be part of a batch — estimate it on its own';
198
208
  /** Default quote total for a `kind: 'training'` estimate when {@link MockHostOptions.trainingQuoteTotal} is unset. */
199
209
  const DEFAULT_TRAINING_QUOTE_TOTAL = 500;
200
210
  /** The real quote lifetime (`BLOCK_TRAINING_QUOTE_TTL_SECONDS`, 15 min). */
@@ -229,6 +239,24 @@ const DEFAULT_LORA_PICK = {
229
239
  trainedWords: ['sinfully stylish'],
230
240
  clipSkip: null,
231
241
  };
242
+ // The second curated LoRA a MULTI-select request returns after
243
+ // DEFAULT_LORA_PICK. Every field differs from the first so a block that mixes
244
+ // two picks up (or renders one twice) shows it.
245
+ const DEFAULT_SECOND_LORA_PICK = {
246
+ versionId: 777003,
247
+ modelId: 555003,
248
+ modelName: 'Ink Wash Lines',
249
+ versionName: 'v1.1',
250
+ baseModel: 'SDXL 1.0',
251
+ modelType: 'LORA',
252
+ strength: 0.8,
253
+ minStrength: -1,
254
+ maxStrength: 2,
255
+ trainedWords: ['ink wash'],
256
+ clipSkip: null,
257
+ };
258
+ /** The multi-select cap — the real host's, and `additionalResources`'. */
259
+ const MOCK_RESOURCE_PICKER_MULTI_MAX = 5;
232
260
  /**
233
261
  * The canned image the mock host "returns" from `OPEN_IMAGE_UPLOAD`. Mirrors the
234
262
  * host's moderated {@link BlockUploadedImageInfo} projection (imageId/nsfwLevel/
@@ -601,7 +629,7 @@ function jsonByteSize(value) {
601
629
  /**
602
630
  * Reads the URL query toggles the gen-matrix dev harness uses, so a starter's
603
631
  * dev harness keeps working with `?viewer/?consent/?fail/?theme/?pick/?pickCkpt`.
604
- * Layer-1 additions: `?balance/?latency/?costPerGen/?failNext/?failRate/?seed`
632
+ * Layer-1 additions: `?balance/?latency/?costPerGen/?authorFee/?failNext/?failRate/?seed`
605
633
  * map onto the new scenario groups so a dev can flip out-of-Buzz /
606
634
  * failures / latency without editing code. `?capRefusal=1` (or `=<text>`) sets
607
635
  * {@link MockGenerationScenario.submitCapRefusal}.
@@ -693,6 +721,15 @@ export function readMockHostUrlOptions(win = globalThis
693
721
  if (costPerGen !== null && Number.isFinite(Number(costPerGen))) {
694
722
  generation.costPerGen = Number(costPerGen);
695
723
  }
724
+ // `?authorFee=<n>` itemises an app fee on the quote; `?authorFee=off`
725
+ // simulates a host that predates the field.
726
+ const authorFee = params.get('authorFee');
727
+ if (authorFee === 'off') {
728
+ generation.authorFee = false;
729
+ }
730
+ else if (authorFee !== null && authorFee !== '' && Number.isFinite(Number(authorFee))) {
731
+ generation.authorFee = Number(authorFee);
732
+ }
696
733
  const failNext = params.get('failNext');
697
734
  if (failNext !== null && Number.isInteger(Number(failNext))) {
698
735
  generation.failNext = Number(failNext);
@@ -799,6 +836,9 @@ export function createMockHost(options = {}) {
799
836
  // Legacy `cost` feeds costPerGen unless the scenario set its own.
800
837
  let legacyCost = options.cost ?? 8;
801
838
  let cannedPicks = options.cannedPicks ?? { Checkpoint: DEFAULT_CHECKPOINT_PICK, LORA: DEFAULT_LORA_PICK };
839
+ // Canned MULTI-select picks. `undefined` = derive from the single LoRA pick
840
+ // (see the option's docs); `null` = dismissed.
841
+ let cannedMultiPicks = options.cannedMultiPicks;
802
842
  // Canned image-upload result. `null` = dismissed; undefined = default image.
803
843
  let cannedImageUpload = options.cannedImageUpload === undefined ? DEFAULT_IMAGE_UPLOAD : options.cannedImageUpload;
804
844
  // Canned generationSource-upload result (purpose:'generationSource').
@@ -899,6 +939,25 @@ export function createMockHost(options = {}) {
899
939
  const sharedStore = new Map();
900
940
  let sharedSeq = 0;
901
941
  let sharedFailNext = sharedScenario.failNext ?? 0;
942
+ let sharedRateLimitNext = sharedScenario.rateLimitNext ?? 0;
943
+ let sharedRateLimitMeta = sharedScenario.rateLimit;
944
+ /**
945
+ * When a {@link MockSharedScenario.rateLimitNext} refusal is pending, consume
946
+ * one and return the reply's error fields; otherwise `null`. The message
947
+ * matches the platform's wording per write family
948
+ * (`apps-shared.router.ts`: submissions / votes / withdrawals / reports).
949
+ */
950
+ const takeSharedRateLimit = (noun) => {
951
+ if (sharedRateLimitNext <= 0)
952
+ return null;
953
+ sharedRateLimitNext -= 1;
954
+ const retryAfterSeconds = sharedRateLimitMeta?.retryAfterSeconds ?? 60;
955
+ const retryAt = sharedRateLimitMeta?.retryAt ?? new Date(Date.now() + retryAfterSeconds * 1000).toISOString();
956
+ return {
957
+ error: `Too many ${noun} — retry in ${retryAfterSeconds}s`,
958
+ rateLimit: { retryAfterSeconds, retryAt },
959
+ };
960
+ };
902
961
  const sharedNow = new Date().toISOString();
903
962
  // Seed newest-LAST so the last-listed seed has the highest seq (newest-first).
904
963
  for (const s of sharedScenario.seed ?? []) {
@@ -929,6 +988,24 @@ export function createMockHost(options = {}) {
929
988
  const spec = gen.costPerGen ?? legacyCost;
930
989
  return typeof spec === 'function' ? spec(body) : (spec ?? legacyCost);
931
990
  };
991
+ // `null` = this quote is not itemised (a fee-less kind, or `authorFee: false`).
992
+ const authorFeeFor = (body) => {
993
+ if (gen.authorFee === false)
994
+ return null;
995
+ // A REGISTERED step is keyed on the VALUE of `step`, as the host keys it —
996
+ // never `'step' in body`: `{ kind: 'step', step: undefined, ... }` is a legal
997
+ // pass-through body, and a pass-through quote prices no fee.
998
+ const quotesFee = body.kind === 'textToImage' || (body.kind === 'step' && typeof body.step === 'string');
999
+ if (!quotesFee)
1000
+ return null;
1001
+ const spec = gen.authorFee ?? 0;
1002
+ return typeof spec === 'function' ? spec(body) : spec;
1003
+ };
1004
+ /** The `cost` of a QUOTE: the fee is inside `total` and itemised beside it. */
1005
+ const quotedCost = (body) => {
1006
+ const fee = authorFeeFor(body);
1007
+ return fee === null ? { total: costFor(body) } : { total: costFor(body) + fee, authorFee: fee };
1008
+ };
932
1009
  const latencyFor = () => {
933
1010
  const l = gen.latencyMs;
934
1011
  if (l === undefined)
@@ -1007,6 +1084,9 @@ export function createMockHost(options = {}) {
1007
1084
  let tokenSerial = 0;
1008
1085
  let submitCount = 0;
1009
1086
  // body + cost remembered per workflow so the succeeded snapshot can echo them.
1087
+ // `body` is absent for an id this mock never submitted (a workflow id an app
1088
+ // restored after a reload). `canceled` is set by `CANCEL_WORKFLOW`, so later
1089
+ // polls keep answering `canceled` instead of resuming the countdown.
1010
1090
  const workflows = new Map();
1011
1091
  // Training: datasets this install prepared (id → admitted count) and the
1012
1092
  // quotes its estimates stored (id → what a run is checked against).
@@ -1156,6 +1236,79 @@ export function createMockHost(options = {}) {
1156
1236
  return;
1157
1237
  }
1158
1238
  }
1239
+ /**
1240
+ * The snapshot ONE estimate of `body` replies with. Shared by
1241
+ * ESTIMATE_WORKFLOW and ESTIMATE_WORKFLOW_BATCH so a batch cell is priced
1242
+ * exactly as a single estimate of the same body.
1243
+ */
1244
+ const estimateSnapshotFor = (body) => {
1245
+ // UNUSABLE-ESTIMATE simulation (`generation.failEstimate`). Reproduces
1246
+ // the two real producers byte-for-byte so a block's `catch` around
1247
+ // `estimate()` is exercisable locally — see the knob's docs for why
1248
+ // that was previously impossible (civitai/civitai#4159).
1249
+ if (gen.failEstimate === 'failed') {
1250
+ // Mirrors the host's `failureSnapshot(err)` exactly: the 'failed'
1251
+ // sentinel id (a real one would be empty and get dropped by the
1252
+ // inbound validator), no `cost`.
1253
+ return {
1254
+ workflowId: 'failed',
1255
+ status: 'failed',
1256
+ error: gen.failEstimateMessage ?? 'mock: estimate failed',
1257
+ };
1258
+ }
1259
+ if (gen.failEstimate === 'no-cost') {
1260
+ // A SUCCESSFUL snapshot that simply has no price — no `error` to
1261
+ // explain it, which is what makes this producer the harder of the
1262
+ // two to diagnose from the block side.
1263
+ return { workflowId: 'wf_estimate', status: 'pending' };
1264
+ }
1265
+ // TRAINING ESTIMATE — KIND-FAITHFUL, the one exception to the
1266
+ // 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.
1271
+ if (body.kind === 'training') {
1272
+ const refuse = (error) => ({
1273
+ workflowId: 'failed',
1274
+ status: 'failed',
1275
+ error,
1276
+ });
1277
+ if (!consentGranted)
1278
+ return refuse(MOCK_TRAINING_SCOPE_ERROR);
1279
+ const imageCount = trainingDatasets.get(body.datasetId);
1280
+ if (imageCount === undefined)
1281
+ return refuse(MOCK_TRAINING_DATASET_GONE_ERROR);
1282
+ const total = trainingQuoteTotal;
1283
+ if (total > MOCK_TRAINING_BOUNDS.maxBuzzPerRun) {
1284
+ return refuse(`this training run costs ${total} Buzz, above the per-run limit of ${MOCK_TRAINING_BOUNDS.maxBuzzPerRun} for apps`);
1285
+ }
1286
+ const quoteId = trainingHandle('tq_');
1287
+ const expiresAtMs = Date.now() + TRAINING_QUOTE_TTL_MS;
1288
+ trainingQuotes.set(quoteId, {
1289
+ datasetId: body.datasetId,
1290
+ total,
1291
+ expiresAtMs,
1292
+ spent: false,
1293
+ });
1294
+ return {
1295
+ workflowId: 'wf_estimate',
1296
+ status: 'pending',
1297
+ cost: { total },
1298
+ trainingQuote: {
1299
+ quoteId,
1300
+ total,
1301
+ imageCount,
1302
+ expiresAt: new Date(expiresAtMs).toISOString(),
1303
+ },
1304
+ };
1305
+ }
1306
+ return {
1307
+ workflowId: 'wf_estimate',
1308
+ status: 'pending',
1309
+ cost: quotedCost(body),
1310
+ };
1311
+ };
1159
1312
  switch (typed.type) {
1160
1313
  case 'REQUEST_TOKEN':
1161
1314
  dispatchToBlock({
@@ -1263,94 +1416,86 @@ export function createMockHost(options = {}) {
1263
1416
  // a registry (that's server-only); the mock accepts any id, fail-open. The
1264
1417
  // sentinel `workflowId` is non-empty so the snapshot survives the
1265
1418
  // SDK inbound validator (which drops empty-workflowId snapshots).
1419
+ // The pricing itself is `estimateSnapshotFor`, shared with the batch.
1266
1420
  const body = typed.payload?.body ?? {};
1267
- // UNUSABLE-ESTIMATE simulation (`generation.failEstimate`). Reproduces
1268
- // the two real producers byte-for-byte so a block's `catch` around
1269
- // `estimate()` is exercisable locally — see the knob's docs for why
1270
- // that was previously impossible (civitai/civitai#4159).
1271
- if (gen.failEstimate === 'failed') {
1421
+ dispatchToBlock({
1422
+ type: 'ESTIMATE_RESULT',
1423
+ payload: { requestId, snapshot: estimateSnapshotFor(body) },
1424
+ });
1425
+ return;
1426
+ }
1427
+ case 'ESTIMATE_WORKFLOW_BATCH': {
1428
+ // The batch twin of ESTIMATE_WORKFLOW, in the host's order: the
1429
+ // request shape, then the list, then each cell through
1430
+ // `estimateSnapshotFor` — the function ESTIMATE_WORKFLOW prices with,
1431
+ // so a cell is priced exactly as one estimate of that body would be.
1432
+ // Nothing is submitted and no Buzz moves.
1433
+ if (!isRoutableRequestId(requestId))
1434
+ return;
1435
+ const mode = gen.batchEstimate ?? 'supported';
1436
+ // A host that predates the message sends nothing at all.
1437
+ if (mode === 'silent')
1438
+ return;
1439
+ if (mode === 'unsupported') {
1272
1440
  dispatchToBlock({
1273
- type: 'ESTIMATE_RESULT',
1274
- payload: {
1275
- requestId,
1276
- // Mirrors the host's `failureSnapshot(err)` exactly: the
1277
- // 'failed' sentinel id (a real one would be empty and get
1278
- // dropped by the inbound validator), no `cost`.
1279
- snapshot: {
1280
- workflowId: 'failed',
1281
- status: 'failed',
1282
- error: gen.failEstimateMessage ?? 'mock: estimate failed',
1283
- },
1284
- },
1441
+ type: 'ESTIMATE_BATCH_RESULT',
1442
+ payload: { requestId, error: 'unsupported on this host' },
1285
1443
  });
1286
1444
  return;
1287
1445
  }
1288
- if (gen.failEstimate === 'no-cost') {
1446
+ const bodies = typed.payload?.bodies;
1447
+ if (!Array.isArray(bodies) || bodies.length === 0) {
1289
1448
  dispatchToBlock({
1290
- type: 'ESTIMATE_RESULT',
1291
- // A SUCCESSFUL snapshot that simply has no price — no `error` to
1292
- // explain it, which is what makes this producer the harder of the
1293
- // two to diagnose from the block side.
1294
- payload: { requestId, snapshot: { workflowId: 'wf_estimate', status: 'pending' } },
1449
+ type: 'ESTIMATE_BATCH_RESULT',
1450
+ payload: { requestId, error: 'invalid estimate batch' },
1295
1451
  });
1296
1452
  return;
1297
1453
  }
1298
- // TRAINING ESTIMATE — KIND-FAITHFUL, the one exception to the
1299
- // kind-agnostic rule above, because the real arm is not a priced
1300
- // pass-through: it needs the spend scope, names a dataset the server
1301
- // must hold, refuses a price above the per-run ceiling, and STORES a
1302
- // quote the run is later checked against. Each refusal is the
1303
- // server's message in the host's `failureSnapshot` shape.
1304
- if (body.kind === 'training') {
1305
- const refuse = (error) => dispatchToBlock({
1306
- type: 'ESTIMATE_RESULT',
1307
- payload: { requestId, snapshot: { workflowId: 'failed', status: 'failed', error } },
1308
- });
1309
- if (!consentGranted)
1310
- return refuse(MOCK_TRAINING_SCOPE_ERROR);
1311
- const imageCount = trainingDatasets.get(body.datasetId);
1312
- if (imageCount === undefined)
1313
- return refuse(MOCK_TRAINING_DATASET_GONE_ERROR);
1314
- const total = trainingQuoteTotal;
1315
- if (total > MOCK_TRAINING_BOUNDS.maxBuzzPerRun) {
1316
- return refuse(`this training run costs ${total} Buzz, above the per-run limit of ${MOCK_TRAINING_BOUNDS.maxBuzzPerRun} for apps`);
1317
- }
1318
- const quoteId = trainingHandle('tq_');
1319
- const expiresAtMs = Date.now() + TRAINING_QUOTE_TTL_MS;
1320
- trainingQuotes.set(quoteId, {
1321
- datasetId: body.datasetId,
1322
- total,
1323
- expiresAtMs,
1324
- spent: false,
1325
- });
1454
+ if (bodies.length > MOCK_ESTIMATE_BATCH_MAX_CELLS) {
1326
1455
  dispatchToBlock({
1327
- type: 'ESTIMATE_RESULT',
1328
- payload: {
1329
- requestId,
1330
- snapshot: {
1331
- workflowId: 'wf_estimate',
1332
- status: 'pending',
1333
- cost: { total },
1334
- trainingQuote: {
1335
- quoteId,
1336
- total,
1337
- imageCount,
1338
- expiresAt: new Date(expiresAtMs).toISOString(),
1339
- },
1340
- },
1341
- },
1456
+ type: 'ESTIMATE_BATCH_RESULT',
1457
+ payload: { requestId, error: 'estimate batch too large' },
1342
1458
  });
1343
1459
  return;
1344
1460
  }
1461
+ const snapshots = bodies.map((cell, index) => {
1462
+ // The host's snapshot for a cell its call budget ran out on.
1463
+ if (gen.batchEstimateTimedOutCells?.includes(index)) {
1464
+ return {
1465
+ workflowId: 'failed',
1466
+ status: 'failed',
1467
+ error: BATCH_ESTIMATE_CELL_TIMEOUT_ERROR,
1468
+ };
1469
+ }
1470
+ // The server refuses a training cell in a batch: a training
1471
+ // estimate stores the quote the viewer later confirms, so it is
1472
+ // estimated on its own. Refused BEFORE `estimateSnapshotFor`, which
1473
+ // would store one.
1474
+ if (cell?.kind === 'training') {
1475
+ return {
1476
+ workflowId: 'failed',
1477
+ status: 'failed',
1478
+ error: MOCK_TRAINING_IN_BATCH_ERROR,
1479
+ };
1480
+ }
1481
+ return estimateSnapshotFor(cell ?? {});
1482
+ });
1483
+ // The server's aggregate rule: the cells that priced, i.e. not
1484
+ // `'failed'` and carrying a numeric `cost.total`.
1485
+ let total = 0;
1486
+ let pricedCells = 0;
1487
+ for (const snapshot of snapshots) {
1488
+ if (snapshot.status === 'failed' || typeof snapshot.cost?.total !== 'number')
1489
+ continue;
1490
+ total += snapshot.cost.total;
1491
+ pricedCells += 1;
1492
+ }
1345
1493
  dispatchToBlock({
1346
- type: 'ESTIMATE_RESULT',
1494
+ type: 'ESTIMATE_BATCH_RESULT',
1347
1495
  payload: {
1348
1496
  requestId,
1349
- snapshot: {
1350
- workflowId: 'wf_estimate',
1351
- status: 'pending',
1352
- cost: { total: costFor(body) },
1353
- },
1497
+ snapshots,
1498
+ aggregate: { total, pricedCells, cellCount: snapshots.length },
1354
1499
  },
1355
1500
  });
1356
1501
  return;
@@ -1386,6 +1531,8 @@ export function createMockHost(options = {}) {
1386
1531
  return;
1387
1532
  }
1388
1533
  const cost = costFor(body);
1534
+ // What the viewer pays: the generation plus the app fee.
1535
+ const charged = cost + (authorFeeFor(body) ?? 0);
1389
1536
  // CAUGHT-SERVER-EXCEPTION simulation (`generation.failSubmitException`).
1390
1537
  // Reproduces the host's `failureSnapshot(err)` byte-for-byte: the
1391
1538
  // 'failed' sentinel id and NO `cost`, which is what separates an
@@ -1453,7 +1600,7 @@ export function createMockHost(options = {}) {
1453
1600
  // presence is what makes `submit()` RESOLVE this rather than
1454
1601
  // reject it as an errored submit
1455
1602
  // (civitai/civitai-app-starters#251). Do not drop it.
1456
- cost: { total: cost },
1603
+ cost: quotedCost(body),
1457
1604
  error: gen.submitCapRefusal === true
1458
1605
  ? DEFAULT_SUBMIT_CAP_REFUSAL
1459
1606
  : gen.submitCapRefusal,
@@ -1468,7 +1615,7 @@ export function createMockHost(options = {}) {
1468
1615
  const insufficient = failMode === 'all' ||
1469
1616
  failMode === 'insufficient' ||
1470
1617
  buzz.insufficient === true ||
1471
- (balanceSimulated && buzz.balance < cost);
1618
+ (balanceSimulated && buzz.balance < charged);
1472
1619
  // Generic generation failure: failNext countdown, failRate dice, or
1473
1620
  // the legacy 'some' (~1 in 3) mode.
1474
1621
  let genericFail = false;
@@ -1548,7 +1695,7 @@ export function createMockHost(options = {}) {
1548
1695
  }
1549
1696
  // Success path: debit the simulated balance + remember body/cost.
1550
1697
  if (balanceSimulated)
1551
- buzz.balance = buzz.balance - cost;
1698
+ buzz.balance = buzz.balance - charged;
1552
1699
  const workflowId = `wf_${submitCount}_${Date.now()}`;
1553
1700
  workflows.set(workflowId, { polls: 0, cost, body });
1554
1701
  dispatchToBlock({
@@ -1559,11 +1706,35 @@ export function createMockHost(options = {}) {
1559
1706
  }
1560
1707
  case 'POLL_WORKFLOW': {
1561
1708
  const workflowId = typed.payload?.workflowId ?? '';
1562
- const wf = workflows.get(workflowId);
1563
- const polls = (wf?.polls ?? 0) + 1;
1564
- if (wf)
1565
- wf.polls = polls;
1566
- if (polls >= pollsUntilDone) {
1709
+ // An id this mock did not submit (e.g. restored from storage after a
1710
+ // reload) gets a record on its first poll, so it counts down to done
1711
+ // like any other instead of answering `processing` forever.
1712
+ let wf = workflows.get(workflowId);
1713
+ if (!wf) {
1714
+ wf = { polls: 0, cost: legacyCost };
1715
+ workflows.set(workflowId, wf);
1716
+ }
1717
+ if (wf.canceled) {
1718
+ dispatchToBlock({
1719
+ type: 'WORKFLOW_STATUS',
1720
+ payload: { requestId, snapshot: { workflowId, status: 'canceled' } },
1721
+ });
1722
+ return;
1723
+ }
1724
+ const polls = wf.polls + 1;
1725
+ wf.polls = polls;
1726
+ const outcome = polls >= pollsUntilDone ? (gen.workflowOutcome?.(workflowId, wf.body) ?? 'succeeded') : null;
1727
+ if (outcome === 'failed' || outcome === 'expired') {
1728
+ dispatchToBlock({
1729
+ type: 'WORKFLOW_STATUS',
1730
+ payload: {
1731
+ requestId,
1732
+ snapshot: { workflowId, status: outcome, error: `mock: workflow ${outcome}` },
1733
+ },
1734
+ });
1735
+ return;
1736
+ }
1737
+ if (outcome === 'succeeded') {
1567
1738
  // Apply synthetic latency on the terminal (succeeded) poll only.
1568
1739
  const delay = latencyFor();
1569
1740
  if (delay > 0) {
@@ -1589,7 +1760,35 @@ export function createMockHost(options = {}) {
1589
1760
  }
1590
1761
  case 'CANCEL_WORKFLOW': {
1591
1762
  const workflowId = typed.payload?.workflowId ?? '';
1592
- workflows.delete(workflowId);
1763
+ const refused = gen.cancelRefused;
1764
+ if (refused === true || (Array.isArray(refused) && refused.includes(workflowId))) {
1765
+ // The real host's answer when the estimate allowance is used up: a
1766
+ // NON-terminal snapshot, and the cancel is not sent.
1767
+ dispatchToBlock({
1768
+ type: 'WORKFLOW_CANCELED',
1769
+ payload: { requestId, snapshot: { workflowId, status: 'processing' } },
1770
+ });
1771
+ return;
1772
+ }
1773
+ const wf = workflows.get(workflowId);
1774
+ if (wf && !wf.canceled && wf.polls >= pollsUntilDone) {
1775
+ // Already finished: like the real host, report the real terminal
1776
+ // status rather than pretending the cancel took.
1777
+ const outcome = gen.workflowOutcome?.(workflowId, wf.body) ?? 'succeeded';
1778
+ dispatchToBlock({
1779
+ type: 'WORKFLOW_CANCELED',
1780
+ payload: {
1781
+ requestId,
1782
+ snapshot: outcome === 'succeeded'
1783
+ ? succeededSnapshot(workflowId)
1784
+ : { workflowId, status: outcome, error: `mock: workflow ${outcome}` },
1785
+ },
1786
+ });
1787
+ return;
1788
+ }
1789
+ // Keep the record, marked canceled, so later polls answer `canceled`
1790
+ // rather than counting down to `succeeded`.
1791
+ workflows.set(workflowId, { ...(wf ?? { polls: 0, cost: legacyCost }), canceled: true });
1593
1792
  dispatchToBlock({
1594
1793
  type: 'WORKFLOW_CANCELED',
1595
1794
  payload: { requestId, snapshot: { workflowId, status: 'canceled' } },
@@ -2065,6 +2264,37 @@ export function createMockHost(options = {}) {
2065
2264
  }
2066
2265
  case 'OPEN_RESOURCE_PICKER': {
2067
2266
  const rtype = typed.payload?.resourceType;
2267
+ // MULTI-select (`multiple: { max }`) — the real host's rules, in the
2268
+ // real host's order: LoRA family only, `max` a whole number ≥ 1
2269
+ // (clamped to 5), and a LIST reply (`selectedResources`, `[]` when
2270
+ // dismissed) with no `selected`. A request WITHOUT the key falls
2271
+ // through to the single-pick reply below, unchanged.
2272
+ const multiple = typed.payload?.multiple;
2273
+ if (multiple !== undefined && multiple !== null) {
2274
+ const refuse = (error) => dispatchToBlock({ type: 'RESOURCE_PICKER_RESULT', payload: { requestId, error } });
2275
+ if (!['lora', 'locon', 'dora'].includes(String(rtype).trim().toLowerCase())) {
2276
+ refuse(`OPEN_RESOURCE_PICKER: multiple is only supported for LoRA-family resource types, not ${String(rtype)}.`);
2277
+ return;
2278
+ }
2279
+ const max = typeof multiple === 'object' ? multiple.max : undefined;
2280
+ if (typeof max !== 'number' || !Number.isInteger(max) || max < 1) {
2281
+ refuse('OPEN_RESOURCE_PICKER: multiple.max must be a whole number of at least 1.');
2282
+ return;
2283
+ }
2284
+ const picks = cannedMultiPicks !== undefined
2285
+ ? (cannedMultiPicks ?? [])
2286
+ : cannedPicks.LORA
2287
+ ? [cannedPicks.LORA, DEFAULT_SECOND_LORA_PICK]
2288
+ : [];
2289
+ dispatchToBlock({
2290
+ type: 'RESOURCE_PICKER_RESULT',
2291
+ payload: {
2292
+ requestId,
2293
+ selectedResources: picks.slice(0, Math.min(max, MOCK_RESOURCE_PICKER_MULTI_MAX)),
2294
+ },
2295
+ });
2296
+ return;
2297
+ }
2068
2298
  const selected = rtype ? cannedPicks[rtype] : undefined;
2069
2299
  dispatchToBlock({
2070
2300
  type: 'RESOURCE_PICKER_RESULT',
@@ -2355,6 +2585,14 @@ export function createMockHost(options = {}) {
2355
2585
  return;
2356
2586
  }
2357
2587
  case 'SHARED_APPEND': {
2588
+ const appendLimited = takeSharedRateLimit('submissions');
2589
+ if (appendLimited) {
2590
+ dispatchToBlock({
2591
+ type: 'SHARED_APPEND_RESULT',
2592
+ payload: { requestId, key: '', ...appendLimited },
2593
+ });
2594
+ return;
2595
+ }
2358
2596
  if (sharedFailNext > 0) {
2359
2597
  sharedFailNext -= 1;
2360
2598
  dispatchToBlock({
@@ -2397,6 +2635,14 @@ export function createMockHost(options = {}) {
2397
2635
  }
2398
2636
  case 'SHARED_VOTE': {
2399
2637
  const key = typed.payload?.key ?? '';
2638
+ const voteLimited = takeSharedRateLimit('votes');
2639
+ if (voteLimited) {
2640
+ dispatchToBlock({
2641
+ type: 'SHARED_VOTE_RESULT',
2642
+ payload: { requestId, count: 0, ...voteLimited },
2643
+ });
2644
+ return;
2645
+ }
2400
2646
  if (sharedFailNext > 0) {
2401
2647
  sharedFailNext -= 1;
2402
2648
  dispatchToBlock({
@@ -2424,6 +2670,14 @@ export function createMockHost(options = {}) {
2424
2670
  }
2425
2671
  case 'SHARED_UNVOTE': {
2426
2672
  const key = typed.payload?.key ?? '';
2673
+ const unvoteLimited = takeSharedRateLimit('votes');
2674
+ if (unvoteLimited) {
2675
+ dispatchToBlock({
2676
+ type: 'SHARED_UNVOTE_RESULT',
2677
+ payload: { requestId, count: 0, ...unvoteLimited },
2678
+ });
2679
+ return;
2680
+ }
2427
2681
  if (sharedFailNext > 0) {
2428
2682
  sharedFailNext -= 1;
2429
2683
  dispatchToBlock({
@@ -2450,6 +2704,14 @@ export function createMockHost(options = {}) {
2450
2704
  }
2451
2705
  case 'SHARED_WITHDRAW': {
2452
2706
  const key = typed.payload?.key ?? '';
2707
+ const withdrawLimited = takeSharedRateLimit('withdrawals');
2708
+ if (withdrawLimited) {
2709
+ dispatchToBlock({
2710
+ type: 'SHARED_WITHDRAW_RESULT',
2711
+ payload: { requestId, ok: false, deleted: false, ...withdrawLimited },
2712
+ });
2713
+ return;
2714
+ }
2453
2715
  if (sharedFailNext > 0) {
2454
2716
  sharedFailNext -= 1;
2455
2717
  dispatchToBlock({
@@ -2467,6 +2729,14 @@ export function createMockHost(options = {}) {
2467
2729
  }
2468
2730
  case 'SHARED_UPDATE': {
2469
2731
  const key = typed.payload?.key ?? '';
2732
+ const updateLimited = takeSharedRateLimit('submissions');
2733
+ if (updateLimited) {
2734
+ dispatchToBlock({
2735
+ type: 'SHARED_UPDATE_RESULT',
2736
+ payload: { requestId, ok: false, ...updateLimited },
2737
+ });
2738
+ return;
2739
+ }
2470
2740
  if (sharedFailNext > 0) {
2471
2741
  sharedFailNext -= 1;
2472
2742
  dispatchToBlock({
@@ -2537,6 +2807,14 @@ export function createMockHost(options = {}) {
2537
2807
  }
2538
2808
  case 'SHARED_REPORT': {
2539
2809
  const key = typed.payload?.key ?? '';
2810
+ const reportLimited = takeSharedRateLimit('reports');
2811
+ if (reportLimited) {
2812
+ dispatchToBlock({
2813
+ type: 'SHARED_REPORT_RESULT',
2814
+ payload: { requestId, ok: false, ...reportLimited },
2815
+ });
2816
+ return;
2817
+ }
2540
2818
  if (sharedFailNext > 0) {
2541
2819
  sharedFailNext -= 1;
2542
2820
  dispatchToBlock({
@@ -2725,6 +3003,9 @@ export function createMockHost(options = {}) {
2725
3003
  if (patch.cannedPicks !== undefined)
2726
3004
  cannedPicks = patch.cannedPicks;
2727
3005
  // `null` is a meaningful value (dismissed), so check for the KEY's presence.
3006
+ if ('cannedMultiPicks' in patch)
3007
+ cannedMultiPicks = patch.cannedMultiPicks;
3008
+ // `null` is a meaningful value (dismissed), so check for the KEY's presence.
2728
3009
  if ('cannedImageUpload' in patch)
2729
3010
  cannedImageUpload = patch.cannedImageUpload ?? null;
2730
3011
  if ('cannedGenerationSourceUpload' in patch)
@@ -2804,10 +3085,14 @@ export function createMockHost(options = {}) {
2804
3085
  storageFailNext = patch.storage.failNext;
2805
3086
  }
2806
3087
  if (patch.shared !== undefined) {
2807
- // Only `failNext` is live-tunable; `seed` is install-time (re-install to
2808
- // change the backing store).
3088
+ // `failNext`, `rateLimitNext` and `rateLimit` are live-tunable; `seed` is
3089
+ // install-time (re-install to change the backing store).
2809
3090
  if (patch.shared.failNext !== undefined)
2810
3091
  sharedFailNext = patch.shared.failNext;
3092
+ if (patch.shared.rateLimitNext !== undefined)
3093
+ sharedRateLimitNext = patch.shared.rateLimitNext;
3094
+ if (patch.shared.rateLimit !== undefined)
3095
+ sharedRateLimitMeta = patch.shared.rateLimit;
2811
3096
  }
2812
3097
  }
2813
3098
  const buzzHandle = {