Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,11 +382,15 @@ player's first admin-only UI (Bridge decision 9) and its one Bridge kind, `sidec
description), `has {community, local, community_unknown?}` (each `{characters, recaps,
description?}`), `earlier` (up to 10 earlier series books without recaps, each costed),
`open_job`: the book's open generate job, else (Phase 1d) the open job of another kind that
refuses one now, a queued or working `chapters.detect`; its `kind` says which). `client.ts`:
refuses one now, a queued or working `chapters.detect` or (Phase 2b) `sidecars.contribute`;
its `kind` says which). `client.ts`:
`bridgeJobs(lib, path)` (`GET /admin/bridge/jobs`, first page), `queueBridgeJob` (201 new, 200
the open one already queued), `cancelBridgeJob`, `bridgeEstimate` (the upstream budget,
`UPSTREAM_TIMEOUT_MS`); refusals `library_not_writable`, `kind_unavailable` and (Phase 1d)
`detect_open` are `API_ERROR_CODES` (`bridgeRefusalKey` words them; never `ApiError.message`).
`UPSTREAM_TIMEOUT_MS`); refusals `library_not_writable`, `kind_unavailable`, (Phase 1d)
`detect_open` and (Phase 2b) `contribute_open` are `API_ERROR_CODES` (`bridgeRefusalKey`
words them, its `REFUSAL_KEY` checked against `ApiErrorCode`; never `ApiError.message`; an
open-job refusal for an earlier book of the series, queued first, reads as the plain
failure, never as this book's).
- **Hooks** (`hooks.ts`, `qk.bridgeJobs` / `qk.bridgeEstimate`): `useBridgeJobs(lib, path,
{enabled, active, select})` asks only a server with the flag and only while `enabled` (a
non-admin would get a 403), `retry: false`; it POLLS (`bridgePollInterval`,
Expand All @@ -404,8 +408,11 @@ player's first admin-only UI (Bridge decision 9) and its one Bridge kind, `sidec
reports past chapter detection (`midRunCheckpoint`), only while the cached answer has no
chapters of its own (at most one, no `chapters_source`).
`useBridgeEstimate` (`staleTime: 0`, read afresh each time the
confirmation opens), `useQueueBridgeJob` / `useCancelBridgeJob` (`CapabilityError` without
the flag; the answer goes into the book's job list, a cancel's 409 reads it again).
confirmation opens; Phase 2b: polled while its `open_job` is a share holding Generate back,
`estimatePollInterval`, 10 s while claimed or running, 60 s while queued, so the hold lifts
once it ends; a detection is not waited for), `useQueueBridgeJob` / `useCancelBridgeJob`
(`CapabilityError` without the flag; the answer goes into the book's job list, a cancel's 409
reads it again, a refused queue reads the book's estimate again).
`useCapabilityQuery` gained `retry`, `select` and a query-aware `refetchInterval` for them.
- **The gate** (`bridge-model.ts`, pure and tested): `mayGenerate(user, server)` = an admin
(never a household member, a kid's or a demo account, whatever the role says), the `bridge`
Expand All @@ -428,7 +435,8 @@ player's first admin-only UI (Bridge decision 9) and its one Bridge kind, `sidec
results appear, and a switch, on by default, to queue the series' earlier books without
recaps first, `queueOrder` / `queueInOrder`, stopping at the first refusal; Generate held
back with `blockedReason` while the library isn't writable, the estimate's `open_job` is a
`chapters.detect` (Phase 1d: the server would answer 409 `detect_open`) or the agent is
`chapters.detect` (Phase 1d: the server would answer 409 `detect_open`) or a
`sidecars.contribute` (Phase 2b, a console share: 409 `contribute_open`), or the agent is
KNOWN not ready),
`GenerateStatus` (polls while its screen is focused: phase, stage words `bridge.stage.*`
from `stageOf` (Phase 1d adds `chapter_detecting` and the park `chapters_not_detected`;
Expand Down
5 changes: 3 additions & 2 deletions STYLEGUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,8 +471,9 @@ users icon, "The community already has characters and recaps for this book" and
opens the Characters tab; book page only). Never pink, and nothing at all for anyone else. The confirmation is a Dialog with a
sparkles icon badge: what the Bridge will read, the transcription time, the expected agent cost (a subscription
says it is notional; no history says "no estimate yet"), that the results appear on this server, and a switch
(on by default) to queue the series' earlier books without recaps first; while the library isn't writable or the
agent is known not to be ready, Generate is held back with the reason in warning text. While a job runs, a
(on by default) to queue the series' earlier books without recaps first; while the library isn't writable, the
book's chapters are still being found or its characters and recaps are being shared (the estimate's `open_job`),
or the agent is known not to be ready, Generate is held back with the reason in warning text. While a job runs, a
compact status takes the button's place: sparkles (circle-exclamation in warning or destructive when parked or
failed), the title, one caption line (the stage in plain words, the counts, the time left; why it stopped; the
error), a neutral ink progress bar (not pink: the hero's progress bar is the page's one pink thing) and Cancel,
Expand Down
2 changes: 2 additions & 0 deletions src/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ describe('ApiClient', () => {
expect(apiErrorKind(new ApiError(409, 'x', 'member_disabled'))).toBe('member_disabled');
// A generate job for a book whose chapters are still being found (Bridge Phase 1d).
expect(apiErrorKind(new ApiError(409, 'x', 'detect_open'))).toBe('detect_open');
// One for a book whose characters and recaps are being shared (Bridge Phase 2b).
expect(apiErrorKind(new ApiError(409, 'x', 'contribute_open'))).toBe('contribute_open');
// A code the app doesn't know reads by its status.
expect(apiErrorKind(new ApiError(409, 'x', 'collection_full'))).toBe('conflict');
expect(apiErrorKind(new ApiError(429, 'too many attempts'))).toBe('too_many');
Expand Down
10 changes: 7 additions & 3 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,12 @@ const API_ERROR_CODES = [
'demo_account',
// The Bridge's admin routes: queueing for a library the Bridge can't write (409), a kind
// the paired Bridge doesn't offer (409), and (Bridge Phase 1d) a generate job for a book
// whose chapters a `chapters.detect` job is still finding (409).
// whose chapters a `chapters.detect` job is still finding (409), and (Phase 2b) one for a
// book whose characters and recaps a `sidecars.contribute` job is sharing (409).
'library_not_writable',
'kind_unavailable',
'detect_open',
'contribute_open',
] as const;

export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
Expand Down Expand Up @@ -1175,8 +1177,10 @@ export class ApiClient {
* the answer is the job either way. Refused with a 409 `library_not_writable` when the
* Bridge can't write the library, a 409 `kind_unavailable` when it doesn't offer the
* kind, a 409 `detect_open` for a generate job while a `chapters.detect` job is queued or
* working for the book (Bridge Phase 1d), a 404 for no such book and a 400 for params the
* kind refuses. */
* working for the book (Bridge Phase 1d), a 409 `contribute_open` for one while a
* `sidecars.contribute` job is queued or working for it (Phase 2b; a queued one held while
* the Contributions module is off gives way and is cancelled), a 404 for no such book
* and a 400 for params the kind refuses. */
queueBridgeJob(job: BridgeJobRequest) {
return this.request<BridgeJob>('POST', '/admin/bridge/jobs', { body: job });
}
Expand Down
22 changes: 19 additions & 3 deletions src/api/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { AppState } from 'react-native';

import {
bridgePollInterval,
estimatePollInterval,
latestDone,
midRunCheckpoint,
SIDECARS_GENERATE,
Expand Down Expand Up @@ -2116,14 +2117,22 @@ export function useBridgeJobs<S = BridgeJob[]>(
}

/** What generating characters and recaps for a book would take (`GET
* /admin/bridge/estimate`), read afresh each time it mounts (the confirmation opens). */
* /admin/bridge/estimate`), read afresh each time it mounts (the confirmation opens), again
* while a share holds Generate back (`estimatePollInterval`), and after a refused queue
* (`useQueueBridgeJob`). */
export function useBridgeEstimate(libraryId: number, path: string, connectionId?: string) {
return useCapabilityQuery<BridgeEstimate>(
'bridge',
(cid) => qk.bridgeEstimate(cid, libraryId, path),
(api, signal) => api.bridgeEstimate(libraryId, path, SIDECARS_GENERATE, signal),
connectionId,
{ ready: path.length > 0, staleTime: 0, retry: false },
{
ready: path.length > 0,
staleTime: 0,
retry: false,
refetchInterval: (query) =>
estimatePollInterval(query.state.data?.open_job, AppState.currentState === 'active'),
},
);
}

Expand All @@ -2139,13 +2148,20 @@ function storeJob(qc: QueryClient, cid: string, job: BridgeJob) {
}

/** Queue a Bridge job (`POST /admin/bridge/jobs`); the answer (a new job, or the open one
* already queued for that kind and book) goes into the book's job list, which then polls. */
* already queued for that kind and book) goes into the book's job list, which then polls.
* A refusal (a job of another kind opened for the book meanwhile, say) reads the book's
* estimate again, so an open confirmation holds Generate back with the reason. */
export function useQueueBridgeJob(connectionId?: string) {
return useCapabilityMutation(
'bridge',
connectionId,
(api, job: BridgeJobRequest) => api.queueBridgeJob(job),
({ qc, cid }, answer) => storeJob(qc, cid, answer),
({ qc, cid }, job, error) => {
if (error) {
void qc.invalidateQueries({ queryKey: qk.bridgeEstimate(cid, job.library_id, job.path) });
}
},
);
}

Expand Down
6 changes: 4 additions & 2 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1408,7 +1408,9 @@ export type BridgeEstimate = {
/** The open `sidecars.generate` job for this book, else (Bridge Phase 1d) the open job of
* another kind that refuses a new one now: a `chapters.detect` job queued or working (the
* server answers a generate job for the book 409 `detect_open` until it ends; the
* player's `blockedReason` holds Generate back for it). Its `kind` says which; absent
* for neither. */
* player's `blockedReason` holds Generate back for it), or (Phase 2b) a
* `sidecars.contribute` job queued or working (409 `contribute_open`, held back the same
* way; a queued one held while the Contributions module is off is not reported). Its `kind`
* says which; absent for none. */
open_job?: BridgeJob;
};
41 changes: 41 additions & 0 deletions src/components/bridge/bridge-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,9 +363,28 @@ describe('bridgeRefusalKey and blockedReason', () => {
expect(bridgeRefusalKey(refusal('detect_open'), 'x.fallback')).toBe(
'bridge.generate.detectOpen',
);
expect(bridgeRefusalKey(refusal('contribute_open'), 'x.fallback')).toBe(
'bridge.generate.contributeOpen',
);
// A code the app doesn't name (a newer server's) falls back, never a missing key.
expect(bridgeRefusalKey(refusal('generate_open'), 'x.fallback')).toBe('x.fallback');
expect(bridgeRefusalKey(new Error('offline'), 'x.fallback')).toBe('x.fallback');
});

// An earlier book of the series, queued first, refused for an open job of its own: those
// words name the book refused, and the one on screen isn't it.
it("never words another book's open-job refusal as this book's", () => {
for (const code of ['detect_open', 'contribute_open'])
expect(bridgeRefusalKey(refusal(code), 'x.fallback', true)).toBe('x.fallback');
// The library's and the Bridge's refusals are the same for every book.
expect(bridgeRefusalKey(refusal('library_not_writable'), 'x.fallback', true)).toBe(
'bridge.generate.notWritable',
);
expect(bridgeRefusalKey(refusal('kind_unavailable'), 'x.fallback', true)).toBe(
'bridge.generate.unavailable',
);
});

// Bridge Phase 1d: while a chapters.detect job is queued or working the server refuses a
// generate job (409 detect_open), and its estimate names that job as `open_job`.
it("holds Generate back while the book's chapters are still being found", () => {
Expand All @@ -383,6 +402,28 @@ describe('bridgeRefusalKey and blockedReason', () => {
).toBe('bridge.generate.notWritable');
});

// Bridge Phase 2b: while a sidecars.contribute job (queued from the console) is queued or
// working the server refuses a generate job (409 contribute_open), and the estimate names
// it as `open_job`.
it("holds Generate back while the book's characters and recaps are being shared", () => {
const contribute = bridgeJob({ kind: 'sidecars.contribute', state: 'queued' });
expect(
blockedReason({ writable: true, agent_ready: true, figures: true, open_job: contribute }),
).toBe('bridge.generate.contributeOpen');
expect(
blockedReason({ writable: false, agent_ready: true, figures: true, open_job: contribute }),
).toBe('bridge.generate.notWritable');
// An open job of a kind the player doesn't know holds nothing back (the server decides).
expect(
blockedReason({
writable: true,
agent_ready: true,
figures: true,
open_job: bridgeJob({ kind: 'some.future_kind', state: 'queued' }),
}),
).toBeNull();
});

it('holds Generate back for an unwritable library, then an agent known not ready', () => {
expect(blockedReason({ writable: false, agent_ready: false, figures: true })).toBe(
'bridge.generate.notWritable',
Expand Down
69 changes: 51 additions & 18 deletions src/components/bridge/bridge-model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { apiErrorKind } from '@/api/client';
import { type ApiErrorCode, type ApiErrorKind, apiErrorKind } from '@/api/client';
import type {
BookMetaGenerated,
BookRef,
Expand All @@ -9,7 +9,13 @@ import type {
SidecarsGenerateParams,
User,
} from '@/api/types';
import { CHAPTERS_DETECT, isOpenJob, latestJob, SIDECARS_GENERATE } from '@/lib/bridge-jobs';
import {
CHAPTERS_DETECT,
isOpenJob,
latestJob,
SIDECARS_CONTRIBUTE,
SIDECARS_GENERATE,
} from '@/lib/bridge-jobs';

// The rules behind the player's one Bridge action, "Generate characters and recaps"
// (Bridge Phase 1a): who may see it, when it is offered, how a job's state reads and the
Expand Down Expand Up @@ -300,37 +306,64 @@ export function jobStatusView(job: BridgeJob): JobStatusView {

// --- Refusals -----------------------------------------------------------------------

/** The Bridge's refusals the player names, by the server's error code. */
/** The Bridge's refusals the player names, by the server's error code (each one of
* `API_ERROR_CODES`, so `apiErrorKind` can answer it). */
const REFUSAL_KEY = {
library_not_writable: 'bridge.generate.notWritable',
kind_unavailable: 'bridge.generate.unavailable',
// Bridge Phase 1d: the book's chapters are still being found (a `chapters.detect` job
// queued or working); a generate job queued after it reuses its transcription.
detect_open: 'bridge.generate.detectOpen',
} as const;
// Bridge Phase 2b: the book's characters and recaps are being shared with the community
// (a `sidecars.contribute` job queued or working, queued from the console; a queued one
// held while the module is off never refuses); a generate run would replace what is filed.
contribute_open: 'bridge.generate.contributeOpen',
} as const satisfies Partial<Record<ApiErrorCode, string>>;

/** A refusal's words (`REFUSAL_KEY`'s values). */
type RefusalKey = (typeof REFUSAL_KEY)[keyof typeof REFUSAL_KEY];

/** `REFUSAL_KEY` read by any kind `apiErrorKind` answers: a kind it doesn't name is absent. */
const REFUSAL_BY_KIND: Readonly<Partial<Record<ApiErrorKind, RefusalKey>>> = REFUSAL_KEY;

/** The open job kinds the server refuses a generate job for, with the words of the refusal
* it answers (the server's `refusedWhileOpen` for `sidecars.generate`): a `chapters.detect`
* job (Bridge Phase 1d, 409 `detect_open`) and a `sidecars.contribute` job (Phase 2b, 409
* `contribute_open`). A kind not named here holds nothing back. */
const OPEN_JOB_REFUSAL: Readonly<
Record<string, (typeof REFUSAL_KEY)['detect_open' | 'contribute_open']>
> = {
[CHAPTERS_DETECT]: REFUSAL_KEY.detect_open,
[SIDECARS_CONTRIBUTE]: REFUSAL_KEY.contribute_open,
};

/** The refusals about an open job of the refused book (`OPEN_JOB_REFUSAL`'s): their words
* name that book ("This book's characters and recaps..."). */
const BOOK_REFUSALS: ReadonlySet<RefusalKey> = new Set(Object.values(OPEN_JOB_REFUSAL));

/** A Bridge refusal in the app's words (never the server's): the library it can't write,
* the kind it doesn't offer, a book whose chapters are still being found, else
* `fallback`. */
export function bridgeRefusalKey<F extends string>(error: unknown, fallback: F) {
const kind = apiErrorKind(error);
return kind === 'library_not_writable' || kind === 'kind_unavailable' || kind === 'detect_open'
? REFUSAL_KEY[kind]
: fallback;
* the kind it doesn't offer, a book whose chapters are still being found or whose
* characters and recaps are being shared, else `fallback`. `otherBook`: the refused book
* isn't the one on screen (an earlier book of the series, queued first), so a refusal about
* its own open job, worded as this book's, reads as `fallback` instead. */
export function bridgeRefusalKey<F extends string>(error: unknown, fallback: F, otherBook = false) {
const key = REFUSAL_BY_KIND[apiErrorKind(error)];
return key && !(otherBook && BOOK_REFUSALS.has(key)) ? key : fallback;
}

/** Why Generate is held back, when it is: the Bridge can't write the library (its
* results would have nowhere to go), the book's chapters are still being found (Bridge
* Phase 1d: the estimate's `open_job` is then the `chapters.detect` job the server would
* refuse a generate job for, 409 `detect_open`), or its agent is known not to be signed
* in. Without the Bridge's figures (`figures`) `agent_ready` is only a default, and the
* server queues the job anyway (it waits for the agent), so an unknown readiness holds
* nothing back. */
* results would have nowhere to go), the estimate's `open_job` is a kind the server would
* refuse a generate job for (`OPEN_JOB_REFUSAL`: the book's chapters are still being found,
* or its characters and recaps are being shared with the community), or its agent is known
* not to be signed in. Without the Bridge's figures (`figures`) `agent_ready` is only a
* default, and the server queues the job anyway (it waits for the agent), so an unknown
* readiness holds nothing back. */
export function blockedReason(
e: Pick<BridgeEstimate, 'writable' | 'agent_ready' | 'figures' | 'open_job'>,
) {
if (!e.writable) return REFUSAL_KEY.library_not_writable;
if (e.open_job?.kind === CHAPTERS_DETECT) return REFUSAL_KEY.detect_open;
const kind = e.open_job?.kind;
if (kind && Object.hasOwn(OPEN_JOB_REFUSAL, kind)) return OPEN_JOB_REFUSAL[kind];
if (e.figures === true && !e.agent_ready) return 'bridge.generate.agentNotReady' as const;
return null;
}
Expand Down
9 changes: 6 additions & 3 deletions src/components/bridge/generate-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ import type { GenerateModel } from './use-generate';
* cost of every run the button queues (or "no estimate yet"; a subscription's note), where
* the results appear (always on this server: the player never offers to contribute them),
* and, when earlier books of the series have no recaps, a switch to queue those first (on
* by default). Generate is held back, with the reason, while the Bridge can't write the
* library or its agent is known not to be ready. The estimate is read afresh each time it
* opens, and again on Retry after a failure.
* by default). Generate is held back, with the reason (`blockedReason`), while the Bridge
* can't write the library, the book's chapters are still being found or its characters and
* recaps are being shared (the estimate's `open_job`), or its agent is known not to be
* ready. The estimate is read afresh each time it opens, again on Retry after a failure,
* while a share holds Generate back (so the hold lifts once it ends) and after a refused
* queue (`useBridgeEstimate`).
*/
export function GenerateDialog({ model, title }: { model: GenerateModel; title?: string }) {
return (
Expand Down
Loading
Loading