@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.
@@ -0,0 +1,117 @@
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-${MockWorkflowQuoteFailure}` | `workflow_quote_${MockWorkflowQuoteFailure}`;
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';
62
+ /** What a step is called in a reference and on the snapshot: its `name`, or `'$<index>'`. */
63
+ export declare const mockWorkflowStepName: (step: {
64
+ name?: unknown;
65
+ }, index: number) => string;
66
+ /** The refusal for a `repeat` whose shape or nesting the mock cannot follow. */
67
+ export declare const MOCK_REPEAT_SHAPE_ERROR: string;
68
+ /** The refusal for a `merge` whose sources the mock cannot read. */
69
+ export declare const MOCK_MERGE_SHAPE_ERROR = "a merge step needs a \"fields\" object whose entries each carry a string \"$ref\" and a \"reduce\"";
70
+ /**
71
+ * How many generations a step runs: 1, or a `repeat`'s iteration count with
72
+ * nested repeats multiplied (never less than 1). Only meaningful for a body
73
+ * {@link mockWorkflowBodyRefusal} accepted.
74
+ */
75
+ export declare function mockWorkflowStepGenerations(stepType: string, input: unknown, args: unknown): number;
76
+ /** The host's refusal text for an unquoted training step (`unquotedTrainingRefusal`). */
77
+ export declare const mockUnquotedTrainingError: (failure: MockWorkflowQuoteFailure) => string;
78
+ /**
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.
85
+ */
86
+ export declare function mockUnpricedWorkflowRefusal(body: {
87
+ steps: ReadonlyArray<{
88
+ $type: string;
89
+ input: unknown;
90
+ }>;
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;
98
+ /**
99
+ * The refusal for a `kind: 'workflow'` body, or `null` for one the mock will
100
+ * price and run. In the host's order:
101
+ *
102
+ * 1. the wire shape — no `errorCode`, the host's schema rejection;
103
+ * 2. the step cap — `workflow_too_many_steps`;
104
+ * 3. step by step: the name rules and a `repeat`'s exact shape and nesting
105
+ * (`workflow_ref_invalid`); the repeat bound — only over an array in
106
+ * `arguments`, at most 16 iterations with nested repeats multiplied
107
+ * (`workflow_repeat_unbounded`); then the reference graph
108
+ * (`workflow_ref_invalid`). A name is `[a-z][a-z0-9_-]{0,31}` (or the
109
+ * step's own `$<index>`), unique, and not `block-step`; every source — a
110
+ * reference, a `repeat`'s `for`, a `merge` field — names `$arguments` or an
111
+ * EARLIER step, and a `merge` field never names a repeat variable;
112
+ * 4. a repeat `as` name that is also a step name — `workflow_ref_invalid`;
113
+ * 5. the step-type denylist on every step and on the step a `repeat` runs —
114
+ * `workflow_step_denied`.
115
+ */
116
+ export declare function mockWorkflowBodyRefusal(body: unknown): MockWorkflowRefusal | null;
117
+ //# sourceMappingURL=mockWorkflowGraph.d.ts.map
@@ -0,0 +1,447 @@
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 const MOCK_WORKFLOW_BOUNDS = {
22
+ /** Steps in one body. */
23
+ maxSteps: 8,
24
+ /** Reference objects across every step input of one body. */
25
+ maxRefs: 64,
26
+ /** Nesting depth of one step `input`. */
27
+ maxInputDepth: 32,
28
+ /** How deep a `repeat` template may nest another `repeat`. */
29
+ maxRepeatNesting: 4,
30
+ /** Serialized bytes of every step `input` plus `arguments`, summed. */
31
+ maxInputBytes: 262_144,
32
+ /** Characters of a step `$type`, and of a step `name` on the wire. */
33
+ 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
+ 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 const MOCK_WORKFLOW_DENIED_STEP_TYPES = Object.freeze([
50
+ 'modelPickleScan',
51
+ 'modelClamScan',
52
+ 'imageScanning',
53
+ 'xGuardModeration',
54
+ 'shieldstralModeration',
55
+ 'promptModeration',
56
+ 'mediaHash',
57
+ 'modelHash',
58
+ 'modelParseMetadata',
59
+ 'comfyNodepackSnapshot',
60
+ 'qwenImageBench',
61
+ 'webScrape',
62
+ 'webSearch',
63
+ 'textToImage',
64
+ ]);
65
+ const DENIED_LOOKUP = new Set(MOCK_WORKFLOW_DENIED_STEP_TYPES.map((t) => t.toLowerCase()));
66
+ const ARGUMENTS_REF = '$arguments';
67
+ /** The name the single-step kinds give their one step; a workflow step may not take it. */
68
+ const RESERVED_STEP_NAME = 'block-step';
69
+ const STEP_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,31}$/;
70
+ /** The only keys a reference object may carry. */
71
+ const REF_KEYS = new Set(['$ref', 'path', 'default', 'format']);
72
+ const BODY_KEYS = new Set(['kind', 'steps', 'arguments']);
73
+ const STEP_KEYS = new Set(['name', '$type', 'input', 'suppressOutput']);
74
+ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
75
+ const isRepeat = (stepType) => typeof stepType === 'string' && stepType.toLowerCase() === 'repeat';
76
+ /** What a step is called in a reference and on the snapshot: its `name`, or `'$<index>'`. */
77
+ export const mockWorkflowStepName = (step, index) => typeof step.name === 'string' ? step.name : `$${index}`;
78
+ const isMerge = (stepType) => typeof stepType === 'string' && stepType.toLowerCase() === 'merge';
79
+ const hasOnlyKeys = (object, allowed) => Object.keys(object).every((key) => allowed.includes(key));
80
+ /**
81
+ * A `repeat` step's input, or `null` unless it is EXACTLY
82
+ * `{ for: { $ref, path, as }, template: { $type, name?, input? } }` with string
83
+ * `$ref` / `path` / `as` / `$type` (and `name`, when present). Fail-closed on
84
+ * shape, every key spelled exactly, as the host reads it.
85
+ */
86
+ function readRepeatInput(input) {
87
+ if (!isPlainObject(input) || !hasOnlyKeys(input, ['for', 'template']))
88
+ return null;
89
+ const { for: forClause, template } = input;
90
+ if (!isPlainObject(forClause) || !hasOnlyKeys(forClause, ['$ref', 'path', 'as']))
91
+ return null;
92
+ if (typeof forClause.$ref !== 'string' ||
93
+ typeof forClause.path !== 'string' ||
94
+ typeof forClause.as !== 'string') {
95
+ return null;
96
+ }
97
+ if (!isPlainObject(template) || !hasOnlyKeys(template, ['$type', 'name', 'input']))
98
+ return null;
99
+ if (typeof template.$type !== 'string')
100
+ return null;
101
+ if (template.name !== undefined && typeof template.name !== 'string')
102
+ return null;
103
+ return { forClause: forClause, template: template };
104
+ }
105
+ /** The refusal for a `repeat` whose shape or nesting the mock cannot follow. */
106
+ export const MOCK_REPEAT_SHAPE_ERROR = 'a repeat step needs exactly { for: { $ref, path, as }, template: { $type, name?, input? } }, ' +
107
+ `nested at most ${MOCK_WORKFLOW_BOUNDS.maxRepeatNesting} deep`;
108
+ /** The refusal for a `merge` whose sources the mock cannot read. */
109
+ export const MOCK_MERGE_SHAPE_ERROR = 'a merge step needs a "fields" object whose entries each carry a string "$ref" and a "reduce"';
110
+ /** The array a `for` path names in `arguments`, by exact own keys, or `null`. */
111
+ function readArgumentsArray(args, path) {
112
+ let current = args;
113
+ for (const part of path.split('.')) {
114
+ if (!isPlainObject(current) || !Object.prototype.hasOwnProperty.call(current, part))
115
+ return null;
116
+ current = current[part];
117
+ }
118
+ return Array.isArray(current) ? current : null;
119
+ }
120
+ /**
121
+ * THE REPEAT BOUND. A refusal message, or `null` when the step is not a
122
+ * `repeat` or its loop is bounded: it iterates an array in the body's own
123
+ * `arguments` (so its length is fixed in the submitted body), and the count,
124
+ * nested repeats multiplied, is at most `maxRepeatIterations`.
125
+ */
126
+ function repeatUnboundedProblem(stepType, input, args, outerIterations = 1) {
127
+ if (!isRepeat(stepType))
128
+ return null;
129
+ const repeat = readRepeatInput(input);
130
+ if (!repeat)
131
+ return MOCK_REPEAT_SHAPE_ERROR;
132
+ const { forClause, template } = repeat;
133
+ if (forClause.$ref !== ARGUMENTS_REF) {
134
+ return (`a repeat may only iterate over the workflow's own arguments ("$ref": "${ARGUMENTS_REF}"), ` +
135
+ `not ${JSON.stringify(forClause.$ref)} — pass the list in "arguments" instead`);
136
+ }
137
+ const items = readArgumentsArray(args, forClause.path);
138
+ if (!items)
139
+ return `repeat "for" path ${JSON.stringify(forClause.path)} does not name an array in "arguments"`;
140
+ const iterations = outerIterations * items.length;
141
+ if (iterations > MOCK_WORKFLOW_BOUNDS.maxRepeatIterations) {
142
+ return (`a repeat may run at most ${MOCK_WORKFLOW_BOUNDS.maxRepeatIterations} iterations, nested repeats ` +
143
+ `multiplied (got ${iterations})`);
144
+ }
145
+ return repeatUnboundedProblem(template.$type, template.input, args, iterations);
146
+ }
147
+ /**
148
+ * How many generations a step runs: 1, or a `repeat`'s iteration count with
149
+ * nested repeats multiplied (never less than 1). Only meaningful for a body
150
+ * {@link mockWorkflowBodyRefusal} accepted.
151
+ */
152
+ export function mockWorkflowStepGenerations(stepType, input, args) {
153
+ if (!isRepeat(stepType))
154
+ return 1;
155
+ const repeat = readRepeatInput(input);
156
+ const items = repeat ? readArgumentsArray(args, repeat.forClause.path) : null;
157
+ if (!repeat || !items)
158
+ return 1;
159
+ return Math.max(1, items.length * mockWorkflowStepGenerations(repeat.template.$type, repeat.template.input, args));
160
+ }
161
+ /** The source each `merge` field reads, or `null` when that cannot be read for certain. */
162
+ function readMergeSources(input) {
163
+ if (!isPlainObject(input))
164
+ return null;
165
+ if (Object.keys(input).some((key) => key !== 'fields' && key.toLowerCase() === 'fields'))
166
+ return null;
167
+ const { fields } = input;
168
+ if (!isPlainObject(fields))
169
+ return null;
170
+ const sources = [];
171
+ for (const field of Object.values(fields)) {
172
+ if (!isPlainObject(field))
173
+ return null;
174
+ if (Object.keys(field).some((key) => key !== '$ref' && key.toLowerCase() === '$ref'))
175
+ return null;
176
+ // Without `reduce` the entry is a plain reference, which the orchestrator
177
+ // replaces with what it points at before the merge reads it.
178
+ if (typeof field.$ref !== 'string' || !('reduce' in field))
179
+ return null;
180
+ sources.push(field.$ref);
181
+ }
182
+ return sources;
183
+ }
184
+ /**
185
+ * Every `$type` a step can run: its own and, for a `repeat`, its template's.
186
+ * `null` for a `repeat` whose shape it cannot follow, or nested past the bound.
187
+ */
188
+ function collectStepTypes(stepType, input) {
189
+ const types = [];
190
+ let currentType = stepType;
191
+ let currentInput = input;
192
+ for (let depth = 0; depth <= MOCK_WORKFLOW_BOUNDS.maxRepeatNesting; depth += 1) {
193
+ types.push(currentType);
194
+ if (!isRepeat(currentType))
195
+ return types;
196
+ const repeat = readRepeatInput(currentInput);
197
+ if (!repeat)
198
+ return null;
199
+ currentType = repeat.template.$type;
200
+ currentInput = repeat.template.input;
201
+ }
202
+ return null;
203
+ }
204
+ /**
205
+ * A reference is an object with BOTH `$ref` and `path` and no key other than
206
+ * `default` / `format`. Anything else — including an object that has `$ref` but
207
+ * no `path` — is ordinary input and is walked, not judged.
208
+ */
209
+ function isRefObject(value) {
210
+ if (!('$ref' in value) || !('path' in value))
211
+ return false;
212
+ return Object.keys(value).every((key) => REF_KEYS.has(key));
213
+ }
214
+ function checkSource(source, sources, state) {
215
+ state.refs += 1;
216
+ if (state.refs > MOCK_WORKFLOW_BOUNDS.maxRefs) {
217
+ return `a workflow may hold at most ${MOCK_WORKFLOW_BOUNDS.maxRefs} references`;
218
+ }
219
+ // THE BACKWARDS-ONLY RULE: `sources` holds `$arguments` and the names of the
220
+ // steps BEFORE this one, so the step itself, a later step and a name that
221
+ // does not exist all miss it the same way.
222
+ if (!sources.has(source)) {
223
+ return `"$ref": ${JSON.stringify(source)} does not name "${ARGUMENTS_REF}" or an earlier step`;
224
+ }
225
+ return null;
226
+ }
227
+ /** A refusal message, or `null` when `value` is fine. */
228
+ function checkValue(value, depth, sources, state) {
229
+ if (typeof value !== 'object' || value === null)
230
+ return null;
231
+ if (depth > MOCK_WORKFLOW_BOUNDS.maxInputDepth) {
232
+ return `input is nested deeper than ${MOCK_WORKFLOW_BOUNDS.maxInputDepth} levels`;
233
+ }
234
+ if (Array.isArray(value)) {
235
+ for (const entry of value) {
236
+ const problem = checkValue(entry, depth + 1, sources, state);
237
+ if (problem)
238
+ return problem;
239
+ }
240
+ return null;
241
+ }
242
+ const object = value;
243
+ if (isRefObject(object)) {
244
+ if (typeof object.$ref !== 'string' || typeof object.path !== 'string') {
245
+ return 'a reference needs a string "$ref" and a string "path"';
246
+ }
247
+ if (object.format !== undefined && object.format !== null && typeof object.format !== 'string') {
248
+ return 'a reference "format" must be a string';
249
+ }
250
+ return checkSource(object.$ref, sources, state);
251
+ }
252
+ for (const entry of Object.values(object)) {
253
+ const problem = checkValue(entry, depth + 1, sources, state);
254
+ if (problem)
255
+ return problem;
256
+ }
257
+ return null;
258
+ }
259
+ /**
260
+ * Check one step's input. Two step types name their sources outside a reference
261
+ * object and are read by shape: `repeat` (its `for` clause, with the `as` name a
262
+ * legal source inside its template) and `merge` (its fields).
263
+ */
264
+ function checkStepInput(stepType, input, depth, sources, state) {
265
+ if (isMerge(stepType)) {
266
+ const mergeSources = readMergeSources(input);
267
+ if (!mergeSources)
268
+ return MOCK_MERGE_SHAPE_ERROR;
269
+ for (const source of mergeSources) {
270
+ // A merge waits on its sources as STEPS, so a loop variable is not one.
271
+ if (state.loopVars.has(source)) {
272
+ return `a merge field cannot read the repeat variable ${JSON.stringify(source)}`;
273
+ }
274
+ const problem = checkSource(source, sources, state);
275
+ if (problem)
276
+ return problem;
277
+ }
278
+ return checkValue(input, depth, sources, state);
279
+ }
280
+ if (!isRepeat(stepType))
281
+ return checkValue(input, depth, sources, state);
282
+ const repeat = readRepeatInput(input);
283
+ if (!repeat)
284
+ return MOCK_REPEAT_SHAPE_ERROR;
285
+ const { forClause, template } = repeat;
286
+ const sourceProblem = checkSource(forClause.$ref, sources, state);
287
+ if (sourceProblem)
288
+ return sourceProblem;
289
+ state.loopVars.add(forClause.as);
290
+ return checkStepInput(template.$type, template.input, depth + 2, new Set([...sources, forClause.as]), state);
291
+ }
292
+ /**
293
+ * The host's `quoteFailureCause`: why there is no quote, as the clause that
294
+ * completes "…needs a price quote and ". Only `'unavailable'` says "try again".
295
+ * The mock has no orchestrator message to pass on, so `'rejected'` is the host's
296
+ * wording for a rejection with no reason safe to show.
297
+ */
298
+ const MOCK_QUOTE_FAILURE_CAUSE = {
299
+ rejected: 'the orchestrator rejected the request; check the step inputs and references ' +
300
+ '(retrying the same body will not help)',
301
+ unavailable: 'none could be obtained; try again',
302
+ unpriced: 'the orchestrator answered without one',
303
+ };
304
+ /** The host's refusal text for an unquoted training step (`unquotedTrainingRefusal`). */
305
+ export const mockUnquotedTrainingError = (failure) => `Training needs a price quote and ${MOCK_QUOTE_FAILURE_CAUSE[failure]}.`;
306
+ /**
307
+ * The refusal, on BOTH the estimate and the submit, for a body
308
+ * {@link mockWorkflowBodyRefusal} ACCEPTED when the host has no price quote for
309
+ * it, or `null` when the submit would proceed (reserved at the ceiling). In the
310
+ * host's order: a training step anywhere (`training-quote-<failure>`), then a
311
+ * `repeat` anywhere (`workflow_repeat_unbounded`, naming the first such step).
312
+ * The message states the cause.
313
+ */
314
+ export function mockUnpricedWorkflowRefusal(body, failure) {
315
+ const types = body.steps.map((step) => collectStepTypes(step.$type, step.input) ?? [step.$type]);
316
+ if (types.some((ts) => ts.some((t) => t === 'training' || t === 'imageResourceTraining'))) {
317
+ return { error: mockUnquotedTrainingError(failure), errorCode: `training-quote-${failure}` };
318
+ }
319
+ const index = types.findIndex((ts) => ts.some(isRepeat));
320
+ if (index === -1)
321
+ return null;
322
+ return {
323
+ error: `workflow step ${index}: a workflow with a repeat needs a price quote and ${MOCK_QUOTE_FAILURE_CAUSE[failure]}`,
324
+ errorCode: 'workflow_repeat_unbounded',
325
+ };
326
+ }
327
+ /**
328
+ * The host's `unquotedWorkflowEstimateRefusal`: what an ESTIMATE of a workflow
329
+ * with no price quote fails with when {@link mockUnpricedWorkflowRefusal} did
330
+ * not already refuse it. Estimate only — the submit of the same body proceeds.
331
+ */
332
+ export function mockUnquotedWorkflowEstimateRefusal(failure) {
333
+ return {
334
+ error: `No price quote for this workflow: ${MOCK_QUOTE_FAILURE_CAUSE[failure]}`,
335
+ errorCode: `workflow_quote_${failure}`,
336
+ };
337
+ }
338
+ /** The host's wire schema, mirrored: exact keys, types and sizes. No `errorCode`. */
339
+ function isWellFormedBody(body) {
340
+ if (!isPlainObject(body) || !Object.keys(body).every((key) => BODY_KEYS.has(key)))
341
+ return false;
342
+ if (!Array.isArray(body.steps) || body.steps.length === 0)
343
+ return false;
344
+ if (body.arguments !== undefined && !isPlainObject(body.arguments))
345
+ return false;
346
+ let bytes = body.arguments ? JSON.stringify(body.arguments).length : 0;
347
+ for (const step of body.steps) {
348
+ if (!isPlainObject(step) || !Object.keys(step).every((key) => STEP_KEYS.has(key)))
349
+ return false;
350
+ const { name, $type, input, suppressOutput } = step;
351
+ if (typeof $type !== 'string' || $type.length < 1 || $type.length > MOCK_WORKFLOW_BOUNDS.maxTypeChars) {
352
+ return false;
353
+ }
354
+ if (name !== undefined &&
355
+ (typeof name !== 'string' || name.length < 1 || name.length > MOCK_WORKFLOW_BOUNDS.maxTypeChars)) {
356
+ return false;
357
+ }
358
+ if (!isPlainObject(input))
359
+ return false;
360
+ if (suppressOutput !== undefined && typeof suppressOutput !== 'boolean')
361
+ return false;
362
+ bytes += JSON.stringify(input).length;
363
+ }
364
+ return bytes <= MOCK_WORKFLOW_BOUNDS.maxInputBytes;
365
+ }
366
+ /**
367
+ * The refusal for a `kind: 'workflow'` body, or `null` for one the mock will
368
+ * price and run. In the host's order:
369
+ *
370
+ * 1. the wire shape — no `errorCode`, the host's schema rejection;
371
+ * 2. the step cap — `workflow_too_many_steps`;
372
+ * 3. step by step: the name rules and a `repeat`'s exact shape and nesting
373
+ * (`workflow_ref_invalid`); the repeat bound — only over an array in
374
+ * `arguments`, at most 16 iterations with nested repeats multiplied
375
+ * (`workflow_repeat_unbounded`); then the reference graph
376
+ * (`workflow_ref_invalid`). A name is `[a-z][a-z0-9_-]{0,31}` (or the
377
+ * step's own `$<index>`), unique, and not `block-step`; every source — a
378
+ * reference, a `repeat`'s `for`, a `merge` field — names `$arguments` or an
379
+ * EARLIER step, and a `merge` field never names a repeat variable;
380
+ * 4. a repeat `as` name that is also a step name — `workflow_ref_invalid`;
381
+ * 5. the step-type denylist on every step and on the step a `repeat` runs —
382
+ * `workflow_step_denied`.
383
+ */
384
+ export function mockWorkflowBodyRefusal(body) {
385
+ if (!isWellFormedBody(body))
386
+ return { error: 'invalid workflow body' };
387
+ const { steps } = body;
388
+ if (steps.length > MOCK_WORKFLOW_BOUNDS.maxSteps) {
389
+ return {
390
+ error: `a workflow may have at most ${MOCK_WORKFLOW_BOUNDS.maxSteps} steps (got ${steps.length})`,
391
+ errorCode: 'workflow_too_many_steps',
392
+ };
393
+ }
394
+ const invalid = (index, problem) => ({
395
+ error: `workflow step ${index}: ${problem}`,
396
+ errorCode: 'workflow_ref_invalid',
397
+ });
398
+ const earlier = new Set([ARGUMENTS_REF]);
399
+ const taken = new Set();
400
+ const state = { refs: 0, loopVars: new Set() };
401
+ const stepTypes = [];
402
+ for (const [index, step] of steps.entries()) {
403
+ const positional = `$${index}`;
404
+ const name = mockWorkflowStepName(step, index);
405
+ if (name !== positional && !STEP_NAME_PATTERN.test(name)) {
406
+ return invalid(index, `name ${JSON.stringify(name)} must match ${STEP_NAME_PATTERN.source} or be "${positional}"`);
407
+ }
408
+ if (name === RESERVED_STEP_NAME)
409
+ return invalid(index, `name "${RESERVED_STEP_NAME}" is reserved`);
410
+ if (taken.has(name))
411
+ return invalid(index, `name ${JSON.stringify(name)} is used twice`);
412
+ const types = collectStepTypes(step.$type, step.input);
413
+ if (types === null)
414
+ return invalid(index, MOCK_REPEAT_SHAPE_ERROR);
415
+ const unbounded = repeatUnboundedProblem(step.$type, step.input, body.arguments);
416
+ if (unbounded !== null) {
417
+ return { error: `workflow step ${index}: ${unbounded}`, errorCode: 'workflow_repeat_unbounded' };
418
+ }
419
+ const problem = checkStepInput(step.$type, step.input, 0, earlier, state);
420
+ if (problem)
421
+ return invalid(index, problem);
422
+ taken.add(name);
423
+ earlier.add(name);
424
+ stepTypes.push(types);
425
+ }
426
+ // A repeat variable that is also a step name (or `$arguments`) would make a
427
+ // template reference ambiguous between the two.
428
+ for (const loopVar of state.loopVars) {
429
+ if (earlier.has(loopVar)) {
430
+ return {
431
+ error: `a repeat "as" name ${JSON.stringify(loopVar)} is also a step name or "${ARGUMENTS_REF}"`,
432
+ errorCode: 'workflow_ref_invalid',
433
+ };
434
+ }
435
+ }
436
+ for (const [index, types] of stepTypes.entries()) {
437
+ const denied = types.find((t) => DENIED_LOOKUP.has(t.toLowerCase()));
438
+ if (denied !== undefined) {
439
+ return {
440
+ error: `workflow step ${index}: orchestrator step type '${denied}' is platform-internal and cannot be submitted by an app block`,
441
+ errorCode: 'workflow_step_denied',
442
+ };
443
+ }
444
+ }
445
+ return null;
446
+ }
447
+ //# sourceMappingURL=mockWorkflowGraph.js.map
package/dist/testing.d.ts CHANGED
@@ -21,7 +21,7 @@ import { __resetTransport } from './transport/singleton.js';
21
21
  import { type MockHostOptions } from './internal/mockHost.js';
22
22
  export { __resetTransport as resetTransport };
23
23
  export { installPopoverShim, type PopoverShimHandle, type PopoverShimOptions, } from './internal/popoverShim.js';
24
- export { createMockHost, readMockHostUrlOptions, type MockHost, type MockHostOptions, type MockHostFailMode, type MockHostScenarioPatch, type MockGenerationScenario, type MockBuzzScenario, type MockBuzzBalance, type MockBuzzHandle, type MockStorageScenario, type MockSharedScenario, type MockSharedSeed, type MockCannedImageScan, type CostSpec, type ImageSpec, type CannedPick, } from './internal/mockHost.js';
24
+ export { createMockHost, readMockHostUrlOptions, type MockHost, type MockHostOptions, type MockHostFailMode, type MockHostScenarioPatch, type MockGenerationScenario, type MockBuzzScenario, type MockBuzzBalance, type MockBuzzHandle, type MockStorageScenario, type MockSharedScenario, type MockSharedSeed, type MockCannedImageScan, type MockWorkflowStepInfo, type MockWorkflowQuoteFailure, type CostSpec, type ImageSpec, type CannedPick, } from './internal/mockHost.js';
25
25
  /**
26
26
  * Props for the dev {@link Harness}.
27
27
  */
@@ -94,6 +94,12 @@ const REQUEST_TIMEOUT_CLASS = {
94
94
  // viewer who then clicked Train would start a CHARGED run the block had
95
95
  // already reported as failed.
96
96
  RUN_TRAINING: 'human',
97
+ // 🔴 `'human'`, same reason as RUN_TRAINING directly above: the host shows the
98
+ // viewer the job's price in its own confirm and replies only on their click or
99
+ // dismiss. At the 30s default the request would reject with the dialog still
100
+ // open, and a viewer who then confirmed would start a CHARGED job the block
101
+ // had already reported as failed.
102
+ RUN_CONFIRMED_STEP: 'human',
97
103
  // ── Fire-and-forget: no requestId, so no pending promise to time out ────
98
104
  BLOCK_ERROR: 'no-reply',
99
105
  BLOCK_HELLO: 'no-reply',
@@ -445,6 +445,19 @@ export declare function isValidTrainingDatasetResult(p: unknown): boolean;
445
445
  * field. A dropped refusal would leave a Train button spinning for ten minutes.
446
446
  */
447
447
  export declare function isValidTrainingResult(p: unknown): boolean;
448
+ /**
449
+ * Reply to `RUN_CONFIRMED_STEP`. A well-formed reply carries EITHER a `snapshot`
450
+ * (a valid {@link isValidWorkflowSnapshot} — the block polls its `workflowId`)
451
+ * OR an `error`; one with neither is malformed and dropped.
452
+ *
453
+ * 🔴 `error` IS SHAPE-CHECKED ONLY, for the reason given on
454
+ * {@link isValidTrainingResult}: this request carries the same TEN-MINUTE
455
+ * consent bound, and the host forwards server refusals (an expired or used
456
+ * quote, a changed body or price, the feature flag) in the same field as its own
457
+ * codes. A membership check would drop those and leave a Run button spinning for
458
+ * ten minutes.
459
+ */
460
+ export declare function isValidConfirmedStepResult(p: unknown): boolean;
448
461
  /**
449
462
  * Reply to `APP_STORAGE_GET`. `value` is arbitrary JSON (incl. `null` for an
450
463
  * unset key / anon viewer), so there is nothing to type-check on it — the guard