@civitai/blocks-react 0.71.0 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -535,14 +535,16 @@ try {
535
535
  | `'training-quote-rejected'` | the orchestrator refused the request itself; resending the same body will not help. `error` reads `Training could not be priced: <reason>` when the reason is safe to show |
536
536
  | `'training-quote-unavailable'` | no usable answer (an outage, a timeout, a rate limit, insufficient funds, …); a retry may succeed |
537
537
  | `'training-quote-unpriced'` | the orchestrator answered without a price |
538
+ | `'daily-limit-exceeded'` | a viewer-confirmed job (a training run or a [confirmed step](#userunconfirmedstep--one-job-above-the-per-call-budget)) would exceed what remains today of the daily Buzz limit **the viewer** set for this app. Nothing was charged, no job started, and only the viewer can raise the limit. Arrives on a confirmed submit's failed snapshot, never on an estimate; `useRunConfirmedStep()` surfaces it as `err.dailyLimitExceeded` |
538
539
  | absent | any other reply, and every reply from a host that predates the field |
539
540
 
540
541
  - **Treat an unknown value as possible.** The field is typed as an open string
541
542
  (`KnownBlockWorkflowErrorCode | (string & {})`), the host may add codes, and
542
543
  the inbound validator accepts any string. Keep a `default` branch.
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.
544
+ - A `kind: 'training'` **estimate** does not set it; the three `training-quote-*`
545
+ codes belong to the pass-through arm.
546
+ - A refused `kind: 'workflow'` body sets its own codes, in the same shape — see
547
+ the next section.
546
548
 
547
549
  #### Several steps as one workflow: `kind: 'workflow'`
548
550
 
@@ -644,12 +646,19 @@ showImages(done.imageUrls ?? []);
644
646
  1. **The estimate is not a maximum.** When a step reads from an earlier step,
645
647
  the orchestrator re-prices it once those values are known and charges the
646
648
  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).
649
+ 2. **A workflow the host cannot price has no estimate, but its submit still
650
+ proceeds.** `estimate()` rejects with a `workflow_quote_*` `errorCode` and
651
+ no `cost` (see the second table under Refusals), and a batch estimate
652
+ returns that as the cell. `submit()` of the same body is **not** refused: it
653
+ reserves the ceiling above instead of a quote. So a failed estimate does not
654
+ predict a refused submit. Two exceptions are refused on both, before any
655
+ reservation: a workflow with a training step (a `training-quote-*`
656
+ `errorCode`, as on the single-step arm), and a workflow with a `repeat` step
657
+ at any depth (`workflow_repeat_unbounded`). Their `error` states why there
658
+ is no quote, and only `none could be obtained; try again` is a cause a retry
659
+ of the same body can clear; for the others the body has to change.
660
+ An older host returns the ceiling as the estimate's `cost.total` instead of
661
+ failing, so there a total equal to the ceiling may be unpriced.
653
662
  3. **One author fee per workflow**, not one per step, on the quoted base. The
654
663
  estimate's `cost.authorFee` is that single fee, already inside `cost.total`.
655
664
  If the workflow bills more than the quote, the fee charged is held to the
@@ -683,22 +692,40 @@ A body the host will not run is refused before anything is priced or reserved:
683
692
  | `'workflow_too_many_steps'` | more than 8 steps |
684
693
  | `'workflow_step_denied'` | a step names a platform-internal `$type` |
685
694
  | `'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) |
695
+ | `'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 the host has no price quote for it. `error` then reads `workflow step N: a workflow with a repeat needs a price quote and <cause>`; a retry of the same body can work only when `<cause>` is `none could be obtained; try again` |
696
+
697
+ A body the host WOULD run, but has no price quote for, fails on `estimate()`
698
+ and as a batch-estimate cell only, with no `cost`. `submit()` of the same body
699
+ still proceeds (point 2 above):
700
+
701
+ | `errorCode` | Means | `error` | Retry the same body? |
702
+ |---|---|---|---|
703
+ | `'workflow_quote_rejected'` | the orchestrator refused the body | `No price quote for this workflow: the orchestrator rejected the request: <reason>` when the reason is safe to show, else `No price quote for this workflow: the orchestrator rejected the request; check the step inputs and references (retrying the same body will not help)` | no — change the body |
704
+ | `'workflow_quote_unavailable'` | no usable answer (an outage, a timeout, a rate limit, …) | `No price quote for this workflow: none could be obtained; try again` | yes |
705
+ | `'workflow_quote_unpriced'` | the orchestrator answered without a price | `No price quote for this workflow: the orchestrator answered without one` | not expected to help |
706
+
707
+ An older host has none of the three: it resolves the estimate with the ceiling
708
+ as `cost.total`.
687
709
 
688
710
  - **Requires a host that knows the kind.** An older host refuses the body.
689
711
  - **Test it locally.** The mock host prices each step with
690
712
  `generation.costPerGen` (a function gets the step as its second argument) and
691
713
  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
714
+ `generation.authorFee` once; refuses with the four codes of the first table,
715
+ in the host's order; reports
694
716
  `steps` on every poll, finishing them in order; and
695
717
  `generation.failWorkflowStep: '<name>'` (`'$<index>'` for an unnamed step)
696
718
  makes one step fail, so you can render a partial failure. The mock checks
697
719
  where a reference points, not whether its `path` exists; it never re-prices;
698
720
  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.
721
+ quotes every workflow unless you set `generation.workflowUnpriced` to
722
+ `'rejected'`, `'unavailable'` or `'unpriced'` (`true` means `'unavailable'`),
723
+ which simulates no quote for that cause: a workflow with a training step or a
724
+ `repeat` is refused on both `estimate()` and `submit()`; any other fails its
725
+ `estimate()` with `workflow_quote_<cause>` and no `cost`, while its
726
+ `submit()` is reserved at `buzzBudget` × steps with no fee. The mock has no
727
+ orchestrator reason to pass on, so `'rejected'` always gives the reason-less
728
+ message, never the `…rejected the request: <reason>` form.
702
729
 
703
730
  > **Estimate must mirror submit** (gotcha #59): build the params for `estimate`
704
731
  > with the *exact* same logic as `submit` — same seed decision especially. The
@@ -733,6 +760,11 @@ resolves with one cell per body, in order, plus the run total. Returns
733
760
  `WorkflowEstimateError`, and the other cells still price.
734
761
  - **`kind: 'training'` bodies are refused per cell.** Estimate a training run
735
762
  with `useBuzzWorkflow().estimate()`.
763
+ - **A cell never carries a `stepQuote`.** A batch stores nothing, so a
764
+ pass-through step priced above the per-call budget comes back with its cost and
765
+ no confirmable quote. To run it with
766
+ [`useRunConfirmedStep()`](#userunconfirmedstep--one-job-above-the-per-call-budget),
767
+ estimate that one body with `useBuzzWorkflow().estimate()`.
736
768
  - **Estimate only.** Nothing is submitted and no Buzz is held or spent. There is
737
769
  no batch submit. Submit each cell with
738
770
  `useBuzzWorkflow().submit()`, which asks the viewer and checks the budget per
@@ -1380,6 +1412,191 @@ server compares the whole body.
1380
1412
  **`dev:live` refuses both bridges**, because the server refuses every training
1381
1413
  request from a dev token.
1382
1414
 
1415
+ ### `useRunConfirmedStep()` — one job above the per-call budget
1416
+
1417
+ A pass-through `step` job (`{ kind: 'step', $type, input, maxBuzz }`) that costs
1418
+ more than the app may spend without asking is refused by `submit()` — unless the
1419
+ **viewer confirms that exact price** in Civitai's own dialog. This hook is that
1420
+ path. It is the training run's confirm applied to any pass-through `$type`
1421
+ (`polyGen`, a long video job, …). Three steps, two hooks:
1422
+
1423
+ 1. **Quote** — `useBuzzWorkflow().estimate(body)`, the body you would submit. When
1424
+ the job qualifies the reply carries `stepQuote: { quoteId, total, stepType,
1425
+ expiresAt }`. `total` is the price the viewer will confirm and be charged.
1426
+ 2. **Run** — `useRunConfirmedStep().runConfirmedStep({ ...body, quoteId })`
1427
+ (`RUN_CONFIRMED_STEP`). Civitai reads the quote back from the server, shows
1428
+ the viewer the price in **its** chrome and submits on their click. Resolves
1429
+ with the submitted workflow's snapshot.
1430
+ 3. **Watch** — `useBuzzWorkflow().watch(snapshot.workflowId)`, as for any job.
1431
+
1432
+ No `stepQuote` on the estimate means the ordinary rules apply: submit it if it
1433
+ fits the budget, and otherwise it cannot run from an app. Change nothing in the
1434
+ body between the estimate and the run — the server checks it against the quoted
1435
+ one.
1436
+
1437
+ 🔴 **Only a fixed price is confirmable.** `stepQuote` is present when the
1438
+ orchestrator quoted a **fixed** price (positive, not variable or metered) that is
1439
+ above the token's per-call `buzzBudget` and at most
1440
+ `BLOCK_CONFIRMED_STEP_MAX_BUZZ_PER_JOB` (5,000 Buzz). The viewer confirms what
1441
+ they are charged, so a price that may move, or no price at all, gets no quote.
1442
+ `stepQuote.total` can differ from `cost.total` on the same snapshot (which stays
1443
+ what an unconfirmed submit would reserve) — put `stepQuote.total` on your button.
1444
+
1445
+ 🔴 **Estimate it on its own.** Only a single `estimate()` returns a `stepQuote`.
1446
+ A batch estimate (`useBatchEstimate()`) stores nothing, so its cells never carry
1447
+ one: price the grid in a batch, then estimate the one body you want to run.
1448
+
1449
+ 🔴 **Two bodies make the estimate reject instead of returning a price.** Only
1450
+ where a quote would otherwise be granted, so not below the budget and not in a
1451
+ batch. `estimate()` rejects with a `WorkflowEstimateError` whose
1452
+ `snapshot.error` is the server's message; the same text is the
1453
+ `RunConfirmedStepError`'s `message` if such a body is run. Neither has a code or
1454
+ a flag — they are server messages, so match on the text:
1455
+
1456
+ | `snapshot.error` / `err.message` | cause | fix |
1457
+ |---|---|---|
1458
+ | `a training step type cannot run as a confirmed step — use kind:'training'` | `$type` is `training` or `imageResourceTraining`, in any casing | train with [`kind: 'training'`](#usepreparetrainingdataset-and-useruntraining--lora-training), which has its own dataset and checks |
1459
+ | starts with `a confirmed step needs plain JSON input (objects, arrays, strings, finite numbers, booleans, null) — ` and ends with the offending path, e.g. `body.input.when is a Date` | `input` holds something that is not plain JSON: a `Date`, `Map`, `Set`, class instance, `BigInt`, `NaN`/`Infinity`, an `undefined` array element, or an empty slot in a sparse array (`… is an empty array slot`). The quote is bound to the exact body, so the body must be exactly representable | send plain JSON (an ISO string for a date, an array for a set). The suffix varies, so match on the part **before the dash** |
1460
+
1461
+ 🔴 **Single-use, and it expires.** A quote is good for one run and for **15
1462
+ minutes** (`stepQuote.expiresAt`). The server prices the job again at submit and
1463
+ refuses a higher price. Estimate again to get a new one.
1464
+
1465
+ 🔴 **Availability.** Behind the host flag **`app-blocks-confirmed-step`**, which
1466
+ ships **off** and is evaluated per viewer; requires a civitai.com host carrying
1467
+ civitai/civitai#5786. **Page apps only** — the model slot answers with an error.
1468
+ The manifest must declare **`ai:write:budgeted`** and the viewer must be signed
1469
+ in and have granted it. **Refused for development, dev-tunnel and review
1470
+ tokens**, so **`dev:live` cannot exercise it**: the estimate there never carries
1471
+ a `stepQuote` and the bridge replies with a refusal. Build the flow against the
1472
+ mock host (below). Where the feature is unavailable nothing errors — the estimate
1473
+ simply has no `stepQuote`, which is also what an older host sends.
1474
+
1475
+ 🔴 **No retry, ever.** Every confirmation is its own job: there is no idempotency
1476
+ key, and confirming the same body twice starts and charges two jobs. The hook
1477
+ never re-sends and never prompts for consent, and `useBuzzWorkflow().submit()`
1478
+ **refuses** a step body that carries a `quoteId` before sending anything — only
1479
+ `RUN_CONFIRMED_STEP`'s dialog can confirm a quote. `runConfirmedStep` also
1480
+ refuses, before sending, a body whose `quoteId` is missing or is not an
1481
+ `sq_`-shaped id (a training `tq_…` quote, or `undefined` from an estimate that
1482
+ returned no `stepQuote`).
1483
+
1484
+ `runConfirmedStep` **rejects** with a `RunConfirmedStepError`; read the flags
1485
+ before saying anything about money:
1486
+
1487
+ | | meaning | what to do |
1488
+ |---|---|---|
1489
+ | `err.declined` | the viewer dismissed the confirm — **no job was submitted**; the quote is still good until it expires | revert, say nothing |
1490
+ | `err.dailyLimitExceeded` | the job would exceed what remains today of the daily Buzz limit **the viewer** set for this app — **no job, nothing charged**. Civitai has already told the viewer and linked to where the limit is raised | say nothing more; do **not** retry and do **not** offer a Buzz purchase (more Buzz does not lift a limit) |
1491
+ | `err.unconfirmed` | `submission-unconfirmed`, or no reply within the 10-min bound (`err.timedOut`) — the job **may be running and charged** | 🔴 **never retry automatically**: check the viewer's generations first (`useAppWorkflows()` lists this app's runs). A retry needs a new estimate and a new confirmation, and is a **second, separately charged job** if the first one started |
1492
+ | `err.refused` (`code: 'refused'`) | the server refused the submit after the viewer confirmed — a **spend cap** (their daily or private-run Buzz cap, the app's daily spend or rate limit). Refunded: **no job, nothing charged**; the quote is used up | show `err.message` (the server's reason); a retry needs a new estimate and usually a later time |
1493
+ | `err.signInRequired` | no session | route into `useRequestSignIn()` |
1494
+ | `err.code` set otherwise | a host refusal (`review-mode` / `block is not ready` / `invalid confirmed step request` / `no block token`) | show or ignore per case |
1495
+ | `err.code === undefined`, no flag | a server refusal with no job started (expired or used quote, changed body, changed price, a training `$type`, non-plain-JSON `input`, the flag is off) | fix the body if the message names it, estimate again, then retry |
1496
+
1497
+ ```tsx
1498
+ const { estimate, submit, watch } = useBuzzWorkflow();
1499
+ const { runConfirmedStep } = useRunConfirmedStep();
1500
+ const { requestSignIn } = useRequestSignIn();
1501
+
1502
+ async function generate3d(prompt: string) {
1503
+ const body: WorkflowBodyPassThroughStep = {
1504
+ kind: 'step',
1505
+ $type: 'polyGen',
1506
+ input: { prompt },
1507
+ maxBuzz: 250,
1508
+ };
1509
+ const quote = (await estimate(body)).stepQuote;
1510
+
1511
+ try {
1512
+ const started = quote
1513
+ ? // Over the per-call budget: the viewer confirms `quote.total` in Civitai's dialog.
1514
+ await runConfirmedStep({ ...body, quoteId: quote.quoteId })
1515
+ : // Within it (or not confirmable): the ordinary path.
1516
+ await submit(body);
1517
+ return await watch(started.workflowId);
1518
+ } catch (err) {
1519
+ if (!(err instanceof RunConfirmedStepError)) throw err;
1520
+ if (err.declined) return; // no job — say nothing
1521
+ if (err.dailyLimitExceeded) return; // no job — Civitai already told the viewer
1522
+ if (err.signInRequired) return requestSignIn();
1523
+ if (err.unconfirmed) return showCheckYourGenerations(); // may be running — never auto-retry
1524
+ if (err.refused) return showError(err.message); // a cap refusal; no job, nothing charged
1525
+ showError('Could not start this job. Get a new price and try again.');
1526
+ }
1527
+ }
1528
+ ```
1529
+
1530
+ **With the mock host** — `createMockHost` or `Harness` from
1531
+ `@civitai/blocks-react/testing` — this is the only place the flow can be
1532
+ exercised before a production host has it. A pass-through `step` estimate carries
1533
+ a `stepQuote` whenever the mock's price for it (`generation.costPerGen`, or
1534
+ `cost`) is above the token's per-call budget (`buzzBudget`, default 200) and at
1535
+ most 5,000, for a signed-in viewer; there is no separate switch. As on the
1536
+ server, a batch-estimate cell never carries one, and where a quote would be
1537
+ granted the estimate fails instead, with the server's message, for a training
1538
+ `$type` and for non-plain-JSON `input`. The mock holds the quotes it stored and
1539
+ refuses an unknown, expired or already-run one, a body that differs from the
1540
+ quoted one, and a plain `SUBMIT_WORKFLOW` carrying a `quoteId`. How the run
1541
+ settles is `runConfirmedStepError`:
1542
+
1543
+ | `runConfirmedStepError` | URL | outcome |
1544
+ |---|---|---|
1545
+ | unset | — | the viewer confirms; the job starts, charged `stepQuote.total` |
1546
+ | `'declined'` | `?confirmedStep=declined` | `err.declined`; the quote survives |
1547
+ | `'daily-limit-exceeded'` | `?confirmedStep=daily-limit` | `err.dailyLimitExceeded`; the quote survives |
1548
+ | `'submission-unconfirmed'` | `?confirmedStep=unconfirmed` | `err.unconfirmed`; the quote is used up |
1549
+ | any other string | — | a server message; the quote is used up |
1550
+
1551
+ `runConfirmedStepCapRefusal` resolves the server's refunded cap refusal
1552
+ (`err.refused`), and both knobs are live-tunable with `host.setScenario(...)`.
1553
+
1554
+ ```tsx
1555
+ import { createMockHost } from '@civitai/blocks-react/testing';
1556
+
1557
+ const host = createMockHost({
1558
+ consentGranted: true,
1559
+ generation: { costPerGen: 800 }, // above the default 200 per-call budget → quoted
1560
+ runConfirmedStepError: 'declined',
1561
+ });
1562
+ const uninstall = host.install();
1563
+ // …later: the viewer confirms this time.
1564
+ host.setScenario({ runConfirmedStepError: undefined });
1565
+ ```
1566
+
1567
+ What the mock does **not** model: it has no dialog, so the run settles at once
1568
+ where the real host waits on a click; it prices every estimate as fixed, where
1569
+ the real host gives a variable or metered price no `stepQuote`; and the flag, the
1570
+ page-only rule and the submit-time re-price are server-side.
1571
+
1572
+ #### 3D outputs: `model3dOutputs`
1573
+
1574
+ A step that produces 3D models (`polyGen`) lists every model url in
1575
+ `snapshot.imageUrls`, as before. `snapshot.model3dOutputs` says which is which:
1576
+ one `{ url, format, variant }` per model, where `format` is the model's own file
1577
+ format (`'glb'`, `'fbx'`, …) and `variant` is the output it came from (`'model'`,
1578
+ `'riggedFbxModel'`, `'basicAnimations.walkingModel'`, …). Both are open sets
1579
+ named by the orchestrator. The field is **omitted** when the workflow produced no
1580
+ 3D model and on hosts that predate it, and the same field is on each
1581
+ `useAppWorkflows()` row.
1582
+
1583
+ ```tsx
1584
+ function modelsOf(snapshot: BlockWorkflowSnapshot) {
1585
+ const models = snapshot.model3dOutputs ?? [];
1586
+ return {
1587
+ // The plain mesh, in the format a web viewer loads.
1588
+ glb: models.find((m) => m.format === 'glb' && m.variant === 'model')?.url,
1589
+ // The rigged export, for a download button.
1590
+ riggedFbx: models.find((m) => m.variant === 'riggedFbxModel')?.url,
1591
+ // Every animation clip, whatever the host named them.
1592
+ animations: models.filter((m) => m.variant.startsWith('basicAnimations.')),
1593
+ };
1594
+ }
1595
+ ```
1596
+
1597
+ In the mock host, `generation.model3dOutputs` puts labels (and their urls in
1598
+ `imageUrls`) on a succeeded pass-through step.
1599
+
1383
1600
  ### `useAppWorkflows(params?)`
1384
1601
 
1385
1602
  The calling app's **own** generator subqueue — the tag-scoped list of generations
@@ -1718,6 +1935,20 @@ if (isModelSlotContext(context) && context.checkpoint) {
1718
1935
  }
1719
1936
  ```
1720
1937
 
1938
+ 🔴 **`persist()` works on a model slot only. On a page it always rejects.** A
1939
+ full-page app (`app.page`) is bound to no model, so the host has nowhere to write
1940
+ the override: it answers every `persist()`, including `persist(null)`, with
1941
+ `ok: false`, and the promise rejects with `page blocks cannot persist a
1942
+ checkpoint override (no model binding)`. A page app keeps the checkpoint `open()`
1943
+ returned in its own state (or in `useAppStorage()`).
1944
+
1945
+ Both dev hosts (`createMockHost` / `Harness` and `dev:live`) refuse it the same
1946
+ way, and both default to a page context. Pass a complete `ModelSlotContext` as
1947
+ `context` to exercise a block that persists. A test that expected `persist()` to
1948
+ resolve under the default mock context was asserting something a page app never
1949
+ gets: give it the model slot the block really runs in, or, for a page app, drop
1950
+ the `persist()` call.
1951
+
1721
1952
  ### `useResourcePicker()`
1722
1953
 
1723
1954
  Drive the platform resource picker for page blocks — `'Checkpoint' | 'LORA'`.
@@ -2823,6 +3054,7 @@ MockHostScenarioPatch
2823
3054
  MockSharedScenario
2824
3055
  MockSharedSeed
2825
3056
  MockStorageScenario
3057
+ MockWorkflowQuoteFailure
2826
3058
  MockWorkflowStepInfo
2827
3059
  PopoverShimHandle
2828
3060
  PopoverShimOptions
@@ -3022,6 +3254,21 @@ const host = createLiveHost(options);
3022
3254
  const uninstall = host.install();
3023
3255
  ```
3024
3256
 
3257
+ What `dev:live` forwards, and how it answers the one call it does not:
3258
+
3259
+ - **`useCheckpointPicker().persist()` on a page** is refused with no backend
3260
+ call, as the page host refuses it (see
3261
+ [`useCheckpointPicker()`](#usecheckpointpicker)). The default `context` is a
3262
+ page, because a dev token is a page token.
3263
+ - **`submit()`'s `idempotencyKey` reaches the server**, as it does from the
3264
+ production hosts. A retry with the same key is deduplicated there, and a key in
3265
+ a format the server refuses fails the submit with the server's message.
3266
+ - **`watch()`'s `waitSeconds` reaches the server**, so a poll is held open
3267
+ instead of answered at once, and the server decides the actual hold. This
3268
+ matches the production hosts as of
3269
+ [civitai/civitai#5798](https://github.com/civitai/civitai/pull/5798); a
3270
+ production host without that change drops the field and answers at once.
3271
+
3025
3272
  ### Stability of `/live`
3026
3273
 
3027
3274
  The same terms as `./testing`: a normal subpath of a `0.x` package where a minor
@@ -162,6 +162,10 @@ export interface UseBatchEstimate {
162
162
  * - **At most {@link BATCH_ESTIMATE_MAX_CELLS} bodies per call.**
163
163
  * - **`kind: 'training'` bodies are refused per cell.** Estimate a training run
164
164
  * with `useBuzzWorkflow().estimate()`.
165
+ * - **A cell never carries a `stepQuote`.** A batch stores nothing, so a
166
+ * pass-through step priced above the per-call budget comes back with its cost
167
+ * and no confirmable quote. To run it with `useRunConfirmedStep()`, estimate
168
+ * that one body with `useBuzzWorkflow().estimate()`.
165
169
  *
166
170
  * 🔴 ESTIMATE ONLY. There is no batch submit and no server-side id for the
167
171
  * list. To watch and cancel the submitted cells together, use
@@ -104,6 +104,10 @@ function toBatchEstimateError(err) {
104
104
  * - **At most {@link BATCH_ESTIMATE_MAX_CELLS} bodies per call.**
105
105
  * - **`kind: 'training'` bodies are refused per cell.** Estimate a training run
106
106
  * with `useBuzzWorkflow().estimate()`.
107
+ * - **A cell never carries a `stepQuote`.** A batch stores nothing, so a
108
+ * pass-through step priced above the per-call budget comes back with its cost
109
+ * and no confirmable quote. To run it with `useRunConfirmedStep()`, estimate
110
+ * that one body with `useBuzzWorkflow().estimate()`.
107
111
  *
108
112
  * 🔴 ESTIMATE ONLY. There is no batch submit and no server-side id for the
109
113
  * list. To watch and cancel the submitted cells together, use
@@ -13,6 +13,22 @@ import type { ConsentRetryOptions } from './consentRetryOptions.js';
13
13
  * on that class is money-ambiguous by design).
14
14
  */
15
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.";
16
+ /**
17
+ * Why {@link UseBuzzWorkflow.submit} refuses a pass-through `step` body that
18
+ * carries a `quoteId`.
19
+ *
20
+ * 🔴 REFUSED BEFORE THE SEND, for the same reason as
21
+ * {@link TRAINING_BODY_SUBMIT_REFUSAL}. A `quoteId` on a pass-through body means
22
+ * "charge this job against the price the viewer confirmed", and only
23
+ * `RUN_CONFIRMED_STEP`'s host-chrome dialog can record that confirmation — so a
24
+ * `SUBMIT_WORKFLOW` carrying one can never start the job; the server refuses it
25
+ * as unconfirmed. Nothing is sent and nothing is spent, which is why this too is
26
+ * a plain `Error` and not a {@link WorkflowSubmitError}.
27
+ *
28
+ * The same body WITHOUT `quoteId` is an ordinary pass-through submit and is not
29
+ * affected.
30
+ */
31
+ export declare const CONFIRMED_STEP_BODY_SUBMIT_REFUSAL = "useBuzzWorkflow.submit() does not run a step body that carries a quoteId \u2014 run it with useRunConfirmedStep().runConfirmedStep(body), or drop quoteId to submit it as an ordinary job. Nothing was sent.";
16
32
  /**
17
33
  * Default orchestrator-side hold per {@link UseBuzzWorkflow.watch} poll,
18
34
  * in SECONDS.
@@ -486,6 +502,17 @@ export interface UseBuzzWorkflow {
486
502
  * A `kind: 'training'` body's reply also carries
487
503
  * {@link BlockWorkflowSnapshot.trainingQuote} — the `quoteId` to run it with
488
504
  * through `useRunTraining()`.
505
+ *
506
+ * A pass-through `step` body's reply carries
507
+ * {@link BlockWorkflowSnapshot.stepQuote} when its FIXED price is above the
508
+ * token's per-call budget (and within the per-job ceiling). That job cannot
509
+ * run through {@link UseBuzzWorkflow.submit}; run it with
510
+ * `useRunConfirmedStep().runConfirmedStep({ ...body, quoteId:
511
+ * stepQuote.quoteId })`, which has the viewer confirm `stepQuote.total`. No
512
+ * `stepQuote` means the ordinary rules apply — it is also what a host without
513
+ * the feature sends. Where a quote would be granted, this REJECTS instead for
514
+ * a training `$type` or an `input` that is not plain JSON (see
515
+ * {@link BlockWorkflowSnapshot.stepQuote} for the two messages).
489
516
  */
490
517
  estimate: (body: WorkflowBody) => Promise<BlockWorkflowSnapshot>;
491
518
  /**
@@ -589,6 +616,9 @@ export interface UseBuzzWorkflow {
589
616
  *
590
617
  * 🔴 A `kind: 'training'` BODY IS REFUSED before anything is sent — it runs
591
618
  * only through `useRunTraining()`. See {@link TRAINING_BODY_SUBMIT_REFUSAL}.
619
+ * So is a pass-through `step` body that carries a `quoteId` — it runs only
620
+ * through `useRunConfirmedStep()`. See
621
+ * {@link CONFIRMED_STEP_BODY_SUBMIT_REFUSAL}.
592
622
  */
593
623
  submit: (body: WorkflowBody, options?: SubmitWorkflowOptions) => Promise<BlockWorkflowSnapshot>;
594
624
  /**
@@ -703,9 +733,11 @@ export interface UseBuzzWorkflow {
703
733
  * orchestrator steps as one workflow through these same calls, and:
704
734
  * - `estimate()`'s `cost.total` is NOT a maximum: the orchestrator re-prices a
705
735
  * 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);
736
+ * - a workflow the host cannot price FAILS `estimate()` with a
737
+ * `workflow_quote_*` `errorCode` and no `cost`, yet still proceeds on
738
+ * `submit()`, reserved at the app's per-generation budget × the number of
739
+ * steps (one with a training step, or with a `repeat` step at any depth, is
740
+ * refused on both); an older host returns that ceiling as the estimate;
709
741
  * - one author fee applies to the whole workflow, not one per step, and it is
710
742
  * reversed whole if the workflow does not succeed;
711
743
  * - `repeat` iterates only an array in the body's `arguments`, at most 16
@@ -30,6 +30,22 @@ const WORKFLOW_SCOPES = [BLOCK_SCOPES.AI_WRITE_BUDGETED];
30
30
  * on that class is money-ambiguous by design).
31
31
  */
32
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.";
33
+ /**
34
+ * Why {@link UseBuzzWorkflow.submit} refuses a pass-through `step` body that
35
+ * carries a `quoteId`.
36
+ *
37
+ * 🔴 REFUSED BEFORE THE SEND, for the same reason as
38
+ * {@link TRAINING_BODY_SUBMIT_REFUSAL}. A `quoteId` on a pass-through body means
39
+ * "charge this job against the price the viewer confirmed", and only
40
+ * `RUN_CONFIRMED_STEP`'s host-chrome dialog can record that confirmation — so a
41
+ * `SUBMIT_WORKFLOW` carrying one can never start the job; the server refuses it
42
+ * as unconfirmed. Nothing is sent and nothing is spent, which is why this too is
43
+ * a plain `Error` and not a {@link WorkflowSubmitError}.
44
+ *
45
+ * The same body WITHOUT `quoteId` is an ordinary pass-through submit and is not
46
+ * affected.
47
+ */
48
+ export const CONFIRMED_STEP_BODY_SUBMIT_REFUSAL = "useBuzzWorkflow.submit() does not run a step body that carries a quoteId — run it with useRunConfirmedStep().runConfirmedStep(body), or drop quoteId to submit it as an ordinary job. Nothing was sent.";
33
49
  /**
34
50
  * Snapshot statuses that mean "no further polling is needed."
35
51
  * Used by both `submit` (a host can return an instant-fail / cached result)
@@ -528,9 +544,11 @@ export class WorkflowSubmitError extends Error {
528
544
  * orchestrator steps as one workflow through these same calls, and:
529
545
  * - `estimate()`'s `cost.total` is NOT a maximum: the orchestrator re-prices a
530
546
  * 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);
547
+ * - a workflow the host cannot price FAILS `estimate()` with a
548
+ * `workflow_quote_*` `errorCode` and no `cost`, yet still proceeds on
549
+ * `submit()`, reserved at the app's per-generation budget × the number of
550
+ * steps (one with a training step, or with a `repeat` step at any depth, is
551
+ * refused on both); an older host returns that ceiling as the estimate;
534
552
  * - one author fee applies to the whole workflow, not one per step, and it is
535
553
  * reversed whole if the workflow does not succeed;
536
554
  * - `repeat` iterates only an array in the body's `arguments`, at most 16
@@ -765,6 +783,15 @@ export function useBuzzWorkflow() {
765
783
  setStatus('error');
766
784
  throw refused;
767
785
  }
786
+ // See `CONFIRMED_STEP_BODY_SUBMIT_REFUSAL`. Keyed on the PRESENCE of a
787
+ // `quoteId`, whatever its shape: a malformed one is no more submittable
788
+ // than a well-formed one, and the server would refuse both.
789
+ if (body.kind === 'step' && body.quoteId !== undefined) {
790
+ const refused = new Error(CONFIRMED_STEP_BODY_SUBMIT_REFUSAL);
791
+ setError(refused);
792
+ setStatus('error');
793
+ throw refused;
794
+ }
768
795
  setError(null);
769
796
  setStatus('submitting');
770
797
  // Idempotency: reuse a caller-supplied stable key across a retry (→ one Buzz
@@ -54,6 +54,8 @@ export interface UseCheckpointPicker {
54
54
  * host. Pass `null` to clear the override and fall back to the publisher
55
55
  * default at next mount. Throws on host-side validation failure (e.g.
56
56
  * "wrong-ecosystem") — surface the message to the user; don't retry blindly.
57
+ * MODEL SLOTS ONLY: on a page (`app.page`) the host refuses every `persist`,
58
+ * so it always throws there; a page app keeps the pick in its own state.
57
59
  *
58
60
  * Both flows are host-mediated: the block never touches the picker UI or
59
61
  * the user-settings table directly. Same trust model as useBuzzPurchase /
@@ -15,6 +15,8 @@ import { sendTypedRequest } from '../transport/transport.js';
15
15
  * host. Pass `null` to clear the override and fall back to the publisher
16
16
  * default at next mount. Throws on host-side validation failure (e.g.
17
17
  * "wrong-ecosystem") — surface the message to the user; don't retry blindly.
18
+ * MODEL SLOTS ONLY: on a page (`app.page`) the host refuses every `persist`,
19
+ * so it always throws there; a page app keeps the pick in its own state.
18
20
  *
19
21
  * Both flows are host-mediated: the block never touches the picker UI or
20
22
  * the user-settings table directly. Same trust model as useBuzzPurchase /