@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 +160 -1
- package/dist/hooks/useBuzzWorkflow.d.ts +16 -0
- package/dist/hooks/useBuzzWorkflow.js +16 -0
- package/dist/internal/mockHost.d.ts +63 -5
- package/dist/internal/mockHost.js +177 -3
- package/dist/internal/mockWorkflowGraph.d.ts +104 -0
- package/dist/internal/mockWorkflowGraph.js +424 -0
- package/dist/testing.d.ts +1 -1
- package/dist/transport/validate.js +35 -0
- package/package.json +2 -2
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
|
|
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
|
|
104
|
-
* itemised; `customComfy`, pass-through `step` and `training` quotes
|
|
105
|
-
* `authorFee`.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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, {
|
|
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: {
|
|
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.
|
|
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.
|
|
61
|
+
"@civitai/app-sdk": "^0.66.0"
|
|
62
62
|
},
|
|
63
63
|
"publishConfig": {
|
|
64
64
|
"access": "public"
|