@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.
@@ -0,0 +1,209 @@
1
+ import type { BlockRunConfirmedStepHostError, BlockStepQuote, BlockWorkflowSnapshot, WorkflowBodyPassThroughStep } from '@civitai/app-sdk/blocks';
2
+ export type { BlockRunConfirmedStepHostError, BlockStepQuote, WorkflowBodyPassThroughStep };
3
+ /**
4
+ * A pass-through step body ready to RUN as a confirmed job:
5
+ * {@link WorkflowBodyPassThroughStep} with the `quoteId` its estimate returned
6
+ * (`snapshot.stepQuote.quoteId`). Required here so a body without one is a
7
+ * compile error rather than a refusal.
8
+ */
9
+ export type QuotedStepBody = WorkflowBodyPassThroughStep & {
10
+ quoteId: string;
11
+ };
12
+ /**
13
+ * The host's CLOSED refusal codes for `RUN_CONFIRMED_STEP`, as a runtime list.
14
+ * Mirrors civitai/civitai's `RUN_CONFIRMED_STEP_HOST_ERRORS`.
15
+ *
16
+ * 🔴 THE ERROR CHANNEL IS NOT AN ENUM: server messages (an expired or used quote,
17
+ * a body or price that differs from the quoted one) arrive in the same field —
18
+ * see {@link isRunConfirmedStepErrorCode}.
19
+ */
20
+ export declare const RUN_CONFIRMED_STEP_ERROR_CODES: readonly ["review-mode", "block is not ready", "sign in to run", "invalid confirmed step request", "no block token", "declined", "daily-limit-exceeded", "submission-unconfirmed"];
21
+ /**
22
+ * The shape of a confirmed-step quote id: `sq_` + 32 lowercase hex characters.
23
+ *
24
+ * A COPY of `@civitai/app-sdk/blocks`'s `BLOCK_STEP_QUOTE_ID_REGEX`, deliberately
25
+ * not value-imported: a value import would raise this package's
26
+ * `@civitai/app-sdk` peer floor for one regular expression (see
27
+ * `PEER_FLOOR.md`). Pinned to the SDK's by `test/confirmedStepFlow.test.tsx`,
28
+ * which imports both.
29
+ */
30
+ export declare const STEP_QUOTE_ID_PATTERN: RegExp;
31
+ /** `true` when `value` is a string shaped like a confirmed-step quote id. */
32
+ export declare function isStepQuoteId(value: unknown): value is string;
33
+ /**
34
+ * The message of the {@link RunConfirmedStepError} `runConfirmedStep` rejects
35
+ * with when the body carries no `sq_`-shaped `quoteId`.
36
+ *
37
+ * 🔴 REFUSED BEFORE THE SEND. The host refuses the same body with `invalid
38
+ * confirmed step request`, so nothing is lost by refusing here — and one thing is
39
+ * gained: a `tq_` training quote, an `undefined` from an estimate that returned
40
+ * no `stepQuote`, or a stale id from storage is named at the call site instead of
41
+ * surfacing as a host refusal. Nothing was sent, so nothing was charged.
42
+ */
43
+ export declare const CONFIRMED_STEP_QUOTE_ID_REFUSAL = "runConfirmedStep() needs the quoteId its estimate returned (snapshot.stepQuote.quoteId, \"sq_\" + 32 hex characters) \u2014 estimate the body with useBuzzWorkflow().estimate() first. Nothing was sent.";
44
+ /**
45
+ * The `code` a {@link RunConfirmedStepError} can carry: one of the host's closed
46
+ * refusal codes, or `'refused'` — assigned by THIS SDK, never sent by the host,
47
+ * for a submit the server refused at a spend cap or as temporarily unavailable
48
+ * (see {@link RunConfirmedStepError.refused}).
49
+ */
50
+ export type RunConfirmedStepErrorCode = BlockRunConfirmedStepHostError | 'refused';
51
+ /** `true` when `error` is one of the host's closed refusal codes rather than a server message. */
52
+ export declare function isRunConfirmedStepErrorCode(error: string): error is BlockRunConfirmedStepHostError;
53
+ /**
54
+ * A confirmed step that did not come back with a snapshot.
55
+ *
56
+ * 🔴 READ THE FLAGS BEFORE SAYING ANYTHING ABOUT MONEY — they are the only part of
57
+ * this error with a money meaning, and they point in opposite directions:
58
+ *
59
+ * - `.declined` — the viewer dismissed the confirm. NO JOB WAS SUBMITTED,
60
+ * guaranteed by the host's consent latch, and the quote is still runnable
61
+ * until it expires. Render nothing.
62
+ * - `.dailyLimitExceeded` — the job would exceed what remains today of the
63
+ * daily Buzz limit the VIEWER set for this app. NO JOB, nothing charged. The
64
+ * host has already explained it to the viewer and pointed at where the limit
65
+ * is raised; a confirm cannot override it. Do not retry, and do not offer a
66
+ * Buzz purchase — more Buzz does not lift a limit.
67
+ * - `.unconfirmed` — the job MAY BE RUNNING AND CHARGED: the host sent the
68
+ * submit and could not learn its outcome (`submission-unconfirmed`), or no
69
+ * reply arrived at all (`.timedOut` — the viewer may have confirmed a dialog
70
+ * this block had stopped waiting for). 🔴 NEVER RETRY AUTOMATICALLY and do not
71
+ * tell the viewer it failed. Every confirmation is its own job, so a retry —
72
+ * which needs a new estimate and a new confirmation — is a SECOND, separately
73
+ * charged job if the first one did start. Check the viewer's generations
74
+ * first (`useAppWorkflows()` lists this app's runs).
75
+ * - `.refused` (`code: 'refused'`) — the server REFUSED the submit at a spend
76
+ * cap after the viewer confirmed (the viewer's daily or private-run Buzz cap,
77
+ * the app's daily spend or rate limit). Its reservation was refunded: NO JOB,
78
+ * nothing charged. `.message` is the server's reason and `.snapshot` the
79
+ * refusal it arrived in. Retrying needs a new estimate and usually a later
80
+ * time.
81
+ * - `.signInRequired` — no session. Route into `useRequestSignIn()`.
82
+ * - anything else — a refusal before any submit (an expired or used quote, a
83
+ * body that no longer matches its quote, a price that changed, or the
84
+ * client-side {@link CONFIRMED_STEP_QUOTE_ID_REFUSAL}). Estimate again before
85
+ * retrying; `.message` is the host's or server's text.
86
+ */
87
+ export declare class RunConfirmedStepError extends Error {
88
+ /**
89
+ * The closed host refusal code, `'refused'` for a cap refusal, or `undefined`
90
+ * for a server/transport error.
91
+ */
92
+ readonly code?: RunConfirmedStepErrorCode;
93
+ /**
94
+ * The server refused the submit (a spend cap, or a temporary-availability deny)
95
+ * and refunded it — no job exists. The quote is used up: estimate again.
96
+ * See the class docs; `.message` is the server's reason.
97
+ */
98
+ readonly refused: boolean;
99
+ /** The refusal snapshot the host replied with, when {@link refused}. */
100
+ readonly snapshot?: BlockWorkflowSnapshot;
101
+ /** The viewer dismissed the confirm — no job was submitted. */
102
+ readonly declined: boolean;
103
+ /**
104
+ * The job would exceed the viewer's own daily Buzz limit for this app — no job
105
+ * was submitted and nothing was charged. Set for the host code
106
+ * `daily-limit-exceeded` and for a refusal snapshot whose `errorCode` is that
107
+ * value.
108
+ */
109
+ readonly dailyLimitExceeded: boolean;
110
+ /**
111
+ * The job may exist and may have been charged — `submission-unconfirmed`, or a
112
+ * transport timeout. Check the viewer's generations before retrying.
113
+ */
114
+ readonly unconfirmed: boolean;
115
+ /** No reply arrived within the 10-minute consent bound. Implies {@link unconfirmed}. */
116
+ readonly timedOut: boolean;
117
+ /** There is no session (`sign in to run`, or a bare `UNAUTHORIZED`). */
118
+ readonly signInRequired: boolean;
119
+ constructor(error: string, opts?: {
120
+ timedOut?: boolean;
121
+ refusedSnapshot?: BlockWorkflowSnapshot;
122
+ code?: BlockRunConfirmedStepHostError;
123
+ });
124
+ }
125
+ /** What {@link useRunConfirmedStep} returns. */
126
+ export interface UseRunConfirmedStep {
127
+ /**
128
+ * Ask the host to run a quoted pass-through step. Civitai shows the viewer a
129
+ * confirm with the server's price; on their click it submits, and this
130
+ * resolves with the submitted workflow's snapshot — poll it with
131
+ * `useBuzzWorkflow().watch(snapshot.workflowId)`.
132
+ *
133
+ * REJECTS with a {@link RunConfirmedStepError} — including `declined` and
134
+ * `dailyLimitExceeded` (no job), `refused` (a spend-cap refusal; no job) and
135
+ * `unconfirmed` (a job may exist). Read the flags before rendering anything. A
136
+ * resolved snapshot is always a submitted job with a pollable `workflowId`.
137
+ *
138
+ * A body whose `quoteId` is missing or not `sq_`-shaped is refused HERE,
139
+ * before anything is sent — see {@link CONFIRMED_STEP_QUOTE_ID_REFUSAL}.
140
+ */
141
+ runConfirmedStep: (body: QuotedStepBody) => Promise<BlockWorkflowSnapshot>;
142
+ /** `true` while a request is in flight (including the viewer's dialog). */
143
+ pending: boolean;
144
+ /** The last request's failure, or `null`. Cleared at the start of the next call. */
145
+ error: RunConfirmedStepError | null;
146
+ }
147
+ /**
148
+ * Run ONE pass-through `kind: 'step'` job whose price is above what the app may
149
+ * spend without asking — through the host-mediated `RUN_CONFIRMED_STEP` →
150
+ * `CONFIRMED_STEP_RESULT` bridge, after the viewer confirms the exact price in
151
+ * Civitai's own dialog.
152
+ *
153
+ * 🔴 AVAILABILITY: page apps only, behind the host flag
154
+ * `app-blocks-confirmed-step` (ships off), with `ai:write:budgeted` declared and
155
+ * granted by a signed-in viewer, on a host carrying civitai/civitai#5786. Refused
156
+ * from `dev:live` and from review sessions — build the flow against the mock
157
+ * host. Where it is unavailable the estimate simply carries no `stepQuote`.
158
+ *
159
+ * The flow: `useBuzzWorkflow().estimate(body)` (its `stepQuote.quoteId`) →
160
+ * `runConfirmedStep({ ...body, quoteId })` → `useBuzzWorkflow().watch(workflowId)`.
161
+ * Change nothing in the body between the estimate and the run: the server checks
162
+ * it against the quoted one.
163
+ *
164
+ * 🔴 ONLY A FIXED PRICE IS CONFIRMABLE. `stepQuote` is present when the
165
+ * orchestrator quoted a fixed price above the token's per-call budget and at
166
+ * most `BLOCK_CONFIRMED_STEP_MAX_BUZZ_PER_JOB` (5,000 Buzz). A variable or
167
+ * metered price, or no price at all, gets no quote and cannot be run this way.
168
+ *
169
+ * 🔴 ESTIMATE THE BODY ON ITS OWN. A batch estimate (`useBatchEstimate()`) never
170
+ * carries a `stepQuote`. And where a quote would be granted, the estimate
171
+ * REJECTS instead for a training `$type` (`training` / `imageResourceTraining`,
172
+ * any casing — use `kind: 'training'`) and for an `input` that is not plain JSON
173
+ * (a `Date`, `Map`, `Set`, `BigInt`, `NaN`, an empty slot in a sparse array, …). Both are server messages with no code; see
174
+ * `BlockWorkflowSnapshot.stepQuote` for the exact text.
175
+ *
176
+ * 🔴 THE CONFIRM IS CIVITAI'S, AND IT IS THE CHARGE. The price on it is read back
177
+ * from the server's stored quote — never from your body. A quote is single-use
178
+ * and expires 15 minutes after the estimate; the server prices the job again at
179
+ * submit and refuses a higher price.
180
+ *
181
+ * 🔴 NO AUTOMATIC RETRY OF ANY KIND, on purpose. Every confirmation is its own
182
+ * job — there is no idempotency key, the orchestrator id comes from the confirmed
183
+ * quote — and one outcome (`submission-unconfirmed`) means a job may already be
184
+ * running. So this hook never re-sends, and neither should you: a retry is a
185
+ * NEW, separately charged job.
186
+ *
187
+ * Sent under the 10-minute human-interaction bound: the host replies only when
188
+ * the viewer clicks or dismisses its dialog.
189
+ *
190
+ * @example
191
+ * const { estimate, watch } = useBuzzWorkflow();
192
+ * const { runConfirmedStep } = useRunConfirmedStep();
193
+ * const quote = (await estimate(body)).stepQuote;
194
+ * if (!quote) return submit(body); // within budget — the ordinary path
195
+ * try {
196
+ * const started = await runConfirmedStep({ ...body, quoteId: quote.quoteId });
197
+ * await watch(started.workflowId, { onUpdate: render });
198
+ * } catch (e) {
199
+ * if (!(e instanceof RunConfirmedStepError)) throw e;
200
+ * if (e.declined) return; // no job — say nothing
201
+ * if (e.dailyLimitExceeded) return; // no job — Civitai already told the viewer
202
+ * if (e.signInRequired) return requestSignIn();
203
+ * if (e.unconfirmed) return showCheckYourGenerations(); // may be running — never auto-retry
204
+ * if (e.refused) return showError(e.message); // a cap refusal; no job, nothing charged
205
+ * showError('Could not start this job. Get a new price and try again.');
206
+ * }
207
+ */
208
+ export declare function useRunConfirmedStep(): UseRunConfirmedStep;
209
+ //# sourceMappingURL=useRunConfirmedStep.d.ts.map
@@ -0,0 +1,287 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
3
+ import { getTransport } from '../transport/singleton.js';
4
+ import { RequestTimeoutError, sendTypedRequest } from '../transport/transport.js';
5
+ /**
6
+ * The host's CLOSED refusal codes for `RUN_CONFIRMED_STEP`, as a runtime list.
7
+ * Mirrors civitai/civitai's `RUN_CONFIRMED_STEP_HOST_ERRORS`.
8
+ *
9
+ * 🔴 THE ERROR CHANNEL IS NOT AN ENUM: server messages (an expired or used quote,
10
+ * a body or price that differs from the quoted one) arrive in the same field —
11
+ * see {@link isRunConfirmedStepErrorCode}.
12
+ */
13
+ export const RUN_CONFIRMED_STEP_ERROR_CODES = [
14
+ 'review-mode',
15
+ 'block is not ready',
16
+ 'sign in to run',
17
+ 'invalid confirmed step request',
18
+ 'no block token',
19
+ 'declined',
20
+ 'daily-limit-exceeded',
21
+ 'submission-unconfirmed',
22
+ ];
23
+ const CODE_SET = new Set(RUN_CONFIRMED_STEP_ERROR_CODES);
24
+ /**
25
+ * The shape of a confirmed-step quote id: `sq_` + 32 lowercase hex characters.
26
+ *
27
+ * A COPY of `@civitai/app-sdk/blocks`'s `BLOCK_STEP_QUOTE_ID_REGEX`, deliberately
28
+ * not value-imported: a value import would raise this package's
29
+ * `@civitai/app-sdk` peer floor for one regular expression (see
30
+ * `PEER_FLOOR.md`). Pinned to the SDK's by `test/confirmedStepFlow.test.tsx`,
31
+ * which imports both.
32
+ */
33
+ export const STEP_QUOTE_ID_PATTERN = /^sq_[a-f0-9]{32}$/;
34
+ /** `true` when `value` is a string shaped like a confirmed-step quote id. */
35
+ export function isStepQuoteId(value) {
36
+ return typeof value === 'string' && STEP_QUOTE_ID_PATTERN.test(value);
37
+ }
38
+ /**
39
+ * The message of the {@link RunConfirmedStepError} `runConfirmedStep` rejects
40
+ * with when the body carries no `sq_`-shaped `quoteId`.
41
+ *
42
+ * 🔴 REFUSED BEFORE THE SEND. The host refuses the same body with `invalid
43
+ * confirmed step request`, so nothing is lost by refusing here — and one thing is
44
+ * gained: a `tq_` training quote, an `undefined` from an estimate that returned
45
+ * no `stepQuote`, or a stale id from storage is named at the call site instead of
46
+ * surfacing as a host refusal. Nothing was sent, so nothing was charged.
47
+ */
48
+ export const CONFIRMED_STEP_QUOTE_ID_REFUSAL = 'runConfirmedStep() needs the quoteId its estimate returned (snapshot.stepQuote.quoteId, "sq_" + 32 hex characters) — estimate the body with useBuzzWorkflow().estimate() first. Nothing was sent.';
49
+ /**
50
+ * The `workflowId` the host's confirmed submit stamps on a snapshot that is NOT
51
+ * a run — its cap refusals resolve `{ workflowId: 'failed', status: 'failed',
52
+ * cost, error }`, the same shape the training submit uses. Compared with `===`:
53
+ * a real orchestrator id is a workflow that may exist.
54
+ */
55
+ const HOST_REFUSAL_WORKFLOW_ID = 'failed';
56
+ /** The viewer's own daily-limit refusal, as a host code and as a snapshot `errorCode`. */
57
+ const DAILY_LIMIT_CODE = 'daily-limit-exceeded';
58
+ /** `true` when `error` is one of the host's closed refusal codes rather than a server message. */
59
+ export function isRunConfirmedStepErrorCode(error) {
60
+ return CODE_SET.has(error);
61
+ }
62
+ /**
63
+ * A confirmed step that did not come back with a snapshot.
64
+ *
65
+ * 🔴 READ THE FLAGS BEFORE SAYING ANYTHING ABOUT MONEY — they are the only part of
66
+ * this error with a money meaning, and they point in opposite directions:
67
+ *
68
+ * - `.declined` — the viewer dismissed the confirm. NO JOB WAS SUBMITTED,
69
+ * guaranteed by the host's consent latch, and the quote is still runnable
70
+ * until it expires. Render nothing.
71
+ * - `.dailyLimitExceeded` — the job would exceed what remains today of the
72
+ * daily Buzz limit the VIEWER set for this app. NO JOB, nothing charged. The
73
+ * host has already explained it to the viewer and pointed at where the limit
74
+ * is raised; a confirm cannot override it. Do not retry, and do not offer a
75
+ * Buzz purchase — more Buzz does not lift a limit.
76
+ * - `.unconfirmed` — the job MAY BE RUNNING AND CHARGED: the host sent the
77
+ * submit and could not learn its outcome (`submission-unconfirmed`), or no
78
+ * reply arrived at all (`.timedOut` — the viewer may have confirmed a dialog
79
+ * this block had stopped waiting for). 🔴 NEVER RETRY AUTOMATICALLY and do not
80
+ * tell the viewer it failed. Every confirmation is its own job, so a retry —
81
+ * which needs a new estimate and a new confirmation — is a SECOND, separately
82
+ * charged job if the first one did start. Check the viewer's generations
83
+ * first (`useAppWorkflows()` lists this app's runs).
84
+ * - `.refused` (`code: 'refused'`) — the server REFUSED the submit at a spend
85
+ * cap after the viewer confirmed (the viewer's daily or private-run Buzz cap,
86
+ * the app's daily spend or rate limit). Its reservation was refunded: NO JOB,
87
+ * nothing charged. `.message` is the server's reason and `.snapshot` the
88
+ * refusal it arrived in. Retrying needs a new estimate and usually a later
89
+ * time.
90
+ * - `.signInRequired` — no session. Route into `useRequestSignIn()`.
91
+ * - anything else — a refusal before any submit (an expired or used quote, a
92
+ * body that no longer matches its quote, a price that changed, or the
93
+ * client-side {@link CONFIRMED_STEP_QUOTE_ID_REFUSAL}). Estimate again before
94
+ * retrying; `.message` is the host's or server's text.
95
+ */
96
+ export class RunConfirmedStepError extends Error {
97
+ /**
98
+ * The closed host refusal code, `'refused'` for a cap refusal, or `undefined`
99
+ * for a server/transport error.
100
+ */
101
+ code;
102
+ /**
103
+ * The server refused the submit (a spend cap, or a temporary-availability deny)
104
+ * and refunded it — no job exists. The quote is used up: estimate again.
105
+ * See the class docs; `.message` is the server's reason.
106
+ */
107
+ refused;
108
+ /** The refusal snapshot the host replied with, when {@link refused}. */
109
+ snapshot;
110
+ /** The viewer dismissed the confirm — no job was submitted. */
111
+ declined;
112
+ /**
113
+ * The job would exceed the viewer's own daily Buzz limit for this app — no job
114
+ * was submitted and nothing was charged. Set for the host code
115
+ * `daily-limit-exceeded` and for a refusal snapshot whose `errorCode` is that
116
+ * value.
117
+ */
118
+ dailyLimitExceeded;
119
+ /**
120
+ * The job may exist and may have been charged — `submission-unconfirmed`, or a
121
+ * transport timeout. Check the viewer's generations before retrying.
122
+ */
123
+ unconfirmed;
124
+ /** No reply arrived within the 10-minute consent bound. Implies {@link unconfirmed}. */
125
+ timedOut;
126
+ /** There is no session (`sign in to run`, or a bare `UNAUTHORIZED`). */
127
+ signInRequired;
128
+ constructor(error, opts) {
129
+ super(error);
130
+ this.name = 'RunConfirmedStepError';
131
+ this.timedOut = opts?.timedOut === true;
132
+ this.refused = opts?.refusedSnapshot !== undefined;
133
+ if (opts?.refusedSnapshot !== undefined) {
134
+ this.code = 'refused';
135
+ this.snapshot = opts.refusedSnapshot;
136
+ }
137
+ else if (opts?.code !== undefined) {
138
+ this.code = opts.code;
139
+ }
140
+ else if (isRunConfirmedStepErrorCode(error)) {
141
+ this.code = error;
142
+ }
143
+ this.declined = error === 'declined';
144
+ this.dailyLimitExceeded =
145
+ error === DAILY_LIMIT_CODE || opts?.refusedSnapshot?.errorCode === DAILY_LIMIT_CODE;
146
+ this.unconfirmed = this.timedOut || error === 'submission-unconfirmed';
147
+ this.signInRequired = error === 'sign in to run' || error === 'UNAUTHORIZED';
148
+ }
149
+ }
150
+ /**
151
+ * Run ONE pass-through `kind: 'step'` job whose price is above what the app may
152
+ * spend without asking — through the host-mediated `RUN_CONFIRMED_STEP` →
153
+ * `CONFIRMED_STEP_RESULT` bridge, after the viewer confirms the exact price in
154
+ * Civitai's own dialog.
155
+ *
156
+ * 🔴 AVAILABILITY: page apps only, behind the host flag
157
+ * `app-blocks-confirmed-step` (ships off), with `ai:write:budgeted` declared and
158
+ * granted by a signed-in viewer, on a host carrying civitai/civitai#5786. Refused
159
+ * from `dev:live` and from review sessions — build the flow against the mock
160
+ * host. Where it is unavailable the estimate simply carries no `stepQuote`.
161
+ *
162
+ * The flow: `useBuzzWorkflow().estimate(body)` (its `stepQuote.quoteId`) →
163
+ * `runConfirmedStep({ ...body, quoteId })` → `useBuzzWorkflow().watch(workflowId)`.
164
+ * Change nothing in the body between the estimate and the run: the server checks
165
+ * it against the quoted one.
166
+ *
167
+ * 🔴 ONLY A FIXED PRICE IS CONFIRMABLE. `stepQuote` is present when the
168
+ * orchestrator quoted a fixed price above the token's per-call budget and at
169
+ * most `BLOCK_CONFIRMED_STEP_MAX_BUZZ_PER_JOB` (5,000 Buzz). A variable or
170
+ * metered price, or no price at all, gets no quote and cannot be run this way.
171
+ *
172
+ * 🔴 ESTIMATE THE BODY ON ITS OWN. A batch estimate (`useBatchEstimate()`) never
173
+ * carries a `stepQuote`. And where a quote would be granted, the estimate
174
+ * REJECTS instead for a training `$type` (`training` / `imageResourceTraining`,
175
+ * any casing — use `kind: 'training'`) and for an `input` that is not plain JSON
176
+ * (a `Date`, `Map`, `Set`, `BigInt`, `NaN`, an empty slot in a sparse array, …). Both are server messages with no code; see
177
+ * `BlockWorkflowSnapshot.stepQuote` for the exact text.
178
+ *
179
+ * 🔴 THE CONFIRM IS CIVITAI'S, AND IT IS THE CHARGE. The price on it is read back
180
+ * from the server's stored quote — never from your body. A quote is single-use
181
+ * and expires 15 minutes after the estimate; the server prices the job again at
182
+ * submit and refuses a higher price.
183
+ *
184
+ * 🔴 NO AUTOMATIC RETRY OF ANY KIND, on purpose. Every confirmation is its own
185
+ * job — there is no idempotency key, the orchestrator id comes from the confirmed
186
+ * quote — and one outcome (`submission-unconfirmed`) means a job may already be
187
+ * running. So this hook never re-sends, and neither should you: a retry is a
188
+ * NEW, separately charged job.
189
+ *
190
+ * Sent under the 10-minute human-interaction bound: the host replies only when
191
+ * the viewer clicks or dismisses its dialog.
192
+ *
193
+ * @example
194
+ * const { estimate, watch } = useBuzzWorkflow();
195
+ * const { runConfirmedStep } = useRunConfirmedStep();
196
+ * const quote = (await estimate(body)).stepQuote;
197
+ * if (!quote) return submit(body); // within budget — the ordinary path
198
+ * try {
199
+ * const started = await runConfirmedStep({ ...body, quoteId: quote.quoteId });
200
+ * await watch(started.workflowId, { onUpdate: render });
201
+ * } catch (e) {
202
+ * if (!(e instanceof RunConfirmedStepError)) throw e;
203
+ * if (e.declined) return; // no job — say nothing
204
+ * if (e.dailyLimitExceeded) return; // no job — Civitai already told the viewer
205
+ * if (e.signInRequired) return requestSignIn();
206
+ * if (e.unconfirmed) return showCheckYourGenerations(); // may be running — never auto-retry
207
+ * if (e.refused) return showError(e.message); // a cap refusal; no job, nothing charged
208
+ * showError('Could not start this job. Get a new price and try again.');
209
+ * }
210
+ */
211
+ export function useRunConfirmedStep() {
212
+ const [pending, setPending] = useState(false);
213
+ const [error, setError] = useState(null);
214
+ // This request can outlive the component by up to ten minutes (the dialog may
215
+ // stay open), so a `pending` toggled on an unmounted control is state nobody
216
+ // can clear.
217
+ const mountedRef = useRef(true);
218
+ useEffect(() => {
219
+ mountedRef.current = true;
220
+ return () => {
221
+ mountedRef.current = false;
222
+ };
223
+ }, []);
224
+ const runConfirmedStep = useCallback(async (body) => {
225
+ // See `CONFIRMED_STEP_QUOTE_ID_REFUSAL`. Before the `pending` flip and the
226
+ // send: nothing about this call may reach the wire. `body` is read through
227
+ // `unknown` because a JavaScript caller, or one that cast its way past the
228
+ // type, is exactly who this is for.
229
+ if (!isStepQuoteId(body?.quoteId)) {
230
+ const refused = new RunConfirmedStepError(CONFIRMED_STEP_QUOTE_ID_REFUSAL, {
231
+ code: 'invalid confirmed step request',
232
+ });
233
+ if (mountedRef.current)
234
+ setError(refused);
235
+ throw refused;
236
+ }
237
+ if (mountedRef.current) {
238
+ setPending(true);
239
+ setError(null);
240
+ }
241
+ try {
242
+ const reply = await sendTypedRequest(getTransport(), { type: 'RUN_CONFIRMED_STEP', payload: { body } }, 'CONFIRMED_STEP_RESULT', { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS }).catch((err) => {
243
+ if (err instanceof RequestTimeoutError) {
244
+ throw new RunConfirmedStepError(err.message, { timedOut: true });
245
+ }
246
+ throw err instanceof Error ? err : new Error(String(err));
247
+ });
248
+ if (reply.error || !reply.snapshot) {
249
+ // `||`, not `??`: `error: ''` is a shape-valid reply. Its fallback is the
250
+ // CAUTIOUS code — with neither a snapshot nor a reason, nothing says a
251
+ // job was not started, so the block must not be told it was safe to
252
+ // retry.
253
+ throw new RunConfirmedStepError(reply.error || 'submission-unconfirmed');
254
+ }
255
+ // 🔴 A RESOLVED SNAPSHOT IS NOT ALWAYS A RUN. The host's confirmed submit
256
+ // answers its spend-cap refusals (refunded, nothing started) with a
257
+ // resolved `{ workflowId: 'failed', status: 'failed', cost, error }`.
258
+ // Resolving that would hand the caller a workflow id to `watch()` that
259
+ // names nothing, and drop the reason. Exact match on the host's
260
+ // sentinel: a failed snapshot with a REAL id is a workflow that may
261
+ // exist, and keeps resolving so it can be watched.
262
+ if (reply.snapshot.workflowId === HOST_REFUSAL_WORKFLOW_ID &&
263
+ reply.snapshot.status === 'failed') {
264
+ throw new RunConfirmedStepError(reply.snapshot.error || 'confirmed step refused', {
265
+ refusedSnapshot: reply.snapshot,
266
+ });
267
+ }
268
+ return reply.snapshot;
269
+ }
270
+ catch (err) {
271
+ const wrapped = err instanceof RunConfirmedStepError
272
+ ? err
273
+ : new RunConfirmedStepError(err instanceof Error ? err.message : String(err));
274
+ if (mountedRef.current)
275
+ setError(wrapped);
276
+ // Thrown even when unmounted: the caller's `await` must still learn the
277
+ // outcome of a possibly-charged job.
278
+ throw wrapped;
279
+ }
280
+ finally {
281
+ if (mountedRef.current)
282
+ setPending(false);
283
+ }
284
+ }, []);
285
+ return { runConfirmedStep, pending, error };
286
+ }
287
+ //# sourceMappingURL=useRunConfirmedStep.js.map
package/dist/index.d.ts CHANGED
@@ -75,6 +75,8 @@ export { usePrepareTrainingDataset, PrepareTrainingDatasetError, PREPARE_TRAININ
75
75
  export type { UsePrepareTrainingDataset, BlockPrepareTrainingDatasetHostError, BlockTrainingDatasetItem, BlockTrainingDatasetResult, BlockTrainingRejectionReason, } from './hooks/usePrepareTrainingDataset.js';
76
76
  export { useRunTraining, RunTrainingError, RUN_TRAINING_ERROR_CODES, isRunTrainingErrorCode, } from './hooks/useRunTraining.js';
77
77
  export type { UseRunTraining, QuotedTrainingBody, RunTrainingErrorCode, BlockRunTrainingHostError, BlockTrainingQuote, WorkflowBodyTraining, } from './hooks/useRunTraining.js';
78
+ export { useRunConfirmedStep, RunConfirmedStepError, RUN_CONFIRMED_STEP_ERROR_CODES, isRunConfirmedStepErrorCode, isStepQuoteId, } from './hooks/useRunConfirmedStep.js';
79
+ export type { UseRunConfirmedStep, QuotedStepBody, RunConfirmedStepErrorCode, BlockRunConfirmedStepHostError, BlockStepQuote, WorkflowBodyPassThroughStep, } from './hooks/useRunConfirmedStep.js';
78
80
  export type { BlockBuzzTransaction, BlockBuzzAccount, BlockDailyCompensationResource, BlockWildcardPack, BlockWildcardPackErrorCode, AppWorkflow, AppWorkflowImage, AppWorkflowsParams, BlockGatedImage, BlockCollectionFollowErrorCode, BlockCollectionFollowResult, } from '@civitai/app-sdk/blocks';
79
81
  export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';
80
82
  export type { UseCheckpointPicker } from './hooks/useCheckpointPicker.js';
package/dist/index.js CHANGED
@@ -63,6 +63,7 @@ export { useCollectionFollow, CollectionFollowError, COLLECTION_FOLLOW_ERROR_COD
63
63
  export { useCreatePostFromApp, CreatePostError, CREATE_POST_ERROR_CODES, isCreatePostErrorCode, } from './hooks/useCreatePostFromApp.js';
64
64
  export { usePrepareTrainingDataset, PrepareTrainingDatasetError, PREPARE_TRAINING_DATASET_ERROR_CODES, isPrepareTrainingDatasetErrorCode, } from './hooks/usePrepareTrainingDataset.js';
65
65
  export { useRunTraining, RunTrainingError, RUN_TRAINING_ERROR_CODES, isRunTrainingErrorCode, } from './hooks/useRunTraining.js';
66
+ export { useRunConfirmedStep, RunConfirmedStepError, RUN_CONFIRMED_STEP_ERROR_CODES, isRunConfirmedStepErrorCode, isStepQuoteId, } from './hooks/useRunConfirmedStep.js';
66
67
  export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';
67
68
  export { useResourcePicker, RESOURCE_PICKER_MULTIPLE_MAX } from './hooks/useResourcePicker.js';
68
69
  export { useImageUpload } from './hooks/useImageUpload.js';
@@ -0,0 +1,37 @@
1
+ /**
2
+ * hostSurface.ts — which production host a dev host stands in for.
3
+ *
4
+ * civitai.com has two hosts. A full-page app (`app.page`) is mounted by the page
5
+ * host; a block in a `model.*` slot is mounted by the model-slot host. They
6
+ * answer a few messages differently, so a dev host has to know which of the two
7
+ * it is imitating. Both dev hosts (`createMockHost`, `createLiveHost`) read it
8
+ * from the one place they already model it: the `BLOCK_INIT` context.
9
+ */
10
+ import type { BlockContext } from '@civitai/app-sdk/blocks';
11
+ /** The slot id of a full-page app. */
12
+ export declare const PAGE_SLOT_ID = "app.page";
13
+ /**
14
+ * Is this dev host standing in for the PAGE host?
15
+ *
16
+ * `context` is the `options.context` the harness passed. `undefined` is a page:
17
+ * both dev hosts default to a complete page context when none is given.
18
+ *
19
+ * Keyed on the slot id alone, not on `isPageSlotContext`. Production picks the
20
+ * host by the slot the app is mounted in, so an incomplete `{ slotId:
21
+ * 'app.page' }` context is still a page.
22
+ */
23
+ export declare function emulatesPageHost(context: BlockContext | undefined): boolean;
24
+ /**
25
+ * The `error` the page host answers `SET_USER_CHECKPOINT` with, verbatim.
26
+ *
27
+ * A page token is bound to no model, and the server writes a checkpoint override
28
+ * into the settings row of a model-bound install. The page host therefore
29
+ * refuses every such request with `USER_CHECKPOINT_SET { requestId, ok: false,
30
+ * error }`, whatever `versionId` was sent, and makes no server call.
31
+ *
32
+ * Read from `civitai/civitai` `main` at `c1fffa4d1f`
33
+ * (`src/components/AppBlocks/PageBlockHost.tsx`). It is host-authored text: show
34
+ * it or log it, do not branch on it.
35
+ */
36
+ export declare const PAGE_CHECKPOINT_PERSIST_ERROR = "page blocks cannot persist a checkpoint override (no model binding)";
37
+ //# sourceMappingURL=hostSurface.d.ts.map
@@ -0,0 +1,38 @@
1
+ /**
2
+ * hostSurface.ts — which production host a dev host stands in for.
3
+ *
4
+ * civitai.com has two hosts. A full-page app (`app.page`) is mounted by the page
5
+ * host; a block in a `model.*` slot is mounted by the model-slot host. They
6
+ * answer a few messages differently, so a dev host has to know which of the two
7
+ * it is imitating. Both dev hosts (`createMockHost`, `createLiveHost`) read it
8
+ * from the one place they already model it: the `BLOCK_INIT` context.
9
+ */
10
+ /** The slot id of a full-page app. */
11
+ export const PAGE_SLOT_ID = 'app.page';
12
+ /**
13
+ * Is this dev host standing in for the PAGE host?
14
+ *
15
+ * `context` is the `options.context` the harness passed. `undefined` is a page:
16
+ * both dev hosts default to a complete page context when none is given.
17
+ *
18
+ * Keyed on the slot id alone, not on `isPageSlotContext`. Production picks the
19
+ * host by the slot the app is mounted in, so an incomplete `{ slotId:
20
+ * 'app.page' }` context is still a page.
21
+ */
22
+ export function emulatesPageHost(context) {
23
+ return (context?.slotId ?? PAGE_SLOT_ID) === PAGE_SLOT_ID;
24
+ }
25
+ /**
26
+ * The `error` the page host answers `SET_USER_CHECKPOINT` with, verbatim.
27
+ *
28
+ * A page token is bound to no model, and the server writes a checkpoint override
29
+ * into the settings row of a model-bound install. The page host therefore
30
+ * refuses every such request with `USER_CHECKPOINT_SET { requestId, ok: false,
31
+ * error }`, whatever `versionId` was sent, and makes no server call.
32
+ *
33
+ * Read from `civitai/civitai` `main` at `c1fffa4d1f`
34
+ * (`src/components/AppBlocks/PageBlockHost.tsx`). It is host-authored text: show
35
+ * it or log it, do not branch on it.
36
+ */
37
+ export const PAGE_CHECKPOINT_PERSIST_ERROR = 'page blocks cannot persist a checkpoint override (no model binding)';
38
+ //# sourceMappingURL=hostSurface.js.map
@@ -47,7 +47,7 @@
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
49
  * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 48, of which 10 REFUSE (enumerated under SCOPE below)
50
+ * switch case labels .... 49, of which 11 REFUSE (enumerated under SCOPE below)
51
51
  * --- END DERIVED ---
52
52
  *
53
53
  * PICKERS (Phase 1 of "make dev:live a faithful local host"): the live host
@@ -70,11 +70,12 @@
70
70
  * and row budgets enforced per (app, user) — with the REAL ceilings, so a
71
71
  * write this host accepts is one production would accept.
72
72
  *
73
- * SET_USER_CHECKPOINT (Phase 4): FORWARDED (faithful) to the block-token
74
- * `blocks.updateUserSettings` mutation — never fabricated. The default page
75
- * setup mints a PAGE token which lacks `ctx.modelId`, so the backend returns the
76
- * honest error "block token lacks modelId context"; that real outcome is
77
- * surfaced to the block. With a model-slot token it persists for real.
73
+ * SET_USER_CHECKPOINT (Phase 4): answered as the production host for the slot
74
+ * would. On a PAGE context (the default; dev-token mints page tokens) it is
75
+ * refused locally with the page host's own reply — `ok: false` and the same
76
+ * error text — and no backend call, because the page host makes none. On a
77
+ * model-slot context it is FORWARDED to the block-token
78
+ * `blocks.updateUserSettings` mutation, never fabricated.
78
79
  *
79
80
  * SHARED STORAGE (#386): the live host SERVES all TEN `SHARED_*` bridges —
80
81
  * `apps.shared.{list,get,getCount,getCounts,append,update,vote,unvote,withdraw,
@@ -113,6 +114,10 @@
113
114
  * • RUN_TRAINING — the same dev-token refusal, plus the host-chrome consent
114
115
  * dialog a run is confirmed in, which this harness does not have. Replies
115
116
  * with a refusal.
117
+ * • RUN_CONFIRMED_STEP — the server refuses a confirmed step from a dev token
118
+ * and the estimate never returns a `stepQuote` for one, and the price is
119
+ * confirmed in host chrome this harness does not have. Replies with a
120
+ * refusal.
116
121
  * • PUBLISH_GENERATION_OUTPUTS — publishing requires the viewer's signed-in
117
122
  * civitai.com session, which the local harness does not have; test
118
123
  * publishing against the mock host — `createMockHost` or `Harness` from
@@ -217,6 +222,10 @@ export interface LiveHostOptions {
217
222
  * string so the two cannot drift.
218
223
  */
219
224
  export declare const TRAINING_UNSUPPORTED_ERROR = "training is not supported in dev:live \u2014 use dev:mock";
225
+ /**
226
+ * The reply `RUN_CONFIRMED_STEP` gets in dev:live — see its handler.
227
+ */
228
+ export declare const CONFIRMED_STEP_UNSUPPORTED_ERROR = "confirmed steps are not supported in dev:live \u2014 use dev:mock";
220
229
  /**
221
230
  * Decoded payload of a block-token JWT (the claims `BlockTokenService.sign`
222
231
  * stamps — see civitai `block-token.service.ts`). Everything is optional/unknown