@civitai/blocks-react 0.71.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.
- package/README.md +262 -15
- package/dist/hooks/useBatchEstimate.d.ts +4 -0
- package/dist/hooks/useBatchEstimate.js +4 -0
- package/dist/hooks/useBuzzWorkflow.d.ts +35 -3
- package/dist/hooks/useBuzzWorkflow.js +30 -3
- package/dist/hooks/useCheckpointPicker.d.ts +2 -0
- package/dist/hooks/useCheckpointPicker.js +2 -0
- package/dist/hooks/useRunConfirmedStep.d.ts +209 -0
- package/dist/hooks/useRunConfirmedStep.js +287 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/internal/hostSurface.d.ts +37 -0
- package/dist/internal/hostSurface.js +38 -0
- package/dist/internal/liveHost.d.ts +15 -6
- package/dist/internal/liveHost.js +151 -69
- package/dist/internal/mockHost.d.ts +123 -10
- package/dist/internal/mockHost.js +377 -14
- package/dist/internal/mockWorkflowGraph.d.ts +23 -10
- package/dist/internal/mockWorkflowGraph.js +34 -11
- package/dist/testing.d.ts +1 -1
- package/dist/transport/requestTimeouts.js +6 -0
- package/dist/transport/validate.d.ts +13 -0
- package/dist/transport/validate.js +76 -0
- package/package.json +4 -4
|
@@ -39,12 +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';
|
|
46
|
-
import { mockUnpricedWorkflowRefusal, mockWorkflowBodyRefusal, mockWorkflowStepGenerations, mockWorkflowStepName, } from './mockWorkflowGraph.js';
|
|
47
|
+
import { mockUnpricedWorkflowRefusal, mockUnquotedWorkflowEstimateRefusal, mockWorkflowBodyRefusal, mockWorkflowStepGenerations, mockWorkflowStepName, } from './mockWorkflowGraph.js';
|
|
47
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';
|
|
48
50
|
import { RUN_TRAINING_ERROR_CODES } from '../hooks/useRunTraining.js';
|
|
49
51
|
import { hostContextWithTheme } from '../transport/transport.js';
|
|
50
52
|
import { isRoutableRequestId } from '../transport/requestId.js';
|
|
@@ -219,6 +221,152 @@ const MOCK_TRAINING_IN_BATCH_ERROR = 'a training estimate cannot be part of a ba
|
|
|
219
221
|
const DEFAULT_TRAINING_QUOTE_TOTAL = 500;
|
|
220
222
|
/** The real quote lifetime (`BLOCK_TRAINING_QUOTE_TTL_SECONDS`, 15 min). */
|
|
221
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
|
+
};
|
|
222
370
|
// The canned picks carry the WIDENED BlockResourceInfo projection (PR-C) — the
|
|
223
371
|
// public recommended settings a real host now returns — so dev:mock mirrors prod
|
|
224
372
|
// (a picked resource seeds a weight slider + trigger words). Defaults match the
|
|
@@ -643,6 +791,8 @@ function jsonByteSize(value) {
|
|
|
643
791
|
* map onto the new scenario groups so a dev can flip out-of-Buzz /
|
|
644
792
|
* failures / latency without editing code. `?capRefusal=1` (or `=<text>`) sets
|
|
645
793
|
* {@link MockGenerationScenario.submitCapRefusal}.
|
|
794
|
+
* `?confirmedStep=declined|daily-limit|unconfirmed` sets
|
|
795
|
+
* {@link MockHostOptions.runConfirmedStepError}.
|
|
646
796
|
*
|
|
647
797
|
* Returns a partial overlay applied ON TOP of explicit {@link MockHostOptions}
|
|
648
798
|
* (URL wins — it's the interactive dev knob). No-op outside a browser.
|
|
@@ -755,6 +905,17 @@ export function readMockHostUrlOptions(win = globalThis
|
|
|
755
905
|
generation.submitCapRefusal =
|
|
756
906
|
capRefusal === '1' || capRefusal === 'true' ? true : capRefusal;
|
|
757
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';
|
|
758
919
|
if (Object.keys(generation).length > 0)
|
|
759
920
|
out.generation = generation;
|
|
760
921
|
if (Object.keys(buzz).length > 0)
|
|
@@ -907,6 +1068,9 @@ export function createMockHost(options = {}) {
|
|
|
907
1068
|
// image is never added: production leaves it unstamped, so it is not postable.
|
|
908
1069
|
const postableImageIds = new Set(options.postableImageIds ?? []);
|
|
909
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;
|
|
910
1074
|
// App-subqueue bridge data + forced free-text-error knob. `appWorkflows` is
|
|
911
1075
|
// MUTABLE — CANCEL_APP_WORKFLOW marks the matching row canceled in place so a
|
|
912
1076
|
// follow-up QUERY reflects it.
|
|
@@ -993,6 +1157,13 @@ export function createMockHost(options = {}) {
|
|
|
993
1157
|
// Per-viewer vote state (the mock's `mockUserId` is the "requesting viewer").
|
|
994
1158
|
viewerVoted: row.voters.has(mockUserId),
|
|
995
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
|
+
};
|
|
996
1167
|
// Resolve a per-gen cost from the scenario (or legacy `cost`).
|
|
997
1168
|
// A `kind: 'workflow'` body is the SUM of its steps — see `workflowStepCosts`.
|
|
998
1169
|
const costFor = (body) => {
|
|
@@ -1008,7 +1179,7 @@ export function createMockHost(options = {}) {
|
|
|
1008
1179
|
*/
|
|
1009
1180
|
const workflowStepCosts = (body) => {
|
|
1010
1181
|
// Unpriced: reserved at the ceiling, one per-generation budget per step.
|
|
1011
|
-
if (
|
|
1182
|
+
if (workflowQuoteFailure() !== null)
|
|
1012
1183
|
return body.steps.map(() => buzzBudget);
|
|
1013
1184
|
const spec = gen.costPerGen ?? legacyCost;
|
|
1014
1185
|
return (Array.isArray(body.steps) ? body.steps : []).map((step, index) => {
|
|
@@ -1038,7 +1209,7 @@ export function createMockHost(options = {}) {
|
|
|
1038
1209
|
if (!quotesFee)
|
|
1039
1210
|
return null;
|
|
1040
1211
|
// An unpriced workflow has no quoted base, so no fee is priced on it.
|
|
1041
|
-
if (body.kind === 'workflow' &&
|
|
1212
|
+
if (body.kind === 'workflow' && workflowQuoteFailure() !== null)
|
|
1042
1213
|
return 0;
|
|
1043
1214
|
const spec = gen.authorFee ?? 0;
|
|
1044
1215
|
return typeof spec === 'function' ? spec(body) : spec;
|
|
@@ -1134,6 +1305,10 @@ export function createMockHost(options = {}) {
|
|
|
1134
1305
|
// quotes its estimates stored (id → what a run is checked against).
|
|
1135
1306
|
const trainingDatasets = new Map();
|
|
1136
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();
|
|
1137
1312
|
let trainingSerial = 0;
|
|
1138
1313
|
/** `<prefix>` + 32 hex characters — the real handles' shape. */
|
|
1139
1314
|
const trainingHandle = (prefix) => {
|
|
@@ -1267,12 +1442,20 @@ export function createMockHost(options = {}) {
|
|
|
1267
1442
|
spentAccountType: primaryFunder(buzzBalance),
|
|
1268
1443
|
};
|
|
1269
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;
|
|
1270
1449
|
return {
|
|
1271
1450
|
...trainingFields(body),
|
|
1451
|
+
...(model3d ? { model3dOutputs: model3d } : {}),
|
|
1272
1452
|
workflowId,
|
|
1273
1453
|
status: 'succeeded',
|
|
1274
1454
|
cost: { total: cost },
|
|
1275
|
-
imageUrls
|
|
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),
|
|
1276
1459
|
// Pick-aware APPROXIMATION of the real backend's
|
|
1277
1460
|
// BlockWorkflowSnapshot.spentAccountType. The real backend stamps the
|
|
1278
1461
|
// LARGEST realized debit (`primaryDebitedAccountType`), and its currency
|
|
@@ -1361,7 +1544,11 @@ export function createMockHost(options = {}) {
|
|
|
1361
1544
|
* ESTIMATE_WORKFLOW and ESTIMATE_WORKFLOW_BATCH so a batch cell is priced
|
|
1362
1545
|
* exactly as a single estimate of the same body.
|
|
1363
1546
|
*/
|
|
1364
|
-
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) => {
|
|
1365
1552
|
// UNUSABLE-ESTIMATE simulation (`generation.failEstimate`). Reproduces
|
|
1366
1553
|
// the two real producers byte-for-byte so a block's `catch` around
|
|
1367
1554
|
// `estimate()` is exercisable locally — see the knob's docs for why
|
|
@@ -1442,13 +1629,65 @@ export function createMockHost(options = {}) {
|
|
|
1442
1629
|
},
|
|
1443
1630
|
};
|
|
1444
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
|
+
}
|
|
1445
1678
|
// MULTI-STEP — the second kind-faithful arm: the host refuses a body
|
|
1446
1679
|
// it will not run BEFORE pricing it, with an `errorCode`, in the
|
|
1447
1680
|
// `failureSnapshot` shape (no `cost`). A body that passes is priced
|
|
1448
1681
|
// below like any other: `quotedCost` sums the steps and adds ONE fee.
|
|
1449
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();
|
|
1450
1686
|
const refusal = mockWorkflowBodyRefusal(body) ??
|
|
1451
|
-
(
|
|
1687
|
+
(failure === null
|
|
1688
|
+
? null
|
|
1689
|
+
: (mockUnpricedWorkflowRefusal(body, failure) ??
|
|
1690
|
+
mockUnquotedWorkflowEstimateRefusal(failure)));
|
|
1452
1691
|
if (refusal !== null)
|
|
1453
1692
|
return { workflowId: 'failed', status: 'failed', ...refusal };
|
|
1454
1693
|
}
|
|
@@ -1569,7 +1808,7 @@ export function createMockHost(options = {}) {
|
|
|
1569
1808
|
const body = typed.payload?.body ?? {};
|
|
1570
1809
|
dispatchToBlock({
|
|
1571
1810
|
type: 'ESTIMATE_RESULT',
|
|
1572
|
-
payload: { requestId, snapshot: estimateSnapshotFor(body) },
|
|
1811
|
+
payload: { requestId, snapshot: estimateSnapshotFor(body, { storeQuotes: true }) },
|
|
1573
1812
|
});
|
|
1574
1813
|
return;
|
|
1575
1814
|
}
|
|
@@ -1642,7 +1881,7 @@ export function createMockHost(options = {}) {
|
|
|
1642
1881
|
error: MOCK_TRAINING_IN_BATCH_ERROR,
|
|
1643
1882
|
};
|
|
1644
1883
|
}
|
|
1645
|
-
return estimateSnapshotFor(cell ?? {});
|
|
1884
|
+
return estimateSnapshotFor(cell ?? {}, { storeQuotes: false });
|
|
1646
1885
|
});
|
|
1647
1886
|
// The server's aggregate rule: the cells that priced, i.e. not
|
|
1648
1887
|
// `'failed'` and carrying a numeric `cost.total`.
|
|
@@ -1735,12 +1974,34 @@ export function createMockHost(options = {}) {
|
|
|
1735
1974
|
});
|
|
1736
1975
|
return;
|
|
1737
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
|
+
}
|
|
1738
1998
|
// A `kind: 'workflow'` body the host will not run is refused here,
|
|
1739
1999
|
// before any cap, balance or debit — the same refusal, with the same
|
|
1740
2000
|
// `errorCode`, the estimate gives. No `cost`, so `submit()` rejects.
|
|
1741
2001
|
if (body.kind === 'workflow') {
|
|
2002
|
+
const failure = workflowQuoteFailure();
|
|
1742
2003
|
const refusal = mockWorkflowBodyRefusal(body) ??
|
|
1743
|
-
(
|
|
2004
|
+
(failure === null ? null : mockUnpricedWorkflowRefusal(body, failure));
|
|
1744
2005
|
if (refusal !== null) {
|
|
1745
2006
|
dispatchToBlock({
|
|
1746
2007
|
type: 'WORKFLOW_SUBMITTED',
|
|
@@ -2433,6 +2694,84 @@ export function createMockHost(options = {}) {
|
|
|
2433
2694
|
});
|
|
2434
2695
|
return reply({ snapshot: { workflowId, status: 'pending' } });
|
|
2435
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
|
+
}
|
|
2436
2775
|
case 'PUBLISH_GENERATION_OUTPUTS': {
|
|
2437
2776
|
// Publish selected outputs of one of the app's OWN workflows as bare,
|
|
2438
2777
|
// real-scanned public Image rows. Drop a request with no requestId
|
|
@@ -3112,13 +3451,27 @@ export function createMockHost(options = {}) {
|
|
|
3112
3451
|
return saveReply({ ok: true });
|
|
3113
3452
|
}
|
|
3114
3453
|
case 'SET_USER_CHECKPOINT': {
|
|
3115
|
-
//
|
|
3116
|
-
//
|
|
3117
|
-
// explicit null (clear); anything else is a bad-input NACK, same as
|
|
3118
|
-
// the real IframeHost. Without this reply `useCheckpointPicker().
|
|
3119
|
-
// 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.
|
|
3120
3456
|
if (!isRoutableRequestId(requestId))
|
|
3121
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.
|
|
3122
3475
|
const rawVersionId = typed.payload?.versionId;
|
|
3123
3476
|
const versionId = rawVersionId === null
|
|
3124
3477
|
? null
|
|
@@ -3139,6 +3492,11 @@ export function createMockHost(options = {}) {
|
|
|
3139
3492
|
return;
|
|
3140
3493
|
}
|
|
3141
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.
|
|
3142
3500
|
return;
|
|
3143
3501
|
}
|
|
3144
3502
|
},
|
|
@@ -3295,6 +3653,11 @@ export function createMockHost(options = {}) {
|
|
|
3295
3653
|
uploadImageBytesError = patch.uploadImageBytesError;
|
|
3296
3654
|
if ('runTrainingCapRefusal' in patch)
|
|
3297
3655
|
runTrainingCapRefusal = patch.runTrainingCapRefusal;
|
|
3656
|
+
if ('runConfirmedStepError' in patch)
|
|
3657
|
+
runConfirmedStepError = patch.runConfirmedStepError;
|
|
3658
|
+
if ('runConfirmedStepCapRefusal' in patch) {
|
|
3659
|
+
runConfirmedStepCapRefusal = patch.runConfirmedStepCapRefusal;
|
|
3660
|
+
}
|
|
3298
3661
|
if (patch.appWorkflows !== undefined) {
|
|
3299
3662
|
appWorkflows = {
|
|
3300
3663
|
workflows: patch.appWorkflows.workflows,
|
|
@@ -50,8 +50,15 @@ export declare const MOCK_WORKFLOW_DENIED_STEP_TYPES: readonly string[];
|
|
|
50
50
|
/** A refused body: the reply's `error` and, where one is defined, its `errorCode`. */
|
|
51
51
|
export interface MockWorkflowRefusal {
|
|
52
52
|
error: string;
|
|
53
|
-
errorCode?: 'workflow_step_denied' | 'workflow_ref_invalid' | 'workflow_too_many_steps' | 'workflow_repeat_unbounded' |
|
|
53
|
+
errorCode?: 'workflow_step_denied' | 'workflow_ref_invalid' | 'workflow_too_many_steps' | 'workflow_repeat_unbounded' | `training-quote-${MockWorkflowQuoteFailure}` | `workflow_quote_${MockWorkflowQuoteFailure}`;
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Why the host has no price quote for a workflow (its `PassThroughQuoteFailure`
|
|
57
|
+
* kinds): `'rejected'` — the orchestrator refused the body, so the same body
|
|
58
|
+
* will fail again; `'unavailable'` — no usable answer, the one cause a retry of
|
|
59
|
+
* the same body can clear; `'unpriced'` — an answer without a price.
|
|
60
|
+
*/
|
|
61
|
+
export type MockWorkflowQuoteFailure = 'rejected' | 'unavailable' | 'unpriced';
|
|
55
62
|
/** What a step is called in a reference and on the snapshot: its `name`, or `'$<index>'`. */
|
|
56
63
|
export declare const mockWorkflowStepName: (step: {
|
|
57
64
|
name?: unknown;
|
|
@@ -66,22 +73,28 @@ export declare const MOCK_MERGE_SHAPE_ERROR = "a merge step needs a \"fields\" o
|
|
|
66
73
|
* {@link mockWorkflowBodyRefusal} accepted.
|
|
67
74
|
*/
|
|
68
75
|
export declare function mockWorkflowStepGenerations(stepType: string, input: unknown, args: unknown): number;
|
|
69
|
-
/** The host's refusal text for an unquoted training step (`
|
|
70
|
-
export declare const
|
|
76
|
+
/** The host's refusal text for an unquoted training step (`unquotedTrainingRefusal`). */
|
|
77
|
+
export declare const mockUnquotedTrainingError: (failure: MockWorkflowQuoteFailure) => string;
|
|
71
78
|
/**
|
|
72
|
-
* The refusal
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
79
|
+
* The refusal, on BOTH the estimate and the submit, for a body
|
|
80
|
+
* {@link mockWorkflowBodyRefusal} ACCEPTED when the host has no price quote for
|
|
81
|
+
* it, or `null` when the submit would proceed (reserved at the ceiling). In the
|
|
82
|
+
* host's order: a training step anywhere (`training-quote-<failure>`), then a
|
|
83
|
+
* `repeat` anywhere (`workflow_repeat_unbounded`, naming the first such step).
|
|
84
|
+
* The message states the cause.
|
|
78
85
|
*/
|
|
79
86
|
export declare function mockUnpricedWorkflowRefusal(body: {
|
|
80
87
|
steps: ReadonlyArray<{
|
|
81
88
|
$type: string;
|
|
82
89
|
input: unknown;
|
|
83
90
|
}>;
|
|
84
|
-
}): MockWorkflowRefusal | null;
|
|
91
|
+
}, failure: MockWorkflowQuoteFailure): MockWorkflowRefusal | null;
|
|
92
|
+
/**
|
|
93
|
+
* The host's `unquotedWorkflowEstimateRefusal`: what an ESTIMATE of a workflow
|
|
94
|
+
* with no price quote fails with when {@link mockUnpricedWorkflowRefusal} did
|
|
95
|
+
* not already refuse it. Estimate only — the submit of the same body proceeds.
|
|
96
|
+
*/
|
|
97
|
+
export declare function mockUnquotedWorkflowEstimateRefusal(failure: MockWorkflowQuoteFailure): MockWorkflowRefusal;
|
|
85
98
|
/**
|
|
86
99
|
* The refusal for a `kind: 'workflow'` body, or `null` for one the mock will
|
|
87
100
|
* price and run. In the host's order:
|