@civitai/blocks-react 0.64.0 → 0.65.1

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
@@ -325,12 +325,10 @@ if (priced) {
325
325
  try {
326
326
  const snap = await submit(body); // status 'submitting' → 'polling'
327
327
  if (snap.status === 'failed') {
328
- // 🔴 A RESOLVED `failed` IS A PRICED SERVER OUTCOME — and only SOME of
329
- // them are about the viewer's wallet. Affordability (per-call budget, the
330
- // per-user daily Buzz cap) IS fixable by buying Buzz; the per-app velocity
331
- // limit, the per-app aggregate daily cap, a fail-closed "temporarily
332
- // unavailable" deny and a missing price quote are NOT. Selling Buzz for
333
- // one of those takes money and fixes nothing, so branch before you offer.
328
+ // 🔴 A RESOLVED `failed` IS A SERVER OUTCOME, NEVER A TOP-UP CUE: a spend
329
+ // cap or limit buying Buzz does not raise, or a run that may already have
330
+ // spent. The complete list is on `useBuzzWorkflow`'s `submit` docs.
331
+ // Running OUT of Buzz rejects instead — see the catch below.
334
332
  showError(submitOutcomeMessage(snap)); // YOUR app owns this copy
335
333
  } else {
336
334
  await poll(snap.workflowId); // you loop this on a backoff until terminal
@@ -375,11 +373,14 @@ if (priced) {
375
373
  `status: 'failed'`, an `error` string, **and a numeric `cost.total`** — the
376
374
  price the server refused to charge. That is a workflow *outcome*, not an error.
377
375
  Check `snap.status`, not just `try/catch`.
378
- 🔴 **Not all of them are affordability.** Only the per-call `buzzBudget` gate
379
- and the per-user daily Buzz cap are about the wallet. The per-app **velocity**
380
- limit, the per-app **aggregate daily** cap, a fail-closed **"temporarily
381
- unavailable"** deny and a **missing price quote** are priced outcomes too, and
382
- buying Buzz fixes none of them. Branch before you offer a top-up.
376
+ 🔴 **None of them is a top-up cue.** Each is a spend cap or limit that buying
377
+ Buzz does not raise — the per-call `buzzBudget` and the viewer's daily cap
378
+ included — or a reply that may already have spent. The complete list, and
379
+ which of them may say "nothing was charged", is in `useBuzzWorkflow`'s
380
+ `submit` docs. A viewer who is genuinely **out of Buzz** makes `submit`
381
+ **reject** (`WorkflowSubmitError` code `'exception'`, shared with other
382
+ thrown submits): decide a top-up from `useBuzzBalance()` — blue plus the
383
+ block's domain pool, never all three — against the quoted cost.
383
384
  - **`submit` REJECTS when the reply carries no usable workflow outcome**
384
385
  (`@civitai/blocks-react@0.44.0+`). Every failure-shaped reply reports
385
386
  `status: 'failed'`, so `status` cannot tell them apart — **`cost` presence
@@ -468,7 +469,9 @@ if (priced) {
468
469
 
469
470
  ### `useBuzzPurchase()`
470
471
 
471
- Open the Buzz purchase modal — the insufficient-budget recovery path.
472
+ Open the Buzz purchase modal. It raises the viewer's WALLET, never a spend cap:
473
+ offer it when the viewer's spendable Buzz is below a quoted cost, never on a
474
+ resolved `failed` submit (see `useBuzzWorkflow`'s `submit` docs).
472
475
 
473
476
  ```tsx
474
477
  const { openPurchaseModal } = useBuzzPurchase();
@@ -735,6 +738,126 @@ options drive both arms (including `declined`). **`dev:live` refuses this bridge
735
738
  it has no civitai chrome to render the server-resolved confirm in, and driving
736
739
  the write without it would let dev prove out a flow production does not have.
737
740
 
741
+ ### `usePrepareTrainingDataset()` and `useRunTraining()` — LoRA training
742
+
743
+ Train a LoRA with the ai-toolkit engine on the **viewer's own images**, from
744
+ inside a page app — the App Blocks `kind: 'training'` flow. Four steps, three
745
+ hooks:
746
+
747
+ 1. **Dataset** — `usePrepareTrainingDataset().prepareDataset([{ imageId, caption }])`
748
+ (`PREPARE_TRAINING_DATASET`). Only the viewer's own scanned, unflagged images
749
+ within the page's maturity ceiling are admitted; the rest come back in
750
+ `rejected` with a reason (`unavailable`, `unsupported-media`, `not-eligible`,
751
+ `pending-scan`, `import-failed`, `import-unavailable`; `pending-scan` and
752
+ `import-unavailable` are retryable). You get an opaque `datasetId` and the
753
+ admitted `count`, always at least 1 — if **nothing** is admitted the call
754
+ rejects with the server's message instead. 1–50 images, captions up to 1,000 characters; captions are
755
+ moderated. No dialog, no charge.
756
+ 2. **Quote** — `useBuzzWorkflow().estimate(body)` with a `WorkflowBodyTraining`
757
+ (`kind: 'training'`, no `quoteId`). The reply carries `trainingQuote:
758
+ { quoteId, total, imageCount, expiresAt }` — the orchestrator's own price for
759
+ exactly this run, stored server-side for 15 minutes.
760
+ 3. **Run** — `useRunTraining().runTraining({ ...body, quoteId })` (`RUN_TRAINING`).
761
+ **Civitai shows the viewer a consent dialog in its own chrome** — price, base
762
+ model, length and dataset size, all read back from the server, never from
763
+ your body — and submits only on their click. Change nothing in the body
764
+ between the estimate and the run; the server checks it against the quoted one.
765
+ 4. **Follow** — `useBuzzWorkflow().watch(workflowId)`. Once the run's moderation
766
+ status is approved, `trainedEpochs` lists the epochs whose checkpoint is
767
+ ready; send the viewer to the publish wizard with one of them (see
768
+ `trainedEpochs` on `BlockWorkflowSnapshot`).
769
+
770
+ 🔴 **Availability.** Behind the host flag **`app-blocks-training-kind`**, which
771
+ ships **off** and is evaluated per viewer. **Page apps only** — the model slot
772
+ answers both messages with an error. The manifest must declare
773
+ **`ai:write:budgeted`** and the viewer must be signed in and have granted it.
774
+ **Refused from `dev:live` and from review sessions** (the server refuses
775
+ development and review tokens); build the flow against the mock host. Requires a
776
+ civitai.com host carrying civitai/civitai#5434 (`kind: 'training'`,
777
+ `RUN_TRAINING`) and civitai/civitai#5438 (`PREPARE_TRAINING_DATASET`).
778
+
779
+ 🔴 **Money.** There is no `maxBuzz` and no timeout knob — the price is the quote.
780
+ A confirmed run may cost more than the token's per-call `buzzBudget` (the viewer
781
+ confirms the exact price), up to `BLOCK_TRAINING_MAX_BUZZ_PER_RUN` (5,000 Buzz);
782
+ an estimate above that is refused. `useBuzzWorkflow().submit()` **refuses** a
783
+ training body before sending anything: only `RUN_TRAINING`'s dialog can confirm a
784
+ quote, so a training run starts nowhere else.
785
+
786
+ `runTraining` **rejects** with a `RunTrainingError`; read the flags before
787
+ saying anything about money:
788
+
789
+ | | meaning | what to do |
790
+ |---|---|---|
791
+ | `err.declined` | the viewer dismissed the dialog — **no run was submitted** | revert, say nothing |
792
+ | `err.unconfirmed` | `submission-unconfirmed`, or no reply within the 10-min bound (`err.timedOut`) — the run **may be running and charged** | 🔴 **never retry automatically**: check the viewer's trainings first (`useAppWorkflows()` lists this app's runs). Re-running the same body after the server did start it is a **second, separately charged run** |
793
+ | `err.refused` (`code: 'refused'`) | the server refused the submit after the viewer confirmed — a **spend cap** (their daily or private-run Buzz cap, the per-app consent budget, the app's daily spend or rate limit, a dev-session cap) or a **temporary-availability** deny. Refunded: **no run, nothing charged**; the quote is used up | show `err.message` (the server's reason, e.g. `daily Buzz cap reached: …`); estimate again before any retry — buying Buzz does not lift these caps |
794
+ | `err.signInRequired` | no session | route into `useRequestSignIn()` |
795
+ | `err.code` set otherwise | a host refusal (`review-mode` / `block is not ready` / `invalid training request` / `no block token`) | show or ignore per case |
796
+ | `err.code === undefined`, no flag | a server refusal before any submit (expired or used quote, changed body, ineligible image) | estimate again, then retry |
797
+
798
+ `prepareDataset` rejects with a `PrepareTrainingDatasetError` (`.code` for the
799
+ host's refusals — `review-mode`, `block is not ready`, `sign in to train`,
800
+ `invalid training dataset`, `no block token` — plus `.signInRequired` and
801
+ `.timedOut`). Preparing charges nothing, so none of them cost Buzz. It prompts
802
+ for `ai:write:budgeted` and retries once on a grant (see the table below);
803
+ `runTraining` deliberately does not — it carries no idempotency key and has an
804
+ outcome where a run may exist, so it never re-sends.
805
+
806
+ ```tsx
807
+ const { prepareDataset } = usePrepareTrainingDataset();
808
+ const { estimate, watch } = useBuzzWorkflow();
809
+ const { runTraining } = useRunTraining();
810
+
811
+ async function train(images: Array<{ id: number; caption: string }>) {
812
+ // Rejects (no `.code`) when NOTHING is admitted, e.g. "none of the requested
813
+ // images can be used for training"; a resolved dataset always has count >= 1.
814
+ const dataset = await prepareDataset(images.map((i) => ({ imageId: i.id, caption: i.caption })));
815
+ if (dataset.rejected.length > 0) showNotice(`${dataset.rejected.length} image(s) left out`);
816
+
817
+ const body: WorkflowBodyTraining = {
818
+ kind: 'training',
819
+ datasetId: dataset.datasetId,
820
+ engine: 'ai-toolkit',
821
+ model: baseModelKey, // a key from Civitai's training catalog
822
+ params: aiToolkitParams, // AiToolkitTrainingParams
823
+ triggerWord: 'mystyle',
824
+ samplePrompts: ['mystyle, a lighthouse at dusk'],
825
+ };
826
+ const quote = (await estimate(body)).trainingQuote;
827
+ if (!quote) return showError('No training price came back.');
828
+ // Show quote.total; the host's dialog will show the same number.
829
+
830
+ try {
831
+ const started = await runTraining({ ...body, quoteId: quote.quoteId });
832
+ const done = await watch(started.workflowId, { onUpdate: render });
833
+ setEpochs(done.trainedEpochs ?? []);
834
+ } catch (err) {
835
+ if (!(err instanceof RunTrainingError)) throw err;
836
+ if (err.declined) return; // no run
837
+ if (err.unconfirmed) return showCheckYourTrainings(); // may be running — never auto-retry
838
+ if (err.refused) return showError(err.message); // a cap / availability refusal; no run, nothing charged
839
+ if (err.signInRequired) return requestSignIn();
840
+ showError('Could not start training. Get a new price and try again.');
841
+ }
842
+ }
843
+ ```
844
+
845
+ With the mock host — `createMockHost` or `Harness` from
846
+ `@civitai/blocks-react/testing` — the training path is **kind-faithful**: it
847
+ holds the datasets it prepared and the quotes it stored, refuses an unknown
848
+ dataset, a dataset with nothing admitted, a quote above 5,000 and a quote run
849
+ twice, and needs
850
+ `ai:write:budgeted` on the token and a signed-in viewer. Its knobs are
851
+ `trainingDatasetRejected`, `trainingDatasetError`, `trainingQuoteTotal`,
852
+ `runTrainingError` (`'declined'`, `'submission-unconfirmed'`, or any server
853
+ message), `runTrainingCapRefusal` (the server's resolved cap / availability refusal, which consumes the quote →
854
+ `err.refused`) and `generation.trainedEpochs` for the finished run. It has no
855
+ consent dialog, so `runTraining` settles at once where the real host waits on a
856
+ click, and it checks a run's body against its quote by `datasetId` only — the
857
+ server compares the whole body.
858
+ **`dev:live` refuses both bridges**, because the server refuses every training
859
+ request from a dev token.
860
+
738
861
  ### `useAppWorkflows(params?)`
739
862
 
740
863
  The calling app's **own** generator subqueue — the tag-scoped list of generations
@@ -1227,6 +1350,7 @@ had just worked.
1227
1350
  |---|---|---|
1228
1351
  | `useBuzzWorkflow()` | `submit()` | `ai:write:budgeted` |
1229
1352
  | `useCreatePostFromApp()` | `createPost()` | `posts:write:self` |
1353
+ | `usePrepareTrainingDataset()` | `prepareDataset()` | `ai:write:budgeted` |
1230
1354
  | `useGoodPurchase()` | `purchase()` | `goods:purchase:self` |
1231
1355
  | `useTip()` | `tip()` | `social:tip:self` |
1232
1356
 
@@ -9,7 +9,9 @@ export interface UseBuzzPurchase {
9
9
  * Opens the Civitai Buzz purchase modal on the host. Resolves with the
10
10
  * outcome when the user closes the modal — `purchased: true` means the
11
11
  * balance increased; the new balance is included if the host reports it.
12
- * The insufficient-budget recovery path for {@link useBuzzWorkflow}.
12
+ * Raises the viewer's WALLET, never a spend cap: offer it when the viewer's
13
+ * spendable Buzz is below a quoted cost, never on a resolved `failed` submit
14
+ * from {@link useBuzzWorkflow} (see its `submit` docs for why).
13
15
  *
14
16
  * 🔴 HUMAN-GATED: the reply comes when the viewer closes the modal, and a
15
17
  * payment flow is nowhere near a ~30s round-trip — so this passes
@@ -6,7 +6,9 @@ import { sendTypedRequest } from '../transport/transport.js';
6
6
  * Opens the Civitai Buzz purchase modal on the host. Resolves with the
7
7
  * outcome when the user closes the modal — `purchased: true` means the
8
8
  * balance increased; the new balance is included if the host reports it.
9
- * The insufficient-budget recovery path for {@link useBuzzWorkflow}.
9
+ * Raises the viewer's WALLET, never a spend cap: offer it when the viewer's
10
+ * spendable Buzz is below a quoted cost, never on a resolved `failed` submit
11
+ * from {@link useBuzzWorkflow} (see its `submit` docs for why).
10
12
  *
11
13
  * 🔴 HUMAN-GATED: the reply comes when the viewer closes the modal, and a
12
14
  * payment flow is nowhere near a ~30s round-trip — so this passes
@@ -1,5 +1,18 @@
1
1
  import type { BlockWorkflowSnapshot, WorkflowBody, WorkflowStatus } from '@civitai/app-sdk/blocks';
2
2
  import type { ConsentRetryOptions } from './consentRetryOptions.js';
3
+ /**
4
+ * Why {@link UseBuzzWorkflow.submit} refuses a `kind: 'training'` body.
5
+ *
6
+ * 🔴 REFUSED BEFORE THE SEND, NOT LEFT TO THE SERVER. The server charges a
7
+ * training run only against a quote the viewer confirmed from a signed-in
8
+ * session, and only `RUN_TRAINING`'s host-chrome dialog can record that — so a
9
+ * `SUBMIT_WORKFLOW` carrying this body can never start a run. Refusing here
10
+ * turns a server round-trip ending in a confusing refusal into an immediate
11
+ * error that names the hook to use. Nothing is sent and nothing is spent, which
12
+ * is why it is a plain `Error` and not a {@link WorkflowSubmitError} (every code
13
+ * on that class is money-ambiguous by design).
14
+ */
15
+ export declare const TRAINING_BODY_SUBMIT_REFUSAL = "useBuzzWorkflow.submit() does not run a kind:'training' body \u2014 estimate it here, then run it with useRunTraining().runTraining({ ...body, quoteId }). Nothing was sent.";
3
16
  /**
4
17
  * Default orchestrator-side hold per {@link UseBuzzWorkflow.watch} poll,
5
18
  * in SECONDS.
@@ -258,10 +271,11 @@ export type WorkflowSubmitErrorCode = 'exception' | 'workflow-failed';
258
271
  *
259
272
  * - **budget / spend-cap REJECTION** — a legitimate OUTCOME. The server quotes
260
273
  * the price it refused to charge, so the snapshot carries a numeric
261
- * `cost.total`. Blocks recover from it (open a top-up flow), so it MUST keep
262
- * RESOLVING. Every such exit on the server attaches a cost: the per-call
263
- * `buzzBudget` gate, the per-user daily Buzz cap, the per-app aggregate and
264
- * velocity caps, and the dev-tunnel session cap — on all three body kinds.
274
+ * `cost.total`, and the block can tell the viewer what was refused and why,
275
+ * so it MUST keep RESOLVING. It is NOT a top-up cue: these are app and
276
+ * platform caps, and buying Buzz raises none of them. Every such exit on
277
+ * the server attaches a cost; the complete list of them, and of the other
278
+ * resolved `'failed'` replies, is on {@link UseBuzzWorkflow.submit}.
265
279
  * - **caught server EXCEPTION** — `failureSnapshot(err)`, no `cost`, and the
266
280
  * `'failed'` sentinel id. The host had no workflow to report — usually
267
281
  * nothing was queued, but a lost response or an in-progress idempotency
@@ -309,8 +323,8 @@ export type WorkflowSubmitErrorCode = 'exception' | 'workflow-failed';
309
323
  *
310
324
  * 🔴 DO NOT "SIMPLIFY" THIS BY PUTTING `snapshot.error` BACK ON `message`, and do
311
325
  * not widen the guard to reject a budget rejection. The first re-opens #253; the
312
- * second breaks the top-up recovery flow, which is the one thing #251 says must
313
- * not break.
326
+ * second turns a priced, explainable refusal into an opaque exception, which is
327
+ * the one thing #251 says must not happen.
314
328
  */
315
329
  export declare class WorkflowSubmitError extends Error {
316
330
  /**
@@ -465,6 +479,10 @@ export interface UseBuzzWorkflow {
465
479
  * consent dialog with no user gesture behind it, once per edit. A missing
466
480
  * scope surfaces as an ordinary rejection; show no price and let `submit()`
467
481
  * do the asking.
482
+ *
483
+ * A `kind: 'training'` body's reply also carries
484
+ * {@link BlockWorkflowSnapshot.trainingQuote} — the `quoteId` to run it with
485
+ * through `useRunTraining()`.
468
486
  */
469
487
  estimate: (body: WorkflowBody) => Promise<BlockWorkflowSnapshot>;
470
488
  /**
@@ -486,14 +504,67 @@ export interface UseBuzzWorkflow {
486
504
  * 🔴 A BUDGET / SPEND-CAP REJECTION STILL RESOLVES, and that is deliberate. It
487
505
  * is a documented outcome, not an error: the server quotes what it refused to
488
506
  * charge, so the resolved snapshot has `status === 'failed'` AND a numeric
489
- * `cost.total`. THAT is the shape to branch on when offering a top-up —
490
- * `useBuzzPurchase().openPurchaseModal()` — not a `catch`.
491
- *
492
- * 🔴 BUT A RESOLVED `'failed'` IS NOT ALWAYS AN AFFORDABILITY PROBLEM, so do not
493
- * wire every one of them to a purchase modal. The per-app **velocity** limit,
494
- * the per-app **aggregate daily** cap, a fail-closed "temporarily unavailable"
495
- * deny and a **missing price quote** are all priced, resolving outcomes that
496
- * buying Buzz cannot fix.
507
+ * `cost.total`.
508
+ *
509
+ * 🔴 WHAT A RESOLVED `status: 'failed'` CAN MEAN — THE ONE COMPLETE LIST. The
510
+ * `@civitai/blocks-react` README, the `civitai-block-starter` AGENTS.md and the
511
+ * changesets link here rather than restating it. Every case below RESOLVES
512
+ * (`blocks.submitWorkflow` returns it; the REST route answers it with HTTP
513
+ * 200):
514
+ *
515
+ * 1. **Refused before anything ran — the `'failed'` sentinel `workflowId`, a
516
+ * `cost.total`, nothing charged, every reservation released.**
517
+ * - the per-call budget, `token.buzzBudget` (`insufficient buzz budget: …`);
518
+ * - the viewer's daily Buzz cap — or, in its place, the review-session cap
519
+ * (a moderator's run-for-real) or the private-run cap;
520
+ * - the viewer's consent budget (the ceiling they set when they granted the
521
+ * scope);
522
+ * - the app's velocity limit or aggregate daily cap, or a fail-closed
523
+ * "temporarily unavailable" deny;
524
+ * - the dev-session cap (`dev:tunnel`);
525
+ * - a missing price quote for a step ("generation temporarily unavailable").
526
+ *
527
+ * A training step with no price quote is refused the same way but with NO
528
+ * `cost`, which on this hook REJECTS as `'exception'` instead.
529
+ * 2. **Not confirmed — Buzz may have been spent.** A training run whose
530
+ * submission the server could not confirm: the sentinel id, a `cost.total`,
531
+ * and `submissionUnconfirmed: true` on the REST reply. The reservation is NOT
532
+ * refunded and the run may be going. 🔴 The host bridge forwards only the
533
+ * snapshot, so on this hook that flag never arrives and the reply looks like
534
+ * case 1 — never tell a viewer "nothing was charged" for a resolved failed
535
+ * `kind: 'training'` submit; send them to their trainings instead.
536
+ * 3. **A run was created and came back failed — a REAL `workflowId`.** The
537
+ * reservation is kept and Buzz may have been spent. Priced, it resolves;
538
+ * unpriced, it rejects as `'workflow-failed'`.
539
+ *
540
+ * So only case 1 may be told "could not start, nothing was charged".
541
+ *
542
+ * 🔴 A RESOLVED `'failed'` IS NEVER A TOP-UP CUE — do not wire it to
543
+ * `useBuzzPurchase().openPurchaseModal()`. Case 1 is a set of caps that buying
544
+ * Buzz does not raise, and cases 2 and 3 are not about the wallet.
545
+ *
546
+ * 🔴 THE CASE A TOP-UP DOES FIX — THE VIEWER IS ACTUALLY OUT OF BUZZ — REJECTS.
547
+ * The orchestrator refuses the run, the server throws, and the host answers
548
+ * with its cost-less `failureSnapshot(err)`, so this rejects with
549
+ * {@link WorkflowSubmitError} code **`'exception'`** — the same code as every
550
+ * other thrown submit (a bad request, an outage, a rate limit, an idempotency
551
+ * conflict). Nothing structural marks it as insufficient funds: the code is
552
+ * shared, and `snapshot.error` is the upstream message, server-authored prose
553
+ * that is not a contract. So decide a top-up from the BALANCE, not from the
554
+ * rejection: on `'exception'`, read {@link useBuzzBalance} and offer
555
+ * `openPurchaseModal()` only if the viewer's SPENDABLE Buzz is below the quoted
556
+ * cost. Two things make that read correct:
557
+ * - `useBuzzBalance` needs `buzz:read:self` declared in `block.manifest.json`,
558
+ * and that scope is consent-gated (it is not consent-exempt), so request it
559
+ * like any other gated scope. An anonymous viewer is refused outright.
560
+ * - A block spends only blue plus ITS DOMAIN'S pool: blue + green under an SFW
561
+ * ceiling, blue + yellow under a mature one. The server keys this on the
562
+ * token's DOMAIN ceiling (`maxBrowsingLevel`), so compute it as
563
+ * `isSfwCeiling(useDomainMaturity().maxBrowsingLevel)` (from
564
+ * `@civitai/app-sdk/blocks`; an unknown ceiling counts as SFW) — NOT
565
+ * `useDomainMaturity().isSfw`, which is narrowed by the viewer's own setting —
566
+ * and compare `blue` plus that one pool, never blue + green + yellow, against
567
+ * the quoted cost.
497
568
  *
498
569
  * 🔴 THIS ALSO INCLUDES MODERATOR REVIEW PREVIEW. While an app is under review
499
570
  * the host short-circuits every workflow request with
@@ -512,6 +583,9 @@ export interface UseBuzzWorkflow {
512
583
  * `CONSENT_UNAVAILABLE` environment is never retried, and a second consent
513
584
  * failure reaches you unchanged. Opt out with
514
585
  * {@link ConsentRetryOptions.autoRequestConsent}`: false`.
586
+ *
587
+ * 🔴 A `kind: 'training'` BODY IS REFUSED before anything is sent — it runs
588
+ * only through `useRunTraining()`. See {@link TRAINING_BODY_SUBMIT_REFUSAL}.
515
589
  */
516
590
  submit: (body: WorkflowBody, options?: SubmitWorkflowOptions) => Promise<BlockWorkflowSnapshot>;
517
591
  /**
@@ -566,15 +640,18 @@ export interface UseBuzzWorkflow {
566
640
  *
567
641
  * 🔴 A BUDGET REFUSAL DOES **NOT** REJECT — IT RESOLVES. It comes back as a
568
642
  * snapshot with `status: 'failed'`, an `error` string and the `cost` the server
569
- * declined to charge, and THAT resolved shape is the cue to call
570
- * `useBuzzPurchase().openPurchaseModal()`. What DOES reject is a submit with no
571
- * usable outcome — see {@link WorkflowSubmitError}. Routing a rejection into a
572
- * top-up sells Buzz for a failure Buzz cannot fix.
643
+ * declined to charge. It is NOT a cue to call
644
+ * `useBuzzPurchase().openPurchaseModal()`: every resolving refusal is a cap
645
+ * buying Buzz does not raise, or a reply that is not about the wallet at all —
646
+ * the complete list is on {@link UseBuzzWorkflow.submit}.
573
647
  *
574
- * 🔴 NOR IS EVERY RESOLVED `'failed'` AN AFFORDABILITY PROBLEM. The per-app
575
- * velocity limit, the per-app aggregate daily cap, a fail-closed "temporarily
576
- * unavailable" deny and a missing price quote are all priced, resolving outcomes
577
- * too. Branch on the message/your own policy before offering to sell anything.
648
+ * 🔴 RUNNING OUT OF BUZZ REJECTS, and is not marked as such. The orchestrator's
649
+ * refusal reaches the block as {@link WorkflowSubmitError} code `'exception'`,
650
+ * shared with every other thrown submit. Offer a top-up only after checking the
651
+ * viewer's SPENDABLE Buzz ({@link useBuzzBalance}: blue plus the block's domain
652
+ * pool, `buzz:read:self` declared) against the quoted cost — see
653
+ * {@link UseBuzzWorkflow.submit}, which also holds the complete list of resolved
654
+ * refusals.
578
655
  *
579
656
  * AFTER `submit` FLIPS `status` TO `'polling'`, USE `watch(workflowId)`. It owns
580
657
  * the loop, resolves on the terminal snapshot, and pushes every intermediate
@@ -652,8 +729,9 @@ export interface UseBuzzWorkflow {
652
729
  * try {
653
730
  * const snap = await submit(body); // status 'submitting' → 'polling'
654
731
  * if (snap.status === 'failed') {
655
- * // RESOLVED + priced = a server outcome (affordability, a cap, a velocity
656
- * // limit, a transient deny). Only some of those are fixable by buying Buzz.
732
+ * // RESOLVED = a server outcome: a cap, a limit, a missing quote — none of
733
+ * // them fixed by buying Buzz — or a run that may have spent (see `submit`
734
+ * // for the complete list). Never a top-up cue.
657
735
  * showError(submitOutcomeMessage(snap));
658
736
  * } else {
659
737
  * const done = await watch(snap.workflowId, { onUpdate: render }); // → terminal
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { BLOCK_SCOPES } from '@civitai/app-sdk/blocks';
3
3
  import { withConsentRetry } from '../internal/withConsentRetry.js';
4
+ import { WORKFLOW_REQUEST_TIMEOUT_MS } from '../transport/requestTimeouts.js';
4
5
  import { getTransport } from '../transport/singleton.js';
5
6
  import { resolveIdempotencyKey, sendTypedRequest } from '../transport/transport.js';
6
7
  /**
@@ -16,6 +17,19 @@ import { resolveIdempotencyKey, sendTypedRequest } from '../transport/transport.
16
17
  * automatic prompt silently do nothing.
17
18
  */
18
19
  const WORKFLOW_SCOPES = [BLOCK_SCOPES.AI_WRITE_BUDGETED];
20
+ /**
21
+ * Why {@link UseBuzzWorkflow.submit} refuses a `kind: 'training'` body.
22
+ *
23
+ * 🔴 REFUSED BEFORE THE SEND, NOT LEFT TO THE SERVER. The server charges a
24
+ * training run only against a quote the viewer confirmed from a signed-in
25
+ * session, and only `RUN_TRAINING`'s host-chrome dialog can record that — so a
26
+ * `SUBMIT_WORKFLOW` carrying this body can never start a run. Refusing here
27
+ * turns a server round-trip ending in a confusing refusal into an immediate
28
+ * error that names the hook to use. Nothing is sent and nothing is spent, which
29
+ * is why it is a plain `Error` and not a {@link WorkflowSubmitError} (every code
30
+ * on that class is money-ambiguous by design).
31
+ */
32
+ export const TRAINING_BODY_SUBMIT_REFUSAL = "useBuzzWorkflow.submit() does not run a kind:'training' body — estimate it here, then run it with useRunTraining().runTraining({ ...body, quoteId }). Nothing was sent.";
19
33
  /**
20
34
  * Snapshot statuses that mean "no further polling is needed."
21
35
  * Used by both `submit` (a host can return an instant-fail / cached result)
@@ -27,18 +41,16 @@ const TERMINAL_STATUSES = new Set([
27
41
  'canceled',
28
42
  'expired',
29
43
  ]);
30
- /**
31
- * The workflow requests (estimate / submit / poll) are orchestrator-bound:
32
- * the host forwards them to the generation orchestrator and only replies
33
- * once it answers. `submit` is the slowest — server-side it does a whatif
34
- * cost-preflight AND the real submit (two orchestrator round-trips) plus a
35
- * prompt audit before responding, which legitimately exceeds the
36
- * transport's 30s `DEFAULT_REQUEST_TIMEOUT_MS` (tuned for fast bridge
37
- * messages) when the orchestrator queue is busy. Give these calls a
38
- * generous ceiling so a busy-but-healthy orchestrator doesn't surface as
39
- * a spurious `request "SUBMIT_WORKFLOW" timed out` rejection.
40
- */
41
- const WORKFLOW_REQUEST_TIMEOUT_MS = 120_000;
44
+ // The workflow requests (estimate / submit / poll) are orchestrator-bound:
45
+ // the host forwards them to the generation orchestrator and only replies
46
+ // once it answers. `submit` is the slowest — server-side it does a whatif
47
+ // cost-preflight AND the real submit (two orchestrator round-trips) plus a
48
+ // prompt audit before responding, which legitimately exceeds the
49
+ // transport's 30s `DEFAULT_REQUEST_TIMEOUT_MS` (tuned for fast bridge
50
+ // messages) when the orchestrator queue is busy. `WORKFLOW_REQUEST_TIMEOUT_MS`
51
+ // (imported above, from `transport/requestTimeouts.ts`) gives these calls a
52
+ // generous ceiling so a busy-but-healthy orchestrator doesn't surface as a
53
+ // spurious `request "SUBMIT_WORKFLOW" timed out` rejection.
42
54
  /**
43
55
  * Default orchestrator-side hold per {@link UseBuzzWorkflow.watch} poll,
44
56
  * in SECONDS.
@@ -288,10 +300,11 @@ const HOST_SYNTHESISED_WORKFLOW_ID = 'failed';
288
300
  *
289
301
  * - **budget / spend-cap REJECTION** — a legitimate OUTCOME. The server quotes
290
302
  * the price it refused to charge, so the snapshot carries a numeric
291
- * `cost.total`. Blocks recover from it (open a top-up flow), so it MUST keep
292
- * RESOLVING. Every such exit on the server attaches a cost: the per-call
293
- * `buzzBudget` gate, the per-user daily Buzz cap, the per-app aggregate and
294
- * velocity caps, and the dev-tunnel session cap — on all three body kinds.
303
+ * `cost.total`, and the block can tell the viewer what was refused and why,
304
+ * so it MUST keep RESOLVING. It is NOT a top-up cue: these are app and
305
+ * platform caps, and buying Buzz raises none of them. Every such exit on
306
+ * the server attaches a cost; the complete list of them, and of the other
307
+ * resolved `'failed'` replies, is on {@link UseBuzzWorkflow.submit}.
295
308
  * - **caught server EXCEPTION** — `failureSnapshot(err)`, no `cost`, and the
296
309
  * `'failed'` sentinel id. The host had no workflow to report — usually
297
310
  * nothing was queued, but a lost response or an in-progress idempotency
@@ -339,8 +352,8 @@ const HOST_SYNTHESISED_WORKFLOW_ID = 'failed';
339
352
  *
340
353
  * 🔴 DO NOT "SIMPLIFY" THIS BY PUTTING `snapshot.error` BACK ON `message`, and do
341
354
  * not widen the guard to reject a budget rejection. The first re-opens #253; the
342
- * second breaks the top-up recovery flow, which is the one thing #251 says must
343
- * not break.
355
+ * second turns a priced, explainable refusal into an opaque exception, which is
356
+ * the one thing #251 says must not happen.
344
357
  */
345
358
  export class WorkflowSubmitError extends Error {
346
359
  /**
@@ -452,15 +465,18 @@ export class WorkflowSubmitError extends Error {
452
465
  *
453
466
  * 🔴 A BUDGET REFUSAL DOES **NOT** REJECT — IT RESOLVES. It comes back as a
454
467
  * snapshot with `status: 'failed'`, an `error` string and the `cost` the server
455
- * declined to charge, and THAT resolved shape is the cue to call
456
- * `useBuzzPurchase().openPurchaseModal()`. What DOES reject is a submit with no
457
- * usable outcome — see {@link WorkflowSubmitError}. Routing a rejection into a
458
- * top-up sells Buzz for a failure Buzz cannot fix.
459
- *
460
- * 🔴 NOR IS EVERY RESOLVED `'failed'` AN AFFORDABILITY PROBLEM. The per-app
461
- * velocity limit, the per-app aggregate daily cap, a fail-closed "temporarily
462
- * unavailable" deny and a missing price quote are all priced, resolving outcomes
463
- * too. Branch on the message/your own policy before offering to sell anything.
468
+ * declined to charge. It is NOT a cue to call
469
+ * `useBuzzPurchase().openPurchaseModal()`: every resolving refusal is a cap
470
+ * buying Buzz does not raise, or a reply that is not about the wallet at all —
471
+ * the complete list is on {@link UseBuzzWorkflow.submit}.
472
+ *
473
+ * 🔴 RUNNING OUT OF BUZZ REJECTS, and is not marked as such. The orchestrator's
474
+ * refusal reaches the block as {@link WorkflowSubmitError} code `'exception'`,
475
+ * shared with every other thrown submit. Offer a top-up only after checking the
476
+ * viewer's SPENDABLE Buzz ({@link useBuzzBalance}: blue plus the block's domain
477
+ * pool, `buzz:read:self` declared) against the quoted cost — see
478
+ * {@link UseBuzzWorkflow.submit}, which also holds the complete list of resolved
479
+ * refusals.
464
480
  *
465
481
  * AFTER `submit` FLIPS `status` TO `'polling'`, USE `watch(workflowId)`. It owns
466
482
  * the loop, resolves on the terminal snapshot, and pushes every intermediate
@@ -538,8 +554,9 @@ export class WorkflowSubmitError extends Error {
538
554
  * try {
539
555
  * const snap = await submit(body); // status 'submitting' → 'polling'
540
556
  * if (snap.status === 'failed') {
541
- * // RESOLVED + priced = a server outcome (affordability, a cap, a velocity
542
- * // limit, a transient deny). Only some of those are fixable by buying Buzz.
557
+ * // RESOLVED = a server outcome: a cap, a limit, a missing quote — none of
558
+ * // them fixed by buying Buzz — or a run that may have spent (see `submit`
559
+ * // for the complete list). Never a top-up cue.
543
560
  * showError(submitOutcomeMessage(snap));
544
561
  * } else {
545
562
  * const done = await watch(snap.workflowId, { onUpdate: render }); // → terminal
@@ -687,10 +704,11 @@ export function useBuzzWorkflow() {
687
704
  // `submit` half of civitai/civitai#4159). Two producers report
688
705
  // `status:'failed'` and `status` separates neither:
689
706
  //
690
- // - a budget / spend-cap REJECTION is an OUTCOME the block recovers from
691
- // (open a top-up flow). The server quotes the price it refused to
692
- // charge, so `cost.total` is present. It RESOLVES — turning this arm
693
- // into a throw is the one change that would break the recovery path.
707
+ // - a budget / spend-cap REJECTION is an OUTCOME the block reports (the
708
+ // run did not start, at this quoted price). The server quotes the price
709
+ // it refused to charge, so `cost.total` is present. It RESOLVES —
710
+ // turning this arm into a throw would erase that. (It is not a top-up
711
+ // cue: buying Buzz raises none of these caps.)
694
712
  // - a failure-shaped reply with NO `cost` is not a usable outcome. It
695
713
  // REJECTS — and the `code` says which kind, because they differ on
696
714
  // whether money moved (see WorkflowSubmitError.code).
@@ -720,6 +738,14 @@ export function useBuzzWorkflow() {
720
738
  return snapshot;
721
739
  }, []);
722
740
  const submit = useCallback(async (body, options) => {
741
+ // See `TRAINING_BODY_SUBMIT_REFUSAL`. Before the status flip, the key
742
+ // mint and the consent retry: nothing about this call may reach the wire.
743
+ if (body.kind === 'training') {
744
+ const refused = new Error(TRAINING_BODY_SUBMIT_REFUSAL);
745
+ setError(refused);
746
+ setStatus('error');
747
+ throw refused;
748
+ }
723
749
  setError(null);
724
750
  setStatus('submitting');
725
751
  // Idempotency: reuse a caller-supplied stable key across a retry (→ one Buzz
@@ -52,13 +52,13 @@ export interface GoodPurchaseOptions extends ConsentRetryOptions {
52
52
  * fresh key here would work too, but it would make a lost response on the
53
53
  * retry unrecoverable, which is the thing idempotency keys exist to prevent.
54
54
  *
55
- * ⚠️ RECONCILED WITH THE OPPOSITE POLICY NEXT DOOR, which a reader will hit.
56
- * `useBuzzWorkflow` states twice that a resolved-failed budget "is the cue to
57
- * call `useBuzzPurchase().openPurchaseModal()`" — i.e. the APP decides — and
58
- * warns that "routing a rejection into a top-up sells Buzz for a failure Buzz
59
- * cannot fix". That policy is right for a generation, and this option does not
60
- * contradict it: it defaults to `false`, and it keys on the REASON
61
- * (`insufficient_funds`) rather than on "the call failed", so it never offers
55
+ * ⚠️ RECONCILED WITH THE POLICY NEXT DOOR, which a reader will hit.
56
+ * `useBuzzWorkflow` says a resolved-failed generation is never a top-up cue
57
+ * (those are caps Buzz does not raise) and that running out of Buzz there
58
+ * rejects without a structural marker, so the APP decides a top-up from the
59
+ * balance. This option does not contradict that: it defaults to `false`, and
60
+ * it keys on the REASON (`insufficient_funds`, which a goods purchase DOES
61
+ * report structurally) rather than on "the call failed", so it never offers
62
62
  * Buzz for a failure Buzz cannot fix. What it buys, and the only reason it is
63
63
  * in the library at all rather than left to each app, is the same-key retry
64
64
  * above — a server contract an app gets wrong in the expensive direction.