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