@civitai/blocks-react 0.68.0 → 0.69.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 +165 -2
- package/dist/hooks/useBatchEstimate.d.ts +4 -3
- package/dist/hooks/useBatchEstimate.js +4 -3
- package/dist/hooks/useWorkflowBatch.d.ts +196 -0
- package/dist/hooks/useWorkflowBatch.js +352 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/internal/mockHost.d.ts +23 -0
- package/dist/internal/mockHost.js +61 -6
- package/dist/transport/validate.js +6 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -495,6 +495,53 @@ if (quote.cost) showPrice(priceLine(quote.cost));
|
|
|
495
495
|
reports `authorFee: 0`, and `authorFee: false` (`?authorFee=off`) leaves it
|
|
496
496
|
out, as an older host does.
|
|
497
497
|
|
|
498
|
+
#### Why a training step could not be priced: `snapshot.errorCode`
|
|
499
|
+
|
|
500
|
+
A pass-through `step` body whose `$type` is a training type (`'training'` or
|
|
501
|
+
`'imageResourceTraining'`) needs a price quote; when the host cannot get one it
|
|
502
|
+
refuses with `{ workflowId: 'failed', status: 'failed', error, errorCode }` and
|
|
503
|
+
no `cost`. `estimate()` rejects with a `WorkflowEstimateError` and `submit()`
|
|
504
|
+
with a `WorkflowSubmitError` (before anything is reserved), and a batch
|
|
505
|
+
estimate returns it as that body's cell. `errorCode` is the stable value to
|
|
506
|
+
branch on; `error` stays the diagnostic read.
|
|
507
|
+
|
|
508
|
+
```tsx
|
|
509
|
+
import { useBuzzWorkflow, WorkflowEstimateError } from '@civitai/blocks-react';
|
|
510
|
+
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
|
|
511
|
+
|
|
512
|
+
const { estimate } = useBuzzWorkflow();
|
|
513
|
+
declare const body: WorkflowBody;
|
|
514
|
+
|
|
515
|
+
try {
|
|
516
|
+
await estimate(body);
|
|
517
|
+
} catch (err) {
|
|
518
|
+
if (!(err instanceof WorkflowEstimateError)) throw err;
|
|
519
|
+
switch (err.snapshot.errorCode) {
|
|
520
|
+
case 'training-quote-rejected': // the request itself was refused — change it
|
|
521
|
+
showError('These training settings were refused. Check them and try again.');
|
|
522
|
+
break;
|
|
523
|
+
case 'training-quote-unavailable': // no answer right now — retrying may work
|
|
524
|
+
case 'training-quote-unpriced': // answered without a price
|
|
525
|
+
showError('Training cannot be priced right now. Try again shortly.');
|
|
526
|
+
break;
|
|
527
|
+
default: // no code, or one a later host added
|
|
528
|
+
showError('Could not get a price.');
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
| `errorCode` | Means |
|
|
534
|
+
|---|---|
|
|
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
|
+
| `'training-quote-unavailable'` | no usable answer (an outage, a timeout, a rate limit, insufficient funds, …); a retry may succeed |
|
|
537
|
+
| `'training-quote-unpriced'` | the orchestrator answered without a price |
|
|
538
|
+
| absent | any other reply, and every reply from a host that predates the field |
|
|
539
|
+
|
|
540
|
+
- **Treat an unknown value as possible.** The field is typed as an open string
|
|
541
|
+
(`KnownBlockWorkflowErrorCode | (string & {})`), the host may add codes, and
|
|
542
|
+
the inbound validator accepts any string. Keep a `default` branch.
|
|
543
|
+
- The `kind: 'training'` arm does not set it.
|
|
544
|
+
|
|
498
545
|
> **Estimate must mirror submit** (gotcha #59): build the params for `estimate`
|
|
499
546
|
> with the *exact* same logic as `submit` — same seed decision especially. The
|
|
500
547
|
> orchestrator whatif prices a cache hit (identical workflow) at 0 and a fresh
|
|
@@ -529,10 +576,11 @@ resolves with one cell per body, in order, plus the run total. Returns
|
|
|
529
576
|
- **`kind: 'training'` bodies are refused per cell.** Estimate a training run
|
|
530
577
|
with `useBuzzWorkflow().estimate()`.
|
|
531
578
|
- **Estimate only.** Nothing is submitted and no Buzz is held or spent. There is
|
|
532
|
-
no batch submit
|
|
579
|
+
no batch submit. Submit each cell with
|
|
533
580
|
`useBuzzWorkflow().submit()`, which asks the viewer and checks the budget per
|
|
534
581
|
cell. `aggregate.total` is a quote, not a lock: each cell is priced again when
|
|
535
|
-
it is submitted.
|
|
582
|
+
it is submitted. To follow the submitted cells and cancel them together, see
|
|
583
|
+
[`useWorkflowBatch()`](#useworkflowbatch).
|
|
536
584
|
|
|
537
585
|
`aggregate.total` sums the cells that priced. It covers the whole grid only when
|
|
538
586
|
`aggregate.pricedCells === aggregate.cellCount`; otherwise show it as a partial
|
|
@@ -664,6 +712,121 @@ budget runs out (`ok: false`, `timedOut: true`). **`dev:live`** forwards the cal
|
|
|
664
712
|
a civitai.com that predates the batch procedure it rejects with `'failed'`
|
|
665
713
|
rather than `'unsupported'`.
|
|
666
714
|
|
|
715
|
+
### `useWorkflowBatch()`
|
|
716
|
+
|
|
717
|
+
Watch a set of already-submitted workflows as one batch: per-cell status, a
|
|
718
|
+
summary, and cancel-all. Built for grid apps that submit each cell with
|
|
719
|
+
`useBuzzWorkflow().submit()` (each with its own confirmation, unchanged) and
|
|
720
|
+
then have to follow all of them. `useWorkflowBatch(workflowIds, options?)`
|
|
721
|
+
returns `{ cells, summary, cancelAll, rewatch }`.
|
|
722
|
+
|
|
723
|
+
- **The array of ids is the batch.** There is no server-side batch id. Append
|
|
724
|
+
each id as its `submit()` resolves, and persist the same array (plain
|
|
725
|
+
strings) to re-attach after a reload by passing it back. Skip a submit that
|
|
726
|
+
resolved with the `'failed'` placeholder id: nothing was created.
|
|
727
|
+
- **Every cell is one ordinary `watch()`**, so each workflow has exactly one
|
|
728
|
+
poll in flight. When a cell's loop restarts (the list changes, `rewatch()`,
|
|
729
|
+
a StrictMode remount) the new loop waits for the old loop's poll to return.
|
|
730
|
+
`options` (`intervalMs`, `waitSeconds`, `timeoutMs`, `maxRetries`) are passed
|
|
731
|
+
to every cell's `watch()`.
|
|
732
|
+
- **At most 30 ids** (`WORKFLOW_BATCH_MAX_CELLS`): the poll allowance (1,200 per
|
|
733
|
+
60 s per block instance and viewer) divided by one watch's 40 polls a minute.
|
|
734
|
+
A longer list, a duplicate, an empty id or a `'failed'`/`'whatif'`
|
|
735
|
+
placeholder throws.
|
|
736
|
+
- **`summary`** counts `succeeded`, `failed`, `canceled`, `expired` and `running`
|
|
737
|
+
(not terminal, including cells with no snapshot yet) from each cell's latest
|
|
738
|
+
snapshot, plus `realisedCost`: the sum of `cost.total` on the terminal
|
|
739
|
+
snapshots that carry one. That is the generation cost the host reported, not a
|
|
740
|
+
ledger read; the author fee is charged separately and is not included.
|
|
741
|
+
`settled` is `running === 0`.
|
|
742
|
+
- **A host failure placeholder is not a workflow outcome.** When the host fails
|
|
743
|
+
a poll it answers `{ workflowId: 'failed', status: 'failed' }`. That says
|
|
744
|
+
nothing about the workflow, so the cell is not counted as failed: its `phase`
|
|
745
|
+
becomes `'stopped'` with `error` set, and `summary.stopped` counts it. A
|
|
746
|
+
stopped cell (also a watch that timed out or ran out of retries) is not
|
|
747
|
+
polled again until you call `rewatch()`.
|
|
748
|
+
- **`cancelAll()` cancels every cell not known to be finished** and resolves
|
|
749
|
+
(never rejects) with `{ canceled, alreadySettled, refused, notAttempted,
|
|
750
|
+
failed, complete }`, each a list of ids. Cancels charge the estimate allowance
|
|
751
|
+
(150 per 10 s per block instance; on a page app every viewer shares it), so at
|
|
752
|
+
most four are in flight. A refused cancel resolves `status: 'processing'`: it
|
|
753
|
+
was not sent and the workflow is still running. After the first refusal the
|
|
754
|
+
rest of the call is not sent (`notAttempted`). A cancel answered with the
|
|
755
|
+
host's failure placeholder is `failed`, not a failed workflow. Nothing is
|
|
756
|
+
retried automatically. When `complete` is `false`, wait a few seconds and call
|
|
757
|
+
`cancelAll()` again; it targets only cells that are still not finished.
|
|
758
|
+
- **Unmounting stops every poll.** A `cancelAll()` already running keeps sending.
|
|
759
|
+
|
|
760
|
+
```tsx
|
|
761
|
+
import { useEffect, useState } from 'react';
|
|
762
|
+
import type { WorkflowBody } from '@civitai/app-sdk/blocks';
|
|
763
|
+
import { useBuzzWorkflow, useWorkflowBatch } from '@civitai/blocks-react';
|
|
764
|
+
|
|
765
|
+
const SAVED = 'grid-run-ids';
|
|
766
|
+
|
|
767
|
+
function GridRun({ bodies }: { bodies: WorkflowBody[] }) {
|
|
768
|
+
const { submit } = useBuzzWorkflow();
|
|
769
|
+
const [ids, setIds] = useState<string[]>(() => {
|
|
770
|
+
try {
|
|
771
|
+
return JSON.parse(localStorage.getItem(SAVED) ?? '[]') as string[];
|
|
772
|
+
} catch {
|
|
773
|
+
return [];
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
useEffect(() => {
|
|
777
|
+
try {
|
|
778
|
+
localStorage.setItem(SAVED, JSON.stringify(ids));
|
|
779
|
+
} catch {
|
|
780
|
+
// Storage can be unavailable; the run still works, it just won't re-attach.
|
|
781
|
+
}
|
|
782
|
+
}, [ids]);
|
|
783
|
+
|
|
784
|
+
const { cells, summary, cancelAll, rewatch } = useWorkflowBatch(ids);
|
|
785
|
+
const [cancelNote, setCancelNote] = useState<string | null>(null);
|
|
786
|
+
|
|
787
|
+
async function runCell(body: WorkflowBody, key: string) {
|
|
788
|
+
const snap = await submit(body, { idempotencyKey: key });
|
|
789
|
+
if (snap.workflowId !== 'failed') setIds((prev) => [...prev, snap.workflowId]);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function onCancelAll() {
|
|
793
|
+
const result = await cancelAll();
|
|
794
|
+
setCancelNote(
|
|
795
|
+
result.complete
|
|
796
|
+
? null
|
|
797
|
+
: `${result.refused.length + result.notAttempted.length + result.failed.length} cells are still running. Try again in a few seconds.`,
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return (
|
|
802
|
+
<div>
|
|
803
|
+
<button onClick={() => bodies.forEach((b, i) => void runCell(b, `cell-${i}`))}>Run grid</button>
|
|
804
|
+
<p>
|
|
805
|
+
{summary.succeeded} done, {summary.failed} failed, {summary.canceled} cancelled,{' '}
|
|
806
|
+
{summary.running} running. Spent {summary.realisedCost} Buzz.
|
|
807
|
+
</p>
|
|
808
|
+
{!summary.settled && <button onClick={onCancelAll}>Cancel all</button>}
|
|
809
|
+
{summary.stopped > 0 && <button onClick={rewatch}>Refresh stalled cells</button>}
|
|
810
|
+
{cancelNote && <p>{cancelNote}</p>}
|
|
811
|
+
<ul>
|
|
812
|
+
{cells.map((cell) => (
|
|
813
|
+
<li key={cell.workflowId}>{cell.snapshot?.status ?? 'queued'}</li>
|
|
814
|
+
))}
|
|
815
|
+
</ul>
|
|
816
|
+
</div>
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
**Mock host:** `createMockHost` and `Harness` keep a cancelled workflow
|
|
822
|
+
cancelled on later polls, report a finished workflow's real status when it is
|
|
823
|
+
cancelled, and count down an id they did not submit (one restored after a
|
|
824
|
+
reload) like any other. Set `generation.cancelRefused` to `true` (or a list of
|
|
825
|
+
workflow ids) to answer cancels the way the host does when the allowance is used
|
|
826
|
+
up, and `generation.workflowOutcome` to a `(workflowId, body) => 'succeeded' |
|
|
827
|
+
'failed' | 'expired'` function to give a grid a mix of outcomes. The mock does
|
|
828
|
+
not model the rate limits themselves.
|
|
829
|
+
|
|
667
830
|
### `useBuzzPurchase()`
|
|
668
831
|
|
|
669
832
|
Open the Buzz purchase modal. It raises the viewer's WALLET, never a spend cap:
|
|
@@ -163,9 +163,10 @@ export interface UseBatchEstimate {
|
|
|
163
163
|
* - **`kind: 'training'` bodies are refused per cell.** Estimate a training run
|
|
164
164
|
* with `useBuzzWorkflow().estimate()`.
|
|
165
165
|
*
|
|
166
|
-
* 🔴 ESTIMATE ONLY. There is no batch submit
|
|
167
|
-
* list.
|
|
168
|
-
*
|
|
166
|
+
* 🔴 ESTIMATE ONLY. There is no batch submit and no server-side id for the
|
|
167
|
+
* list. To watch and cancel the submitted cells together, use
|
|
168
|
+
* `useWorkflowBatch()`. The total is a quote: nothing reserves it and each cell
|
|
169
|
+
* is priced again at `submit()`.
|
|
169
170
|
*
|
|
170
171
|
* Like `estimate()`, this does NOT prompt for consent. It needs the
|
|
171
172
|
* `ai:write:budgeted` scope; without it the call rejects with `code: 'failed'`.
|
|
@@ -105,9 +105,10 @@ function toBatchEstimateError(err) {
|
|
|
105
105
|
* - **`kind: 'training'` bodies are refused per cell.** Estimate a training run
|
|
106
106
|
* with `useBuzzWorkflow().estimate()`.
|
|
107
107
|
*
|
|
108
|
-
* 🔴 ESTIMATE ONLY. There is no batch submit
|
|
109
|
-
* list.
|
|
110
|
-
*
|
|
108
|
+
* 🔴 ESTIMATE ONLY. There is no batch submit and no server-side id for the
|
|
109
|
+
* list. To watch and cancel the submitted cells together, use
|
|
110
|
+
* `useWorkflowBatch()`. The total is a quote: nothing reserves it and each cell
|
|
111
|
+
* is priced again at `submit()`.
|
|
111
112
|
*
|
|
112
113
|
* Like `estimate()`, this does NOT prompt for consent. It needs the
|
|
113
114
|
* `ai:write:budgeted` scope; without it the call rejects with `code: 'failed'`.
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { BlockWorkflowSnapshot } from '@civitai/app-sdk/blocks';
|
|
2
|
+
import type { WatchWorkflowOptions } from './useBuzzWorkflow.js';
|
|
3
|
+
/**
|
|
4
|
+
* The most workflows one {@link useWorkflowBatch} may watch.
|
|
5
|
+
*
|
|
6
|
+
* Derived from the host's poll allowance, not picked: one watched workflow makes
|
|
7
|
+
* at most 60 / 1.5 = 40 polls a minute (one poll in flight, `intervalMs` 1,500
|
|
8
|
+
* between polls), and the viewer's poll allowance is 1,200 per 60 s per block
|
|
9
|
+
* instance, so 1,200 / 40 = 30 workflows is the most one viewer can watch before
|
|
10
|
+
* polls start coming back as placeholders. The developer docs' limits table
|
|
11
|
+
* gives the same figure. A list longer than this throws, because an app that
|
|
12
|
+
* needs more cells needs a different plan than "watch them all at once".
|
|
13
|
+
*/
|
|
14
|
+
export declare const WORKFLOW_BATCH_MAX_CELLS = 30;
|
|
15
|
+
/**
|
|
16
|
+
* Where a cell's watch stands.
|
|
17
|
+
*
|
|
18
|
+
* - `'watching'` — a `watch()` loop is running for it.
|
|
19
|
+
* - `'settled'` — its latest snapshot is terminal. Nothing is polling it.
|
|
20
|
+
* - `'stopped'` — nothing is polling it and it is NOT known to be finished: the
|
|
21
|
+
* watch timed out (`timeoutMs`), gave up after `maxRetries` failed polls, or the
|
|
22
|
+
* host answered a poll with its failure placeholder instead of this workflow's
|
|
23
|
+
* status. The workflow may still be running. `error` says why when there was an
|
|
24
|
+
* error. Call {@link UseWorkflowBatch.rewatch} to start watching it again.
|
|
25
|
+
*/
|
|
26
|
+
export type WorkflowBatchCellPhase = 'watching' | 'settled' | 'stopped';
|
|
27
|
+
/**
|
|
28
|
+
* Where a cell's most recent cancel request stands.
|
|
29
|
+
*
|
|
30
|
+
* - `'idle'` — no cancel was asked for.
|
|
31
|
+
* - `'pending'` — a cancel request is in flight.
|
|
32
|
+
* - `'done'` — the host answered with a terminal snapshot. Usually `canceled`,
|
|
33
|
+
* but a workflow that finished before the cancel landed reports its real
|
|
34
|
+
* status (`succeeded`, `failed`, `expired`).
|
|
35
|
+
* - `'refused'` — not cancelled. The host answered with a NON-terminal snapshot
|
|
36
|
+
* (its answer when the estimate allowance is used up: the cancel was not sent),
|
|
37
|
+
* or this cell was not attempted because an earlier cancel in the same
|
|
38
|
+
* `cancelAll()` was refused. The workflow is still running. Call `cancelAll()`
|
|
39
|
+
* again after a few seconds.
|
|
40
|
+
* - `'failed'` — the cancel request itself failed: it rejected, or the host
|
|
41
|
+
* answered with its failure placeholder (`workflowId: 'failed'`) rather than
|
|
42
|
+
* this workflow's snapshot. The workflow's state is unknown and it may still be
|
|
43
|
+
* running. Calling `cancelAll()` again retries it.
|
|
44
|
+
*/
|
|
45
|
+
export type WorkflowBatchCancelState = 'idle' | 'pending' | 'done' | 'refused' | 'failed';
|
|
46
|
+
/** One watched workflow. */
|
|
47
|
+
export interface WorkflowBatchCell {
|
|
48
|
+
workflowId: string;
|
|
49
|
+
/**
|
|
50
|
+
* The latest snapshot the host returned FOR THIS WORKFLOW, or `null` before the
|
|
51
|
+
* first one. A host failure placeholder (whose `workflowId` is `'failed'`) is
|
|
52
|
+
* never stored here, because it is not this workflow's status.
|
|
53
|
+
*/
|
|
54
|
+
snapshot: BlockWorkflowSnapshot | null;
|
|
55
|
+
phase: WorkflowBatchCellPhase;
|
|
56
|
+
/** Why the watch stopped, when it stopped on an error. `null` otherwise. */
|
|
57
|
+
error: Error | null;
|
|
58
|
+
cancel: WorkflowBatchCancelState;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Counts over every cell, from each cell's latest snapshot.
|
|
62
|
+
*
|
|
63
|
+
* `succeeded + failed + canceled + expired + running === total`.
|
|
64
|
+
*/
|
|
65
|
+
export interface WorkflowBatchSummary {
|
|
66
|
+
total: number;
|
|
67
|
+
/** Cells whose latest snapshot is not terminal, including cells with no snapshot yet. */
|
|
68
|
+
running: number;
|
|
69
|
+
succeeded: number;
|
|
70
|
+
failed: number;
|
|
71
|
+
canceled: number;
|
|
72
|
+
expired: number;
|
|
73
|
+
/**
|
|
74
|
+
* The part of `running` that nothing is polling (phase `'stopped'`). A batch
|
|
75
|
+
* with `stopped > 0` will not settle on its own: call `rewatch()`.
|
|
76
|
+
*/
|
|
77
|
+
stopped: number;
|
|
78
|
+
/**
|
|
79
|
+
* The sum of `cost.total` on the terminal snapshots that carry one: the
|
|
80
|
+
* generation cost the host reported for each finished workflow (a cancelled
|
|
81
|
+
* workflow reports its cost after the cancel). It is what the snapshots say,
|
|
82
|
+
* not a read of the viewer's Buzz ledger. The app author's fee is charged
|
|
83
|
+
* separately and is not included.
|
|
84
|
+
*/
|
|
85
|
+
realisedCost: number;
|
|
86
|
+
/** How many terminal cells contributed to `realisedCost`. */
|
|
87
|
+
costedCells: number;
|
|
88
|
+
/** `true` when every cell is terminal (`running === 0`). `true` for an empty list. */
|
|
89
|
+
settled: boolean;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* What one {@link UseWorkflowBatch.cancelAll} call did. Each array holds workflow
|
|
93
|
+
* ids, in list order.
|
|
94
|
+
*/
|
|
95
|
+
export interface WorkflowBatchCancelResult {
|
|
96
|
+
/** The host answered `canceled`. */
|
|
97
|
+
canceled: string[];
|
|
98
|
+
/**
|
|
99
|
+
* The workflow had already finished: either its latest snapshot was already
|
|
100
|
+
* terminal (no request was sent), or the host answered the cancel with the
|
|
101
|
+
* workflow's real terminal status because it finished first.
|
|
102
|
+
*/
|
|
103
|
+
alreadySettled: string[];
|
|
104
|
+
/** The host answered with a non-terminal snapshot: the cancel was not sent. */
|
|
105
|
+
refused: string[];
|
|
106
|
+
/**
|
|
107
|
+
* Not attempted, because an earlier cancel in this call was refused. The
|
|
108
|
+
* allowance is used up, so sending more would only be refused too.
|
|
109
|
+
*/
|
|
110
|
+
notAttempted: string[];
|
|
111
|
+
/** The cancel request failed. The workflow's state is unknown. */
|
|
112
|
+
failed: string[];
|
|
113
|
+
/** `true` when `refused`, `notAttempted` and `failed` are all empty. */
|
|
114
|
+
complete: boolean;
|
|
115
|
+
}
|
|
116
|
+
/** Options for {@link useWorkflowBatch}. Passed to every cell's `watch()`. */
|
|
117
|
+
export type WorkflowBatchOptions = Pick<WatchWorkflowOptions, 'intervalMs' | 'waitSeconds' | 'timeoutMs' | 'maxRetries'>;
|
|
118
|
+
/** What {@link useWorkflowBatch} returns. */
|
|
119
|
+
export interface UseWorkflowBatch {
|
|
120
|
+
/** One cell per id passed in, in the same order. */
|
|
121
|
+
cells: readonly WorkflowBatchCell[];
|
|
122
|
+
summary: WorkflowBatchSummary;
|
|
123
|
+
/**
|
|
124
|
+
* Cancel every cell that is not known to be finished. Resolves (never rejects)
|
|
125
|
+
* with what happened to each one; see {@link WorkflowBatchCancelResult}.
|
|
126
|
+
*
|
|
127
|
+
* Calling it while a previous call is still running returns that call's
|
|
128
|
+
* promise. Calling it again after a call that was not `complete` retries the
|
|
129
|
+
* cells that are still not finished.
|
|
130
|
+
*/
|
|
131
|
+
cancelAll: () => Promise<WorkflowBatchCancelResult>;
|
|
132
|
+
/** Start watching every `'stopped'` cell again. */
|
|
133
|
+
rewatch: () => void;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Watch a set of already-submitted workflows as one batch, with per-cell status,
|
|
137
|
+
* a summary, and cancel-all. For grid apps that submit N workflows one by one
|
|
138
|
+
* with `useBuzzWorkflow().submit()` (each with its own confirmation) and then
|
|
139
|
+
* have to follow all N.
|
|
140
|
+
*
|
|
141
|
+
* 🔴 THE APP OWNS THE LIST, AND THE LIST IS THE BATCH. Pass the workflow ids as
|
|
142
|
+
* an array: append each id as its `submit()` resolves, and persist the same
|
|
143
|
+
* array (it is plain strings) to re-attach after a reload by passing it back.
|
|
144
|
+
* There is no server-side batch id. The host knows nothing about the grouping,
|
|
145
|
+
* so nothing here changes what the host allows: every cell is one ordinary
|
|
146
|
+
* `watch()` and every cancel is one ordinary `cancel()`.
|
|
147
|
+
*
|
|
148
|
+
* What it guarantees, and the host limits behind each:
|
|
149
|
+
*
|
|
150
|
+
* - **One poll in flight per workflow.** Each cell is one `watch()` loop. When a
|
|
151
|
+
* loop is restarted (the list changes, `rewatch()`, a React StrictMode
|
|
152
|
+
* remount), the new loop waits for the old loop's in-flight poll to return
|
|
153
|
+
* first, so two polls for one workflow never overlap.
|
|
154
|
+
* - **At most {@link WORKFLOW_BATCH_MAX_CELLS} workflows.** That is the poll
|
|
155
|
+
* allowance (1,200 per 60 s, per block instance and viewer) divided by one
|
|
156
|
+
* watch's 40 polls a minute. A longer list throws.
|
|
157
|
+
* - **Cancel-all is paced and stops on refusal.** Cancels charge the estimate
|
|
158
|
+
* allowance (150 per 10 s per block instance, shared by every viewer of a page
|
|
159
|
+
* app). At most four are in flight. When the host answers one with a
|
|
160
|
+
* non-terminal snapshot (its answer when that allowance is used up), the rest
|
|
161
|
+
* of the call is not sent and comes back as `notAttempted`. Nothing is retried
|
|
162
|
+
* automatically. Call `cancelAll()` again after a few seconds.
|
|
163
|
+
* - **A host failure placeholder is not a workflow outcome.** When the host
|
|
164
|
+
* fails a poll or a cancel it answers with `{ workflowId: 'failed', status:
|
|
165
|
+
* 'failed' }`. `watch()` and `cancel()` resolve with that, but it says nothing
|
|
166
|
+
* about the workflow, which may still be running. This hook never counts it as
|
|
167
|
+
* a failed cell: a poll placeholder stops the cell (phase `'stopped'`, with
|
|
168
|
+
* `error` set), and a cancel placeholder is a `'failed'` cancel.
|
|
169
|
+
* - **Unmounting stops every poll.** No state updates happen after unmount. A
|
|
170
|
+
* `cancelAll()` already running keeps sending, because the viewer asked for it.
|
|
171
|
+
*
|
|
172
|
+
* Throws (at render) when the list is longer than
|
|
173
|
+
* {@link WORKFLOW_BATCH_MAX_CELLS}, has a duplicate, has an empty id, or has the
|
|
174
|
+
* `'failed'` / `'whatif'` placeholder a refused `submit()` resolves with.
|
|
175
|
+
*
|
|
176
|
+
* Needs the same `ai:write:budgeted` scope as `submit()`. A viewer who submitted
|
|
177
|
+
* already has it.
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* const { submit } = useBuzzWorkflow();
|
|
181
|
+
* const [ids, setIds] = useState<string[]>(() => loadSavedIds());
|
|
182
|
+
* const batch = useWorkflowBatch(ids);
|
|
183
|
+
*
|
|
184
|
+
* async function runCell(body: WorkflowBody, key: string) {
|
|
185
|
+
* const snap = await submit(body, { idempotencyKey: key });
|
|
186
|
+
* if (snap.workflowId !== 'failed') setIds((prev) => [...prev, snap.workflowId]);
|
|
187
|
+
* }
|
|
188
|
+
*
|
|
189
|
+
* useEffect(() => saveIds(ids), [ids]);
|
|
190
|
+
*
|
|
191
|
+
* // batch.summary.running, batch.summary.realisedCost, batch.cells[i].snapshot …
|
|
192
|
+
* const result = await batch.cancelAll();
|
|
193
|
+
* if (!result.complete) showRetryCancel(result);
|
|
194
|
+
*/
|
|
195
|
+
export declare function useWorkflowBatch(workflowIds: readonly string[], options?: WorkflowBatchOptions): UseWorkflowBatch;
|
|
196
|
+
//# sourceMappingURL=useWorkflowBatch.d.ts.map
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { useBuzzWorkflow } from './useBuzzWorkflow.js';
|
|
3
|
+
/**
|
|
4
|
+
* The most workflows one {@link useWorkflowBatch} may watch.
|
|
5
|
+
*
|
|
6
|
+
* Derived from the host's poll allowance, not picked: one watched workflow makes
|
|
7
|
+
* at most 60 / 1.5 = 40 polls a minute (one poll in flight, `intervalMs` 1,500
|
|
8
|
+
* between polls), and the viewer's poll allowance is 1,200 per 60 s per block
|
|
9
|
+
* instance, so 1,200 / 40 = 30 workflows is the most one viewer can watch before
|
|
10
|
+
* polls start coming back as placeholders. The developer docs' limits table
|
|
11
|
+
* gives the same figure. A list longer than this throws, because an app that
|
|
12
|
+
* needs more cells needs a different plan than "watch them all at once".
|
|
13
|
+
*/
|
|
14
|
+
export const WORKFLOW_BATCH_MAX_CELLS = 30;
|
|
15
|
+
/**
|
|
16
|
+
* How many `cancel()` requests {@link UseWorkflowBatch.cancelAll} keeps in flight.
|
|
17
|
+
*
|
|
18
|
+
* Cancels charge the ESTIMATE allowance (150 per 10 s per block instance). On a
|
|
19
|
+
* page app every viewer of the app shares that allowance, along with the host's
|
|
20
|
+
* other reads for the app (viewer, balance, image and model lookups). Four at a
|
|
21
|
+
* time keeps a 16-cell cancel-all from landing as one burst on an allowance other
|
|
22
|
+
* people are using. Not exported: it is a pacing detail, not a contract.
|
|
23
|
+
*/
|
|
24
|
+
const CANCEL_CONCURRENCY = 4;
|
|
25
|
+
/** Statuses after which a workflow will not change again. */
|
|
26
|
+
const TERMINAL = new Set([
|
|
27
|
+
'succeeded',
|
|
28
|
+
'failed',
|
|
29
|
+
'canceled',
|
|
30
|
+
'expired',
|
|
31
|
+
]);
|
|
32
|
+
/**
|
|
33
|
+
* Placeholder ids a resolved `submit()` can carry when no workflow was created
|
|
34
|
+
* (see `useBuzzWorkflow().submit`). There is nothing behind them to watch.
|
|
35
|
+
*/
|
|
36
|
+
const NON_WORKFLOW_IDS = new Set(['failed', 'whatif']);
|
|
37
|
+
const FRESH = { snapshot: null, phase: 'watching', error: null, cancel: 'idle' };
|
|
38
|
+
function validateIds(ids) {
|
|
39
|
+
if (ids.length > WORKFLOW_BATCH_MAX_CELLS) {
|
|
40
|
+
throw new RangeError(`useWorkflowBatch: ${ids.length} workflow ids, but at most ${WORKFLOW_BATCH_MAX_CELLS} can be watched at once`);
|
|
41
|
+
}
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
for (const id of ids) {
|
|
44
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
45
|
+
throw new TypeError('useWorkflowBatch: every workflow id must be a non-empty string');
|
|
46
|
+
}
|
|
47
|
+
if (NON_WORKFLOW_IDS.has(id)) {
|
|
48
|
+
throw new TypeError(`useWorkflowBatch: '${id}' is not a workflow id. A submit that resolved with it created nothing to watch`);
|
|
49
|
+
}
|
|
50
|
+
if (seen.has(id)) {
|
|
51
|
+
throw new TypeError(`useWorkflowBatch: workflow id '${id}' appears more than once`);
|
|
52
|
+
}
|
|
53
|
+
seen.add(id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function summarise(cells) {
|
|
57
|
+
const summary = {
|
|
58
|
+
total: cells.length,
|
|
59
|
+
running: 0,
|
|
60
|
+
succeeded: 0,
|
|
61
|
+
failed: 0,
|
|
62
|
+
canceled: 0,
|
|
63
|
+
expired: 0,
|
|
64
|
+
stopped: 0,
|
|
65
|
+
realisedCost: 0,
|
|
66
|
+
costedCells: 0,
|
|
67
|
+
settled: true,
|
|
68
|
+
};
|
|
69
|
+
for (const cell of cells) {
|
|
70
|
+
const status = cell.snapshot?.status;
|
|
71
|
+
if (status === undefined || !TERMINAL.has(status)) {
|
|
72
|
+
summary.running += 1;
|
|
73
|
+
if (cell.phase === 'stopped')
|
|
74
|
+
summary.stopped += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
summary[status] += 1;
|
|
78
|
+
const total = cell.snapshot?.cost?.total;
|
|
79
|
+
if (typeof total === 'number' && Number.isFinite(total)) {
|
|
80
|
+
summary.realisedCost += total;
|
|
81
|
+
summary.costedCells += 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
summary.settled = summary.running === 0;
|
|
85
|
+
return summary;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Watch a set of already-submitted workflows as one batch, with per-cell status,
|
|
89
|
+
* a summary, and cancel-all. For grid apps that submit N workflows one by one
|
|
90
|
+
* with `useBuzzWorkflow().submit()` (each with its own confirmation) and then
|
|
91
|
+
* have to follow all N.
|
|
92
|
+
*
|
|
93
|
+
* 🔴 THE APP OWNS THE LIST, AND THE LIST IS THE BATCH. Pass the workflow ids as
|
|
94
|
+
* an array: append each id as its `submit()` resolves, and persist the same
|
|
95
|
+
* array (it is plain strings) to re-attach after a reload by passing it back.
|
|
96
|
+
* There is no server-side batch id. The host knows nothing about the grouping,
|
|
97
|
+
* so nothing here changes what the host allows: every cell is one ordinary
|
|
98
|
+
* `watch()` and every cancel is one ordinary `cancel()`.
|
|
99
|
+
*
|
|
100
|
+
* What it guarantees, and the host limits behind each:
|
|
101
|
+
*
|
|
102
|
+
* - **One poll in flight per workflow.** Each cell is one `watch()` loop. When a
|
|
103
|
+
* loop is restarted (the list changes, `rewatch()`, a React StrictMode
|
|
104
|
+
* remount), the new loop waits for the old loop's in-flight poll to return
|
|
105
|
+
* first, so two polls for one workflow never overlap.
|
|
106
|
+
* - **At most {@link WORKFLOW_BATCH_MAX_CELLS} workflows.** That is the poll
|
|
107
|
+
* allowance (1,200 per 60 s, per block instance and viewer) divided by one
|
|
108
|
+
* watch's 40 polls a minute. A longer list throws.
|
|
109
|
+
* - **Cancel-all is paced and stops on refusal.** Cancels charge the estimate
|
|
110
|
+
* allowance (150 per 10 s per block instance, shared by every viewer of a page
|
|
111
|
+
* app). At most four are in flight. When the host answers one with a
|
|
112
|
+
* non-terminal snapshot (its answer when that allowance is used up), the rest
|
|
113
|
+
* of the call is not sent and comes back as `notAttempted`. Nothing is retried
|
|
114
|
+
* automatically. Call `cancelAll()` again after a few seconds.
|
|
115
|
+
* - **A host failure placeholder is not a workflow outcome.** When the host
|
|
116
|
+
* fails a poll or a cancel it answers with `{ workflowId: 'failed', status:
|
|
117
|
+
* 'failed' }`. `watch()` and `cancel()` resolve with that, but it says nothing
|
|
118
|
+
* about the workflow, which may still be running. This hook never counts it as
|
|
119
|
+
* a failed cell: a poll placeholder stops the cell (phase `'stopped'`, with
|
|
120
|
+
* `error` set), and a cancel placeholder is a `'failed'` cancel.
|
|
121
|
+
* - **Unmounting stops every poll.** No state updates happen after unmount. A
|
|
122
|
+
* `cancelAll()` already running keeps sending, because the viewer asked for it.
|
|
123
|
+
*
|
|
124
|
+
* Throws (at render) when the list is longer than
|
|
125
|
+
* {@link WORKFLOW_BATCH_MAX_CELLS}, has a duplicate, has an empty id, or has the
|
|
126
|
+
* `'failed'` / `'whatif'` placeholder a refused `submit()` resolves with.
|
|
127
|
+
*
|
|
128
|
+
* Needs the same `ai:write:budgeted` scope as `submit()`. A viewer who submitted
|
|
129
|
+
* already has it.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* const { submit } = useBuzzWorkflow();
|
|
133
|
+
* const [ids, setIds] = useState<string[]>(() => loadSavedIds());
|
|
134
|
+
* const batch = useWorkflowBatch(ids);
|
|
135
|
+
*
|
|
136
|
+
* async function runCell(body: WorkflowBody, key: string) {
|
|
137
|
+
* const snap = await submit(body, { idempotencyKey: key });
|
|
138
|
+
* if (snap.workflowId !== 'failed') setIds((prev) => [...prev, snap.workflowId]);
|
|
139
|
+
* }
|
|
140
|
+
*
|
|
141
|
+
* useEffect(() => saveIds(ids), [ids]);
|
|
142
|
+
*
|
|
143
|
+
* // batch.summary.running, batch.summary.realisedCost, batch.cells[i].snapshot …
|
|
144
|
+
* const result = await batch.cancelAll();
|
|
145
|
+
* if (!result.complete) showRetryCancel(result);
|
|
146
|
+
*/
|
|
147
|
+
export function useWorkflowBatch(workflowIds, options) {
|
|
148
|
+
validateIds(workflowIds);
|
|
149
|
+
// A private `useBuzzWorkflow()` instance, so the app's own instance's
|
|
150
|
+
// `result`/`status` are not overwritten by 16 cells' updates.
|
|
151
|
+
const { watch, cancel } = useBuzzWorkflow();
|
|
152
|
+
const [states, setStates] = useState(() => new Map());
|
|
153
|
+
// Mirror of `states` for async code, which must not read a stale closure.
|
|
154
|
+
const statesRef = useRef(states);
|
|
155
|
+
statesRef.current = states;
|
|
156
|
+
const idsKey = workflowIds.join('\u0000');
|
|
157
|
+
const idsRef = useRef(workflowIds);
|
|
158
|
+
idsRef.current = workflowIds;
|
|
159
|
+
const optionsRef = useRef(options);
|
|
160
|
+
optionsRef.current = options;
|
|
161
|
+
const mountedRef = useRef(false);
|
|
162
|
+
/** Per workflow: the running loop's controller and a promise that settles when it ends. */
|
|
163
|
+
const loopsRef = useRef(new Map());
|
|
164
|
+
const cancelAllRef = useRef(null);
|
|
165
|
+
const patch = useCallback((workflowId, change) => {
|
|
166
|
+
if (!mountedRef.current)
|
|
167
|
+
return;
|
|
168
|
+
setStates((prev) => {
|
|
169
|
+
const next = new Map(prev);
|
|
170
|
+
next.set(workflowId, { ...(prev.get(workflowId) ?? FRESH), ...change });
|
|
171
|
+
return next;
|
|
172
|
+
});
|
|
173
|
+
}, []);
|
|
174
|
+
const startLoop = useCallback((workflowId) => {
|
|
175
|
+
const loops = loopsRef.current;
|
|
176
|
+
const previous = loops.get(workflowId);
|
|
177
|
+
previous?.controller.abort();
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
// 🔴 CHAIN ON THE PREVIOUS LOOP. Aborting a `watch()` does not withdraw a
|
|
180
|
+
// poll already in flight (the host can hold it up to 15 s); the loop only
|
|
181
|
+
// sees the abort when that poll returns. Starting the new loop at once would
|
|
182
|
+
// put two polls for one workflow in flight.
|
|
183
|
+
const done = (previous?.done ?? Promise.resolve()).then(async () => {
|
|
184
|
+
if (controller.signal.aborted)
|
|
185
|
+
return;
|
|
186
|
+
try {
|
|
187
|
+
const final = await watch(workflowId, {
|
|
188
|
+
...optionsRef.current,
|
|
189
|
+
signal: controller.signal,
|
|
190
|
+
onUpdate: (snapshot) => {
|
|
191
|
+
if (controller.signal.aborted || snapshot.workflowId !== workflowId)
|
|
192
|
+
return;
|
|
193
|
+
patch(workflowId, { snapshot });
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
if (controller.signal.aborted)
|
|
197
|
+
return;
|
|
198
|
+
if (final.workflowId !== workflowId) {
|
|
199
|
+
patch(workflowId, {
|
|
200
|
+
phase: 'stopped',
|
|
201
|
+
error: new Error(`useWorkflowBatch: the host answered a poll for ${workflowId} with a failure placeholder, not the workflow's status`),
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
patch(workflowId, {
|
|
206
|
+
snapshot: final,
|
|
207
|
+
phase: TERMINAL.has(final.status) ? 'settled' : 'stopped',
|
|
208
|
+
error: null,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
if (controller.signal.aborted)
|
|
213
|
+
return;
|
|
214
|
+
patch(workflowId, { phase: 'stopped', error: err instanceof Error ? err : new Error(String(err)) });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
loops.set(workflowId, { controller, done });
|
|
218
|
+
}, [watch, patch]);
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
mountedRef.current = true;
|
|
221
|
+
const loops = loopsRef.current;
|
|
222
|
+
return () => {
|
|
223
|
+
mountedRef.current = false;
|
|
224
|
+
for (const loop of loops.values())
|
|
225
|
+
loop.controller.abort();
|
|
226
|
+
};
|
|
227
|
+
}, []);
|
|
228
|
+
// Diff the list against the running loops: start new ids, stop removed ones.
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
const loops = loopsRef.current;
|
|
231
|
+
const wanted = new Set(idsRef.current);
|
|
232
|
+
for (const [id, loop] of loops) {
|
|
233
|
+
// Abort, but KEEP the entry: its `done` is what a re-added id's new loop
|
|
234
|
+
// must wait on, because the aborted loop's poll may still be in flight.
|
|
235
|
+
if (!wanted.has(id))
|
|
236
|
+
loop.controller.abort();
|
|
237
|
+
}
|
|
238
|
+
for (const id of idsRef.current) {
|
|
239
|
+
const loop = loops.get(id);
|
|
240
|
+
if (loop && !loop.controller.signal.aborted)
|
|
241
|
+
continue;
|
|
242
|
+
const state = statesRef.current.get(id);
|
|
243
|
+
// A settled or stopped cell stays as it is until `rewatch()`.
|
|
244
|
+
if (state && state.phase !== 'watching')
|
|
245
|
+
continue;
|
|
246
|
+
startLoop(id);
|
|
247
|
+
}
|
|
248
|
+
setStates((prev) => {
|
|
249
|
+
let changed = false;
|
|
250
|
+
const next = new Map(prev);
|
|
251
|
+
for (const id of prev.keys()) {
|
|
252
|
+
if (!wanted.has(id)) {
|
|
253
|
+
next.delete(id);
|
|
254
|
+
changed = true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return changed ? next : prev;
|
|
258
|
+
});
|
|
259
|
+
// `idsKey` stands for the list's contents; the list itself is read from the ref.
|
|
260
|
+
}, [idsKey, startLoop]);
|
|
261
|
+
const rewatch = useCallback(() => {
|
|
262
|
+
for (const id of idsRef.current) {
|
|
263
|
+
if (statesRef.current.get(id)?.phase !== 'stopped')
|
|
264
|
+
continue;
|
|
265
|
+
patch(id, { phase: 'watching', error: null });
|
|
266
|
+
startLoop(id);
|
|
267
|
+
}
|
|
268
|
+
}, [patch, startLoop]);
|
|
269
|
+
const cancelAll = useCallback(() => {
|
|
270
|
+
if (cancelAllRef.current)
|
|
271
|
+
return cancelAllRef.current;
|
|
272
|
+
const run = async () => {
|
|
273
|
+
const ids = [...idsRef.current];
|
|
274
|
+
const outcome = new Map();
|
|
275
|
+
const targets = [];
|
|
276
|
+
for (const id of ids) {
|
|
277
|
+
const status = statesRef.current.get(id)?.snapshot?.status;
|
|
278
|
+
if (status !== undefined && TERMINAL.has(status))
|
|
279
|
+
outcome.set(id, 'alreadySettled');
|
|
280
|
+
else
|
|
281
|
+
targets.push(id);
|
|
282
|
+
}
|
|
283
|
+
let refusedSeen = false;
|
|
284
|
+
let next = 0;
|
|
285
|
+
const worker = async () => {
|
|
286
|
+
for (let id = targets[next]; id !== undefined; id = targets[next]) {
|
|
287
|
+
next += 1;
|
|
288
|
+
if (refusedSeen) {
|
|
289
|
+
outcome.set(id, 'notAttempted');
|
|
290
|
+
patch(id, { cancel: 'refused' });
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
patch(id, { cancel: 'pending' });
|
|
294
|
+
let snapshot;
|
|
295
|
+
try {
|
|
296
|
+
snapshot = await cancel(id);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
outcome.set(id, 'failed');
|
|
300
|
+
patch(id, { cancel: 'failed' });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (snapshot.workflowId !== id) {
|
|
304
|
+
// The host's failure placeholder: the request failed, the workflow's
|
|
305
|
+
// state is unknown.
|
|
306
|
+
outcome.set(id, 'failed');
|
|
307
|
+
patch(id, { cancel: 'failed' });
|
|
308
|
+
}
|
|
309
|
+
else if (TERMINAL.has(snapshot.status)) {
|
|
310
|
+
outcome.set(id, snapshot.status === 'canceled' ? 'canceled' : 'alreadySettled');
|
|
311
|
+
loopsRef.current.get(id)?.controller.abort();
|
|
312
|
+
patch(id, { cancel: 'done', snapshot, phase: 'settled', error: null });
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
refusedSeen = true;
|
|
316
|
+
outcome.set(id, 'refused');
|
|
317
|
+
patch(id, { cancel: 'refused' });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
await Promise.all(Array.from({ length: Math.min(CANCEL_CONCURRENCY, targets.length) }, () => worker()));
|
|
322
|
+
const result = {
|
|
323
|
+
canceled: [],
|
|
324
|
+
alreadySettled: [],
|
|
325
|
+
refused: [],
|
|
326
|
+
notAttempted: [],
|
|
327
|
+
failed: [],
|
|
328
|
+
complete: true,
|
|
329
|
+
};
|
|
330
|
+
for (const id of ids) {
|
|
331
|
+
const bucket = outcome.get(id);
|
|
332
|
+
if (bucket)
|
|
333
|
+
result[bucket].push(id);
|
|
334
|
+
}
|
|
335
|
+
result.complete =
|
|
336
|
+
result.refused.length === 0 && result.notAttempted.length === 0 && result.failed.length === 0;
|
|
337
|
+
return result;
|
|
338
|
+
};
|
|
339
|
+
const promise = run().finally(() => {
|
|
340
|
+
cancelAllRef.current = null;
|
|
341
|
+
});
|
|
342
|
+
cancelAllRef.current = promise;
|
|
343
|
+
return promise;
|
|
344
|
+
}, [cancel, patch]);
|
|
345
|
+
const cells = useMemo(() => workflowIds.map((workflowId) => ({ workflowId, ...(states.get(workflowId) ?? FRESH) })),
|
|
346
|
+
// `idsKey` stands for the list's contents, so a new array with the same ids
|
|
347
|
+
// does not rebuild the cells.
|
|
348
|
+
[idsKey, states]);
|
|
349
|
+
const summary = useMemo(() => summarise(cells), [cells]);
|
|
350
|
+
return { cells, summary, cancelAll, rewatch };
|
|
351
|
+
}
|
|
352
|
+
//# sourceMappingURL=useWorkflowBatch.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export { DEFAULT_WATCH_WAIT_SECONDS, useBuzzWorkflow, WorkflowEstimateError, Wor
|
|
|
29
29
|
export type { SubmitWorkflowOptions, UseBuzzWorkflow, WatchWorkflowOptions, WorkflowSubmitErrorCode, } from './hooks/useBuzzWorkflow.js';
|
|
30
30
|
export { BATCH_ESTIMATE_MAX_CELLS, BatchEstimateError, useBatchEstimate, } from './hooks/useBatchEstimate.js';
|
|
31
31
|
export type { BatchEstimateCell, BatchEstimateErrorCode, BatchEstimateResult, BlockEstimateBatchAggregate, EstimateBatchOptions, UseBatchEstimate, } from './hooks/useBatchEstimate.js';
|
|
32
|
+
export { WORKFLOW_BATCH_MAX_CELLS, useWorkflowBatch } from './hooks/useWorkflowBatch.js';
|
|
33
|
+
export type { UseWorkflowBatch, WorkflowBatchCancelResult, WorkflowBatchCancelState, WorkflowBatchCell, WorkflowBatchCellPhase, WorkflowBatchOptions, WorkflowBatchSummary, } from './hooks/useWorkflowBatch.js';
|
|
32
34
|
export { useEntitlements } from './hooks/useEntitlements.js';
|
|
33
35
|
export type { UseEntitlements, Entitlement } from './hooks/useEntitlements.js';
|
|
34
36
|
export { useGoodPurchase, GoodPurchaseRefusal } from './hooks/useGoodPurchase.js';
|
package/dist/index.js
CHANGED
|
@@ -32,6 +32,7 @@ export { useBlockToken } from './hooks/useBlockToken.js';
|
|
|
32
32
|
export { useHostOrigin } from './hooks/useHostOrigin.js';
|
|
33
33
|
export { DEFAULT_WATCH_WAIT_SECONDS, useBuzzWorkflow, WorkflowEstimateError, WorkflowSubmitError, } from './hooks/useBuzzWorkflow.js';
|
|
34
34
|
export { BATCH_ESTIMATE_MAX_CELLS, BatchEstimateError, useBatchEstimate, } from './hooks/useBatchEstimate.js';
|
|
35
|
+
export { WORKFLOW_BATCH_MAX_CELLS, useWorkflowBatch } from './hooks/useWorkflowBatch.js';
|
|
35
36
|
export { useEntitlements } from './hooks/useEntitlements.js';
|
|
36
37
|
export { useGoodPurchase, GoodPurchaseRefusal } from './hooks/useGoodPurchase.js';
|
|
37
38
|
export { useTip } from './hooks/useTip.js';
|
|
@@ -195,6 +195,29 @@ export interface MockGenerationScenario {
|
|
|
195
195
|
* `batchEstimate: 'supported'`. Default: unset (no cell times out).
|
|
196
196
|
*/
|
|
197
197
|
batchEstimateTimedOutCells?: number[];
|
|
198
|
+
/**
|
|
199
|
+
* Make `CANCEL_WORKFLOW` answer the way the real host answers a cancel it
|
|
200
|
+
* REFUSED because the estimate allowance is used up: `{ workflowId, status:
|
|
201
|
+
* 'processing' }`. The cancel is not applied, so later polls keep reporting
|
|
202
|
+
* the workflow as running. `true` refuses every cancel; an array refuses only
|
|
203
|
+
* those workflow ids. Use it to test a cancel button's "not cancelled, try
|
|
204
|
+
* again" path, including `useWorkflowBatch().cancelAll()`'s `refused` and
|
|
205
|
+
* `notAttempted` lists.
|
|
206
|
+
*
|
|
207
|
+
* Live-tunable via `setScenario({ generation: { cancelRefused } })`. Default:
|
|
208
|
+
* unset (every cancel applies).
|
|
209
|
+
*/
|
|
210
|
+
cancelRefused?: boolean | readonly string[];
|
|
211
|
+
/**
|
|
212
|
+
* The terminal status each polled workflow ends with, so a grid can be tested
|
|
213
|
+
* with a mix of outcomes. Called once per workflow, on the poll that finishes
|
|
214
|
+
* it, with the workflow id and the body it was submitted with (`undefined`
|
|
215
|
+
* for an id this mock did not submit, such as one restored after a reload).
|
|
216
|
+
*
|
|
217
|
+
* `'failed'` and `'expired'` reply `{ workflowId, status, error }` with no
|
|
218
|
+
* `cost` and no images. Default: every workflow `'succeeded'`.
|
|
219
|
+
*/
|
|
220
|
+
workflowOutcome?: (workflowId: string, body: WorkflowBody | undefined) => 'succeeded' | 'failed' | 'expired';
|
|
198
221
|
/**
|
|
199
222
|
* Force every SUBMIT to come back as a caught server EXCEPTION — the host's
|
|
200
223
|
* real `failureSnapshot(err)` shape: `{ workflowId:'failed', status:'failed',
|
|
@@ -1084,6 +1084,9 @@ export function createMockHost(options = {}) {
|
|
|
1084
1084
|
let tokenSerial = 0;
|
|
1085
1085
|
let submitCount = 0;
|
|
1086
1086
|
// body + cost remembered per workflow so the succeeded snapshot can echo them.
|
|
1087
|
+
// `body` is absent for an id this mock never submitted (a workflow id an app
|
|
1088
|
+
// restored after a reload). `canceled` is set by `CANCEL_WORKFLOW`, so later
|
|
1089
|
+
// polls keep answering `canceled` instead of resuming the countdown.
|
|
1087
1090
|
const workflows = new Map();
|
|
1088
1091
|
// Training: datasets this install prepared (id → admitted count) and the
|
|
1089
1092
|
// quotes its estimates stored (id → what a run is checked against).
|
|
@@ -1703,11 +1706,35 @@ export function createMockHost(options = {}) {
|
|
|
1703
1706
|
}
|
|
1704
1707
|
case 'POLL_WORKFLOW': {
|
|
1705
1708
|
const workflowId = typed.payload?.workflowId ?? '';
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
if (
|
|
1709
|
+
// An id this mock did not submit (e.g. restored from storage after a
|
|
1710
|
+
// reload) gets a record on its first poll, so it counts down to done
|
|
1711
|
+
// like any other instead of answering `processing` forever.
|
|
1712
|
+
let wf = workflows.get(workflowId);
|
|
1713
|
+
if (!wf) {
|
|
1714
|
+
wf = { polls: 0, cost: legacyCost };
|
|
1715
|
+
workflows.set(workflowId, wf);
|
|
1716
|
+
}
|
|
1717
|
+
if (wf.canceled) {
|
|
1718
|
+
dispatchToBlock({
|
|
1719
|
+
type: 'WORKFLOW_STATUS',
|
|
1720
|
+
payload: { requestId, snapshot: { workflowId, status: 'canceled' } },
|
|
1721
|
+
});
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
const polls = wf.polls + 1;
|
|
1725
|
+
wf.polls = polls;
|
|
1726
|
+
const outcome = polls >= pollsUntilDone ? (gen.workflowOutcome?.(workflowId, wf.body) ?? 'succeeded') : null;
|
|
1727
|
+
if (outcome === 'failed' || outcome === 'expired') {
|
|
1728
|
+
dispatchToBlock({
|
|
1729
|
+
type: 'WORKFLOW_STATUS',
|
|
1730
|
+
payload: {
|
|
1731
|
+
requestId,
|
|
1732
|
+
snapshot: { workflowId, status: outcome, error: `mock: workflow ${outcome}` },
|
|
1733
|
+
},
|
|
1734
|
+
});
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
if (outcome === 'succeeded') {
|
|
1711
1738
|
// Apply synthetic latency on the terminal (succeeded) poll only.
|
|
1712
1739
|
const delay = latencyFor();
|
|
1713
1740
|
if (delay > 0) {
|
|
@@ -1733,7 +1760,35 @@ export function createMockHost(options = {}) {
|
|
|
1733
1760
|
}
|
|
1734
1761
|
case 'CANCEL_WORKFLOW': {
|
|
1735
1762
|
const workflowId = typed.payload?.workflowId ?? '';
|
|
1736
|
-
|
|
1763
|
+
const refused = gen.cancelRefused;
|
|
1764
|
+
if (refused === true || (Array.isArray(refused) && refused.includes(workflowId))) {
|
|
1765
|
+
// The real host's answer when the estimate allowance is used up: a
|
|
1766
|
+
// NON-terminal snapshot, and the cancel is not sent.
|
|
1767
|
+
dispatchToBlock({
|
|
1768
|
+
type: 'WORKFLOW_CANCELED',
|
|
1769
|
+
payload: { requestId, snapshot: { workflowId, status: 'processing' } },
|
|
1770
|
+
});
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
const wf = workflows.get(workflowId);
|
|
1774
|
+
if (wf && !wf.canceled && wf.polls >= pollsUntilDone) {
|
|
1775
|
+
// Already finished: like the real host, report the real terminal
|
|
1776
|
+
// status rather than pretending the cancel took.
|
|
1777
|
+
const outcome = gen.workflowOutcome?.(workflowId, wf.body) ?? 'succeeded';
|
|
1778
|
+
dispatchToBlock({
|
|
1779
|
+
type: 'WORKFLOW_CANCELED',
|
|
1780
|
+
payload: {
|
|
1781
|
+
requestId,
|
|
1782
|
+
snapshot: outcome === 'succeeded'
|
|
1783
|
+
? succeededSnapshot(workflowId)
|
|
1784
|
+
: { workflowId, status: outcome, error: `mock: workflow ${outcome}` },
|
|
1785
|
+
},
|
|
1786
|
+
});
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
// Keep the record, marked canceled, so later polls answer `canceled`
|
|
1790
|
+
// rather than counting down to `succeeded`.
|
|
1791
|
+
workflows.set(workflowId, { ...(wf ?? { polls: 0, cost: legacyCost }), canceled: true });
|
|
1737
1792
|
dispatchToBlock({
|
|
1738
1793
|
type: 'WORKFLOW_CANCELED',
|
|
1739
1794
|
payload: { requestId, snapshot: { workflowId, status: 'canceled' } },
|
|
@@ -248,6 +248,12 @@ export function isValidWorkflowSnapshot(s) {
|
|
|
248
248
|
}
|
|
249
249
|
if (s.error !== undefined && typeof s.error !== 'string')
|
|
250
250
|
return false;
|
|
251
|
+
// OPTIONAL: set only on the refusals that define a code (today the training
|
|
252
|
+
// price-quote refusal), absent everywhere else. Checked as a STRING, not
|
|
253
|
+
// against today's codes — the open-ended-union rule below: a host adding a
|
|
254
|
+
// code must not turn into a dropped reply in every shipped bundle.
|
|
255
|
+
if (s.errorCode !== undefined && typeof s.errorCode !== 'string')
|
|
256
|
+
return false;
|
|
251
257
|
if (s.autoClaim !== undefined) {
|
|
252
258
|
if (!isObject(s.autoClaim))
|
|
253
259
|
return false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@civitai/blocks-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.69.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.65.0"
|
|
62
62
|
},
|
|
63
63
|
"publishConfig": {
|
|
64
64
|
"access": "public"
|