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