@civitai/blocks-react 0.70.0 → 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 CHANGED
@@ -482,7 +482,7 @@ if (quote.cost) showPrice(priceLine(quote.cost));
482
482
 
483
483
  | `cost.authorFee` | Means | Where |
484
484
  |---|---|---|
485
- | a number `> 0` | that much of `total` is the app fee | `estimate()` for a `textToImage` or registered-`step` body, and the refusal `submit()` resolves for those kinds when a budget, spend cap or rate limit refuses the quoted price |
485
+ | a number `> 0` | that much of `total` is the app fee | `estimate()` for a `textToImage`, registered-`step` or `workflow` body (one fee for the whole workflow), and the refusal `submit()` resolves for those kinds when a budget, spend cap or rate limit refuses the quoted price |
486
486
  | `0` | a fee was looked up and none applies to this request | the same replies |
487
487
  | absent | the total is **not itemised** | a host that predates the field; every snapshot of a submitted workflow (`submit` success, `poll`, `cancel`), whose `total` is the generation's realized cost with the fee charged separately; the registered-`step` `submit()` refusal for a missing orchestrator price quote, which refuses before any fee is looked up; and `customComfy`, pass-through `step` and `training` quotes, which price no fee |
488
488
 
@@ -541,6 +541,164 @@ try {
541
541
  (`KnownBlockWorkflowErrorCode | (string & {})`), the host may add codes, and
542
542
  the inbound validator accepts any string. Keep a `default` branch.
543
543
  - The `kind: 'training'` arm does not set it.
544
+ - A refused `kind: 'workflow'` body sets its own three codes, in the same
545
+ shape — see the next section.
546
+
547
+ #### Several steps as one workflow: `kind: 'workflow'`
548
+
549
+ A `kind: 'workflow'` body submits **several orchestrator steps as one
550
+ workflow**, where a later step can consume an earlier step's result. It goes
551
+ through the hooks you already use: `useBuzzWorkflow().estimate / submit /
552
+ watch / cancel`, a `useBatchEstimate()` cell, and `useWorkflowBatch()`. There
553
+ is no new hook and no new message.
554
+
555
+ ```tsx
556
+ import { useBuzzWorkflow } from '@civitai/blocks-react';
557
+ import type { WorkflowBody } from '@civitai/app-sdk/blocks';
558
+
559
+ const { estimate, submit, watch } = useBuzzWorkflow();
560
+
561
+ // Step 1 draws an image; step 2 removes the background from step 1's result.
562
+ const body: WorkflowBody = {
563
+ kind: 'workflow',
564
+ arguments: { prompt: 'a lighthouse at dusk' },
565
+ steps: [
566
+ {
567
+ name: 'draw',
568
+ $type: 'imageGen',
569
+ input: {
570
+ engine: 'google',
571
+ model: 'nano-banana-2',
572
+ // Read from `arguments` above.
573
+ prompt: { $ref: '$arguments', path: 'prompt' },
574
+ },
575
+ // An intermediate step: its image is not surfaced.
576
+ suppressOutput: true,
577
+ },
578
+ {
579
+ name: 'cutout',
580
+ $type: 'imageBackgroundRemoval',
581
+ // Read from the EARLIER step named `draw`.
582
+ input: { image: { $ref: 'draw', path: 'output.images[0].url' } },
583
+ },
584
+ ],
585
+ };
586
+
587
+ const quote = await estimate(body);
588
+ showPrice(`About ${quote.cost?.total} Buzz`); // an estimate, not a maximum
589
+
590
+ const submitted = await submit(body);
591
+ const done = await watch(submitted.workflowId);
592
+
593
+ for (const step of done.steps ?? []) {
594
+ if (step.status === 'failed') showError(`${step.name} failed`);
595
+ }
596
+ // Even when `done.status === 'failed'`, earlier steps may have left images.
597
+ showImages(done.imageUrls ?? []);
598
+ ```
599
+
600
+ **The body**
601
+
602
+ - `steps`: 1 to 8 (`BLOCK_WORKFLOW_MAX_STEPS`). Each is `{ name?, $type, input,
603
+ suppressOutput? }`, where `$type` is an orchestrator step type and `input` is
604
+ that step's own input.
605
+ - **A step `name`** is optional. It is a lowercase letter followed by up to 31
606
+ of `a-z`, `0-9`, `_` or `-`, unique within the body, and not `block-step`
607
+ (reserved). A step you do not name is named by its position: `$0`, `$1`, ….
608
+ - **A reference** is `{ $ref, path, default?, format? }` and may stand in for
609
+ any value in `input`, at any depth. `$ref` is `'$arguments'`, the `name` of an
610
+ **earlier** step, or — for an earlier step you did not name — its position
611
+ (`'$0'` is the first step). A step can never reference itself or a later
612
+ step. An object is a reference only when it has both `$ref` and `path` and no
613
+ other key but `default` / `format`.
614
+ - **Limits**: 64 references per body, inputs nested at most 32 levels, `repeat`
615
+ templates nested at most 4 deep, a `$type` of at most 64 characters, and 256
616
+ KB (262144 bytes) for every step's `input` plus `arguments` together. An
617
+ unknown key on the body or on a step is rejected. The app's velocity limit
618
+ counts one unit per step, and a `repeat` step one per iteration instead.
619
+ - `suppressOutput: true` marks an intermediate step. Its images are left out of
620
+ `snapshot.imageUrls` and out of its own `steps[i].imageUrls`. It still runs
621
+ and is still charged.
622
+ - There is **no `maxBuzz`**. The spend ceiling is the app's per-generation
623
+ budget × the number of steps, enforced by the host.
624
+ - Any orchestrator `$type` is accepted except the platform-internal ones:
625
+ scanners, moderation classifiers, hashing, web scrape/search, and the retired
626
+ `textToImage` step type.
627
+ - **`repeat` over `arguments` only, at most 16 iterations**
628
+ (`BLOCK_WORKFLOW_REPEAT_MAX_ITERATIONS`, nested repeats multiplied). Its input
629
+ is exactly `{ for: { $ref: '$arguments', path, as }, template: { $type,
630
+ name?, input? } }`, where `path` names an array in `arguments`. Inside the
631
+ template, `as` is a source a reference may name; it may not equal a step
632
+ name. The template's `$type` is held to the denylist too. A `repeat` over
633
+ another step's output is refused, because its length is unknown until that
634
+ step has run. The single-step pass-through `kind: 'step'` arm refuses
635
+ `repeat` outright.
636
+ - **`merge`**: every entry of `input.fields` needs a string `$ref` (`$arguments`
637
+ or an earlier step, never a repeat variable) and a `reduce`; each counts
638
+ toward the 64-reference limit.
639
+ - `WithWorkflowStepRefs<T>` (from `@civitai/app-sdk/blocks`) types an input you
640
+ already have a type for while still allowing references in it.
641
+
642
+ **🔴 Three things about the price**
643
+
644
+ 1. **The estimate is not a maximum.** When a step reads from an earlier step,
645
+ the orchestrator re-prices it once those values are known and charges the
646
+ difference. Never show the estimate as "you will pay at most".
647
+ 2. **A workflow the host cannot price still proceeds.** `submit()` reserves the
648
+ ceiling above instead of a quote. Two exceptions are refused without a
649
+ quote, before any reservation: a workflow with a training step (a
650
+ `training-quote-*` `errorCode`, as on the single-step arm), and a workflow
651
+ with a `repeat` step at any depth (`workflow_repeat_unbounded`; a retry may
652
+ succeed).
653
+ 3. **One author fee per workflow**, not one per step, on the quoted base. The
654
+ estimate's `cost.authorFee` is that single fee, already inside `cost.total`.
655
+ If the workflow bills more than the quote, the fee charged is held to the
656
+ fee shown. A workflow that does not succeed has its **whole** fee reversed,
657
+ even if earlier steps delivered output.
658
+
659
+ **The result**
660
+
661
+ `snapshot.steps` lists every step in order, as `{ name, $type, status,
662
+ imageUrls?, output?, error? }`. Read it from `poll()`, `watch()` and
663
+ `cancel()`; do not rely on it being on the `submit()` reply. The steps a
664
+ `repeat` step generates follow it, named `<repeat name>.<n>`. The top-level
665
+ fields keep their meaning: `status` and `cost` are the whole workflow's, and
666
+ `imageUrls` is the union of the steps that did not set `suppressOutput`.
667
+
668
+ 🔴 **A `failed` workflow is not an empty one.** Steps that succeeded before the
669
+ failing step stay charged and keep their results, so a snapshot with `status:
670
+ 'failed'` can carry `cost.total > 0` and usable `imageUrls`. Show what did
671
+ finish, and do not tell the viewer nothing was charged — though the app fee is
672
+ reversed for any workflow that does not succeed.
673
+
674
+ **Refusals**
675
+
676
+ A body the host will not run is refused before anything is priced or reserved:
677
+ `estimate()` rejects with `WorkflowEstimateError`, `submit()` with
678
+ `WorkflowSubmitError`, and a batch estimate returns it as that cell. Branch on
679
+ `err.snapshot.errorCode`:
680
+
681
+ | `errorCode` | Means |
682
+ |---|---|
683
+ | `'workflow_too_many_steps'` | more than 8 steps |
684
+ | `'workflow_step_denied'` | a step names a platform-internal `$type` |
685
+ | `'workflow_ref_invalid'` | a reference points at the step itself, a later step, or nothing; a step name breaks the rules above; a `repeat` or `merge` is malformed; or a repeat `as` name is also a step name |
686
+ | `'workflow_repeat_unbounded'` | a `repeat` iterates something other than an array in `arguments`, or more than 16 times (nested repeats multiplied); or the workflow has a `repeat` and no price quote could be had (retrying may work) |
687
+
688
+ - **Requires a host that knows the kind.** An older host refuses the body.
689
+ - **Test it locally.** The mock host prices each step with
690
+ `generation.costPerGen` (a function gets the step as its second argument) and
691
+ multiplies it by a `repeat` step's iteration count, then adds
692
+ `generation.authorFee` once; refuses with the four codes above, in the host's
693
+ order; reports
694
+ `steps` on every poll, finishing them in order; and
695
+ `generation.failWorkflowStep: '<name>'` (`'$<index>'` for an unnamed step)
696
+ makes one step fail, so you can render a partial failure. The mock checks
697
+ where a reference points, not whether its `path` exists; it never re-prices;
698
+ and it does not expand a `repeat` step into generated `<name>.<n>` steps. It
699
+ quotes every workflow unless you set `generation.workflowUnpriced: true`,
700
+ which simulates no quote: a workflow with a training step or a `repeat` is
701
+ refused, and any other is reserved at `buzzBudget` × steps with no fee.
544
702
 
545
703
  > **Estimate must mirror submit** (gotcha #59): build the params for `estimate`
546
704
  > with the *exact* same logic as `submit` — same seed decision especially. The
@@ -2665,6 +2823,7 @@ MockHostScenarioPatch
2665
2823
  MockSharedScenario
2666
2824
  MockSharedSeed
2667
2825
  MockStorageScenario
2826
+ MockWorkflowStepInfo
2668
2827
  PopoverShimHandle
2669
2828
  PopoverShimOptions
2670
2829
  ```
@@ -698,6 +698,22 @@ export interface UseBuzzWorkflow {
698
698
  * the paragraph above records, one member over: an incomplete description of a
699
699
  * union, trusted because it read as authoritative.
700
700
  *
701
+ * 🔴 A MULTI-STEP `kind: 'workflow'` BODY CHANGES WHAT THE PRICE MEANS — again a
702
+ * capability claim, so it is stated here. `WorkflowBodyWorkflow` submits several
703
+ * orchestrator steps as one workflow through these same calls, and:
704
+ * - `estimate()`'s `cost.total` is NOT a maximum: the orchestrator re-prices a
705
+ * step once the values it references are known and charges the difference;
706
+ * - a workflow the host cannot price still proceeds on `submit()`, reserved at
707
+ * the app's per-generation budget × the number of steps (one with a training
708
+ * step, or with a `repeat` step at any depth, is refused instead);
709
+ * - one author fee applies to the whole workflow, not one per step, and it is
710
+ * reversed whole if the workflow does not succeed;
711
+ * - `repeat` iterates only an array in the body's `arguments`, at most 16
712
+ * times, nested repeats multiplied;
713
+ * - a `failed` result can carry `cost.total > 0` and usable `imageUrls`, because
714
+ * steps that succeeded before the failing one stay charged — read
715
+ * `result.steps`.
716
+ *
701
717
  * @returns `{ estimate, submit, poll, watch, cancel, status, result, error }`.
702
718
  *
703
719
  * @example
@@ -523,6 +523,22 @@ export class WorkflowSubmitError extends Error {
523
523
  * the paragraph above records, one member over: an incomplete description of a
524
524
  * union, trusted because it read as authoritative.
525
525
  *
526
+ * 🔴 A MULTI-STEP `kind: 'workflow'` BODY CHANGES WHAT THE PRICE MEANS — again a
527
+ * capability claim, so it is stated here. `WorkflowBodyWorkflow` submits several
528
+ * orchestrator steps as one workflow through these same calls, and:
529
+ * - `estimate()`'s `cost.total` is NOT a maximum: the orchestrator re-prices a
530
+ * step once the values it references are known and charges the difference;
531
+ * - a workflow the host cannot price still proceeds on `submit()`, reserved at
532
+ * the app's per-generation budget × the number of steps (one with a training
533
+ * step, or with a `repeat` step at any depth, is refused instead);
534
+ * - one author fee applies to the whole workflow, not one per step, and it is
535
+ * reversed whole if the workflow does not succeed;
536
+ * - `repeat` iterates only an array in the body's `arguments`, at most 16
537
+ * times, nested repeats multiplied;
538
+ * - a `failed` result can carry `cost.total > 0` and usable `imageUrls`, because
539
+ * steps that succeeded before the failing one stay charged — read
540
+ * `result.steps`.
541
+ *
526
542
  * @returns `{ estimate, submit, poll, watch, cancel, status, result, error }`.
527
543
  *
528
544
  * @example
@@ -77,7 +77,21 @@ export type MockCannedImageScan = 'scanned' | {
77
77
  * A per-generation cost: a fixed number, or a function of the submitted
78
78
  * {@link WorkflowBody} (so a dev can vary cost by model / step count).
79
79
  */
80
- export type CostSpec = number | ((req: WorkflowBody) => number);
80
+ export type CostSpec = number | ((req: WorkflowBody, step?: MockWorkflowStepInfo) => number);
81
+ /**
82
+ * The step a {@link CostSpec} function is pricing, passed as its SECOND
83
+ * argument when the body is `kind: 'workflow'`: the mock calls
84
+ * {@link MockGenerationScenario.costPerGen} once per step and sums the results.
85
+ * `undefined` for every other kind, which is priced with one call.
86
+ */
87
+ export interface MockWorkflowStepInfo {
88
+ /** Zero-based position in the body's `steps`. */
89
+ index: number;
90
+ /** The step's `name`, or `'$<index>'` for an unnamed step. */
91
+ name: string;
92
+ $type: string;
93
+ input: Record<string, unknown>;
94
+ }
81
95
  /**
82
96
  * A result image url: a fixed string, or a function of the submitted body
83
97
  * (so a dev can echo the prompt into a placeholder).
@@ -91,6 +105,12 @@ export interface MockGenerationScenario {
91
105
  /**
92
106
  * Cost reported on `ESTIMATE_RESULT` + the succeeded snapshot. A number, or
93
107
  * a `(body) => number`. Overrides the legacy top-level `cost`. Default `8`.
108
+ *
109
+ * A `kind: 'workflow'` body is priced PER STEP: this is called once for each
110
+ * step (a function receives the step as its second argument, see
111
+ * {@link MockWorkflowStepInfo}), multiplied by that step's generations (a
112
+ * `repeat` counts its iterations, nested repeats multiplied), and summed, so a
113
+ * plain number prices an N-step workflow with no `repeat` at N times it.
94
114
  */
95
115
  costPerGen?: CostSpec;
96
116
  /**
@@ -100,9 +120,10 @@ export interface MockGenerationScenario {
100
120
  * real host does. A number, or a `(body) => number`. Default `0`, which is
101
121
  * reported as `authorFee: 0` ("no fee on this request").
102
122
  *
103
- * Like the real host, only a `textToImage` or registered-`step` body is
104
- * itemised; `customComfy`, pass-through `step` and `training` quotes carry no
105
- * `authorFee`. The succeeded snapshot's `cost.total` stays {@link costPerGen}
123
+ * Like the real host, only a `textToImage`, registered-`step` or `workflow`
124
+ * body is itemised; `customComfy`, pass-through `step` and `training` quotes
125
+ * carry no `authorFee`. A `workflow` body is charged this ONCE, however many
126
+ * steps it has. The succeeded snapshot's `cost.total` stays {@link costPerGen}
106
127
  * (a submitted workflow reports the generation's own cost), while a simulated
107
128
  * {@link MockBuzzScenario.balance} is debited `costPerGen + authorFee`.
108
129
  *
@@ -265,7 +286,44 @@ export interface MockGenerationScenario {
265
286
  * Default: unset.
266
287
  */
267
288
  submitCapRefusal?: string | true;
268
- /** A single result image url (or `(body) => url`). */
289
+ /**
290
+ * Make ONE step of a `kind: 'workflow'` run fail, so a block can exercise a
291
+ * PARTIAL failure. Name the step as its snapshot entry names it: its `name`,
292
+ * or `'$<index>'` for a step the body left unnamed. The poll that finishes
293
+ * the workflow then answers `status: 'failed'` with:
294
+ * - `steps`: every step before it `'succeeded'`, the named step `'failed'`
295
+ * with an `error`, every step after it `'canceled'`;
296
+ * - `cost.total`: the sum of the steps that succeeded (they stay charged), so
297
+ * it is `> 0` unless the FIRST step is the one that fails;
298
+ * - `imageUrls`: the images of the succeeded steps that did not set
299
+ * `suppressOutput`.
300
+ *
301
+ * What the real orchestrator reports for the steps AFTER a failed one is its
302
+ * own business; `'canceled'` here is the mock's stand-in for "did not run".
303
+ *
304
+ * Ignored by every other kind, and when no step matches. Takes precedence
305
+ * over {@link workflowOutcome} for the workflow it applies to. Live-tunable
306
+ * via `setScenario({ generation: { failWorkflowStep } })`. Default: unset.
307
+ */
308
+ failWorkflowStep?: string;
309
+ /**
310
+ * Simulate the host getting NO price quote for a `kind: 'workflow'` body. The
311
+ * host then refuses, before any reservation, a workflow with a training step
312
+ * (`errorCode: 'training-quote-unavailable'`) or a `repeat` step at any depth
313
+ * (`'workflow_repeat_unbounded'`); any other workflow proceeds reserved at the
314
+ * CEILING — {@link MockHostOptions.buzzBudget} × the number of steps — which
315
+ * the estimate reports as `cost.total`, with `authorFee: 0` (no quote, no
316
+ * fee base). Ignored by every other kind. Live-tunable via
317
+ * `setScenario({ generation: { workflowUnpriced } })`. Default: unset (every
318
+ * workflow is quoted at its per-step `costPerGen`).
319
+ */
320
+ workflowUnpriced?: boolean;
321
+ /**
322
+ * A single result image url (or `(body) => url`). For a `kind: 'workflow'`
323
+ * run this (and {@link images}) is the LAST step that did not set
324
+ * `suppressOutput`; each earlier visible step gets its own labelled
325
+ * placeholder.
326
+ */
269
327
  image?: ImageSpec;
270
328
  /**
271
329
  * Multiple result image urls (or `(body) => url[]`). Takes precedence over
@@ -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.
@@ -990,10 +994,35 @@ export function createMockHost(options = {}) {
990
994
  viewerVoted: row.voters.has(mockUserId),
991
995
  });
992
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`.
993
998
  const costFor = (body) => {
999
+ if (body.kind === 'workflow')
1000
+ return workflowStepCosts(body).reduce((sum, c) => sum + c, 0);
994
1001
  const spec = gen.costPerGen ?? legacyCost;
995
1002
  return typeof spec === 'function' ? spec(body) : (spec ?? legacyCost);
996
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
+ };
997
1026
  // `null` = this quote is not itemised (a fee-less kind, or `authorFee: false`).
998
1027
  const authorFeeFor = (body) => {
999
1028
  if (gen.authorFee === false)
@@ -1001,9 +1030,16 @@ export function createMockHost(options = {}) {
1001
1030
  // A REGISTERED step is keyed on the VALUE of `step`, as the host keys it —
1002
1031
  // never `'step' in body`: `{ kind: 'step', step: undefined, ... }` is a legal
1003
1032
  // pass-through body, and a pass-through quote prices no fee.
1004
- const quotesFee = body.kind === 'textToImage' || (body.kind === 'step' && typeof body.step === 'string');
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');
1005
1038
  if (!quotesFee)
1006
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;
1007
1043
  const spec = gen.authorFee ?? 0;
1008
1044
  return typeof spec === 'function' ? spec(body) : spec;
1009
1045
  };
@@ -1149,10 +1185,88 @@ export function createMockHost(options = {}) {
1149
1185
  ...(gen.trainingPublishedModel ? { publishedModel: { ...gen.trainingPublishedModel } } : {}),
1150
1186
  };
1151
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
+ };
1152
1257
  const succeededSnapshot = (workflowId) => {
1153
1258
  const wf = workflows.get(workflowId);
1154
1259
  const cost = wf?.cost ?? legacyCost;
1155
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
+ }
1156
1270
  return {
1157
1271
  ...trainingFields(body),
1158
1272
  workflowId,
@@ -1328,6 +1442,16 @@ export function createMockHost(options = {}) {
1328
1442
  },
1329
1443
  };
1330
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
+ }
1331
1455
  return {
1332
1456
  workflowId: 'wf_estimate',
1333
1457
  status: 'pending',
@@ -1611,6 +1735,23 @@ export function createMockHost(options = {}) {
1611
1735
  });
1612
1736
  return;
1613
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
+ }
1614
1755
  const cost = costFor(body);
1615
1756
  // What the viewer pays: the generation plus the app fee.
1616
1757
  const charged = cost + (authorFeeFor(body) ?? 0);
@@ -1758,7 +1899,12 @@ export function createMockHost(options = {}) {
1758
1899
  if (balanceSimulated)
1759
1900
  buzz.balance = buzz.balance - charged;
1760
1901
  const workflowId = `wf_${submitCount}_${Date.now()}`;
1761
- workflows.set(workflowId, { polls: 0, cost, body });
1902
+ workflows.set(workflowId, {
1903
+ polls: 0,
1904
+ cost,
1905
+ body,
1906
+ ...(body.kind === 'workflow' ? { stepCosts: workflowStepCosts(body) } : {}),
1907
+ });
1762
1908
  dispatchToBlock({
1763
1909
  type: 'WORKFLOW_SUBMITTED',
1764
1910
  payload: { requestId, snapshot: { workflowId, status: 'pending' } },
@@ -1784,6 +1930,16 @@ export function createMockHost(options = {}) {
1784
1930
  }
1785
1931
  const polls = wf.polls + 1;
1786
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
+ }
1787
1943
  const outcome = polls >= pollsUntilDone ? (gen.workflowOutcome?.(workflowId, wf.body) ?? 'succeeded') : null;
1788
1944
  if (outcome === 'failed' || outcome === 'expired') {
1789
1945
  dispatchToBlock({
@@ -1814,7 +1970,17 @@ export function createMockHost(options = {}) {
1814
1970
  else {
1815
1971
  dispatchToBlock({
1816
1972
  type: 'WORKFLOW_STATUS',
1817
- payload: { requestId, snapshot: { workflowId, status: 'processing' } },
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
+ },
1818
1984
  });
1819
1985
  }
1820
1986
  return;
@@ -1835,6 +2001,14 @@ export function createMockHost(options = {}) {
1835
2001
  if (wf && !wf.canceled && wf.polls >= pollsUntilDone) {
1836
2002
  // Already finished: like the real host, report the real terminal
1837
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
+ }
1838
2012
  const outcome = gen.workflowOutcome?.(workflowId, wf.body) ?? 'succeeded';
1839
2013
  dispatchToBlock({
1840
2014
  type: 'WORKFLOW_CANCELED',
@@ -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
@@ -0,0 +1,424 @@
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
+ /** The host's refusal text for an unquoted training step (`UNQUOTED_TRAINING_ERROR`). */
293
+ export const MOCK_UNQUOTED_TRAINING_ERROR = 'Training needs a price quote and none could be obtained; try again.';
294
+ /**
295
+ * The refusal for a body {@link mockWorkflowBodyRefusal} ACCEPTED when the host
296
+ * could get NO price quote for it, or `null` when it would proceed (reserved at
297
+ * the ceiling). In the host's order: a training step anywhere (its own
298
+ * `training-quote-*` refusal — the mock reports `'training-quote-unavailable'`,
299
+ * the code for "no usable answer"), then a `repeat` anywhere
300
+ * (`workflow_repeat_unbounded`, naming the first such step).
301
+ */
302
+ export function mockUnpricedWorkflowRefusal(body) {
303
+ const types = body.steps.map((step) => collectStepTypes(step.$type, step.input) ?? [step.$type]);
304
+ if (types.some((ts) => ts.some((t) => t === 'training' || t === 'imageResourceTraining'))) {
305
+ return { error: MOCK_UNQUOTED_TRAINING_ERROR, errorCode: 'training-quote-unavailable' };
306
+ }
307
+ const index = types.findIndex((ts) => ts.some(isRepeat));
308
+ if (index === -1)
309
+ return null;
310
+ return {
311
+ error: `workflow step ${index}: a workflow with a repeat needs a price quote and none could be obtained; try again`,
312
+ errorCode: 'workflow_repeat_unbounded',
313
+ };
314
+ }
315
+ /** The host's wire schema, mirrored: exact keys, types and sizes. No `errorCode`. */
316
+ function isWellFormedBody(body) {
317
+ if (!isPlainObject(body) || !Object.keys(body).every((key) => BODY_KEYS.has(key)))
318
+ return false;
319
+ if (!Array.isArray(body.steps) || body.steps.length === 0)
320
+ return false;
321
+ if (body.arguments !== undefined && !isPlainObject(body.arguments))
322
+ return false;
323
+ let bytes = body.arguments ? JSON.stringify(body.arguments).length : 0;
324
+ for (const step of body.steps) {
325
+ if (!isPlainObject(step) || !Object.keys(step).every((key) => STEP_KEYS.has(key)))
326
+ return false;
327
+ const { name, $type, input, suppressOutput } = step;
328
+ if (typeof $type !== 'string' || $type.length < 1 || $type.length > MOCK_WORKFLOW_BOUNDS.maxTypeChars) {
329
+ return false;
330
+ }
331
+ if (name !== undefined &&
332
+ (typeof name !== 'string' || name.length < 1 || name.length > MOCK_WORKFLOW_BOUNDS.maxTypeChars)) {
333
+ return false;
334
+ }
335
+ if (!isPlainObject(input))
336
+ return false;
337
+ if (suppressOutput !== undefined && typeof suppressOutput !== 'boolean')
338
+ return false;
339
+ bytes += JSON.stringify(input).length;
340
+ }
341
+ return bytes <= MOCK_WORKFLOW_BOUNDS.maxInputBytes;
342
+ }
343
+ /**
344
+ * The refusal for a `kind: 'workflow'` body, or `null` for one the mock will
345
+ * price and run. In the host's order:
346
+ *
347
+ * 1. the wire shape — no `errorCode`, the host's schema rejection;
348
+ * 2. the step cap — `workflow_too_many_steps`;
349
+ * 3. step by step: the name rules and a `repeat`'s exact shape and nesting
350
+ * (`workflow_ref_invalid`); the repeat bound — only over an array in
351
+ * `arguments`, at most 16 iterations with nested repeats multiplied
352
+ * (`workflow_repeat_unbounded`); then the reference graph
353
+ * (`workflow_ref_invalid`). A name is `[a-z][a-z0-9_-]{0,31}` (or the
354
+ * step's own `$<index>`), unique, and not `block-step`; every source — a
355
+ * reference, a `repeat`'s `for`, a `merge` field — names `$arguments` or an
356
+ * EARLIER step, and a `merge` field never names a repeat variable;
357
+ * 4. a repeat `as` name that is also a step name — `workflow_ref_invalid`;
358
+ * 5. the step-type denylist on every step and on the step a `repeat` runs —
359
+ * `workflow_step_denied`.
360
+ */
361
+ export function mockWorkflowBodyRefusal(body) {
362
+ if (!isWellFormedBody(body))
363
+ return { error: 'invalid workflow body' };
364
+ const { steps } = body;
365
+ if (steps.length > MOCK_WORKFLOW_BOUNDS.maxSteps) {
366
+ return {
367
+ error: `a workflow may have at most ${MOCK_WORKFLOW_BOUNDS.maxSteps} steps (got ${steps.length})`,
368
+ errorCode: 'workflow_too_many_steps',
369
+ };
370
+ }
371
+ const invalid = (index, problem) => ({
372
+ error: `workflow step ${index}: ${problem}`,
373
+ errorCode: 'workflow_ref_invalid',
374
+ });
375
+ const earlier = new Set([ARGUMENTS_REF]);
376
+ const taken = new Set();
377
+ const state = { refs: 0, loopVars: new Set() };
378
+ const stepTypes = [];
379
+ for (const [index, step] of steps.entries()) {
380
+ const positional = `$${index}`;
381
+ const name = mockWorkflowStepName(step, index);
382
+ if (name !== positional && !STEP_NAME_PATTERN.test(name)) {
383
+ return invalid(index, `name ${JSON.stringify(name)} must match ${STEP_NAME_PATTERN.source} or be "${positional}"`);
384
+ }
385
+ if (name === RESERVED_STEP_NAME)
386
+ return invalid(index, `name "${RESERVED_STEP_NAME}" is reserved`);
387
+ if (taken.has(name))
388
+ return invalid(index, `name ${JSON.stringify(name)} is used twice`);
389
+ const types = collectStepTypes(step.$type, step.input);
390
+ if (types === null)
391
+ return invalid(index, MOCK_REPEAT_SHAPE_ERROR);
392
+ const unbounded = repeatUnboundedProblem(step.$type, step.input, body.arguments);
393
+ if (unbounded !== null) {
394
+ return { error: `workflow step ${index}: ${unbounded}`, errorCode: 'workflow_repeat_unbounded' };
395
+ }
396
+ const problem = checkStepInput(step.$type, step.input, 0, earlier, state);
397
+ if (problem)
398
+ return invalid(index, problem);
399
+ taken.add(name);
400
+ earlier.add(name);
401
+ stepTypes.push(types);
402
+ }
403
+ // A repeat variable that is also a step name (or `$arguments`) would make a
404
+ // template reference ambiguous between the two.
405
+ for (const loopVar of state.loopVars) {
406
+ if (earlier.has(loopVar)) {
407
+ return {
408
+ error: `a repeat "as" name ${JSON.stringify(loopVar)} is also a step name or "${ARGUMENTS_REF}"`,
409
+ errorCode: 'workflow_ref_invalid',
410
+ };
411
+ }
412
+ }
413
+ for (const [index, types] of stepTypes.entries()) {
414
+ const denied = types.find((t) => DENIED_LOOKUP.has(t.toLowerCase()));
415
+ if (denied !== undefined) {
416
+ return {
417
+ error: `workflow step ${index}: orchestrator step type '${denied}' is platform-internal and cannot be submitted by an app block`,
418
+ errorCode: 'workflow_step_denied',
419
+ };
420
+ }
421
+ }
422
+ return null;
423
+ }
424
+ //# 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 CostSpec, type ImageSpec, type CannedPick, } from './internal/mockHost.js';
25
25
  /**
26
26
  * Props for the dev {@link Harness}.
27
27
  */
@@ -334,6 +334,41 @@ export function isValidWorkflowSnapshot(s) {
334
334
  return false;
335
335
  if (s.trainingQuote !== undefined && !isValidTrainingQuote(s.trainingQuote))
336
336
  return false;
337
+ if (s.steps !== undefined && !isValidWorkflowSteps(s.steps))
338
+ return false;
339
+ return true;
340
+ }
341
+ /**
342
+ * Shape check for `steps` — the per-step view of a `kind: 'workflow'` run.
343
+ * OPTIONAL like its neighbours, and a PRESENT but malformed value rejects the
344
+ * snapshot: the block reads `name`, `$type`, `status` and `imageUrls` off every
345
+ * entry.
346
+ *
347
+ * `status` is checked against the SAME closed set as the snapshot's own
348
+ * `status`, unlike the open string unions above: it is typed as that closed
349
+ * union and a block `switch`es on it, so a snapshot carrying any other value is
350
+ * dropped rather than handed over. `output` is `unknown` by contract and is not
351
+ * inspected.
352
+ */
353
+ function isValidWorkflowSteps(steps) {
354
+ if (!Array.isArray(steps))
355
+ return false;
356
+ for (const step of steps) {
357
+ if (!isObject(step))
358
+ return false;
359
+ if (typeof step.name !== 'string' || typeof step.$type !== 'string')
360
+ return false;
361
+ if (typeof step.status !== 'string' || !WORKFLOW_STATUSES.has(step.status))
362
+ return false;
363
+ if (step.imageUrls !== undefined) {
364
+ if (!Array.isArray(step.imageUrls))
365
+ return false;
366
+ if (!step.imageUrls.every((u) => typeof u === 'string'))
367
+ return false;
368
+ }
369
+ if (step.error !== undefined && typeof step.error !== 'string')
370
+ return false;
371
+ }
337
372
  return true;
338
373
  }
339
374
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/blocks-react",
3
- "version": "0.70.0",
3
+ "version": "0.71.0",
4
4
  "description": "React hooks and iframe transport for Civitai Apps. Pairs with @civitai/app-sdk/blocks.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -58,7 +58,7 @@
58
58
  "typescript": "^5.9.2",
59
59
  "vite": "^8.0.14",
60
60
  "vitest": "^4.1.11",
61
- "@civitai/app-sdk": "^0.65.1"
61
+ "@civitai/app-sdk": "^0.66.0"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public"