From 559a9761371ea5961f1cdbacfa10effa4060e6b4 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Tue, 20 Oct 2026 01:02:31 +0100 Subject: [PATCH 1/5] Bridge: hold Generate back while a book's characters and recaps are being shared A sidecars.contribute job (Bridge Phase 2b, queued from the console) makes the server refuse a generate job for the book with 409 contribute_open, and the brief estimate names it as open_job. blockedReason now holds Generate back for it and bridgeRefusalKey names the refusal, in all six locales. An older server never sends either, so nothing else changes. --- CONVENTIONS.md | 3 +- src/api/client.test.ts | 2 ++ src/api/client.ts | 9 ++++-- src/api/types.ts | 5 ++-- src/components/bridge/bridge-model.test.ts | 27 +++++++++++++++++ src/components/bridge/bridge-model.ts | 26 +++++++++++----- src/components/bridge/generate-flow.test.tsx | 31 ++++++++++++++++++++ src/i18n/locales/de.json | 1 + src/i18n/locales/en.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/it.json | 1 + src/i18n/locales/pt.json | 1 + src/lib/bridge-jobs.ts | 5 ++++ 14 files changed, 101 insertions(+), 13 deletions(-) diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 2dce3d89..cd1d6379 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -428,7 +428,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`; diff --git a/src/api/client.test.ts b/src/api/client.test.ts index ee049a03..b1fb7b4f 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -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'); diff --git a/src/api/client.ts b/src/api/client.ts index d6275231..05254a2e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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]; @@ -1175,8 +1177,9 @@ 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 404 for no such book + * and a 400 for params the kind refuses. */ queueBridgeJob(job: BridgeJobRequest) { return this.request('POST', '/admin/bridge/jobs', { body: job }); } diff --git a/src/api/types.ts b/src/api/types.ts index 3117974a..4a737509 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1408,7 +1408,8 @@ 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). Its `kind` says which; absent for none. */ open_job?: BridgeJob; }; diff --git a/src/components/bridge/bridge-model.test.ts b/src/components/bridge/bridge-model.test.ts index 35625bc8..1c947cfe 100644 --- a/src/components/bridge/bridge-model.test.ts +++ b/src/components/bridge/bridge-model.test.ts @@ -363,6 +363,11 @@ 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'); }); @@ -383,6 +388,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', diff --git a/src/components/bridge/bridge-model.ts b/src/components/bridge/bridge-model.ts index 54f3c11d..08117b27 100644 --- a/src/components/bridge/bridge-model.ts +++ b/src/components/bridge/bridge-model.ts @@ -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 @@ -307,23 +313,28 @@ const REFUSAL_KEY = { // 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', + // 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 generate run + // would replace what is being filed. + contribute_open: 'bridge.generate.contributeOpen', } as const; /** 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`. */ + * the kind it doesn't offer, a book whose chapters are still being found or whose + * characters and recaps are being shared, else `fallback`. */ export function bridgeRefusalKey(error: unknown, fallback: F) { const kind = apiErrorKind(error); - return kind === 'library_not_writable' || kind === 'kind_unavailable' || kind === 'detect_open' - ? REFUSAL_KEY[kind] + return Object.hasOwn(REFUSAL_KEY, kind) + ? REFUSAL_KEY[kind as keyof typeof REFUSAL_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 + * refuse a generate job for, 409 `detect_open`), its characters and recaps are being shared + * with the community (Phase 2b: a `sidecars.contribute` `open_job`, 409 `contribute_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. */ export function blockedReason( @@ -331,6 +342,7 @@ export function blockedReason( ) { if (!e.writable) return REFUSAL_KEY.library_not_writable; if (e.open_job?.kind === CHAPTERS_DETECT) return REFUSAL_KEY.detect_open; + if (e.open_job?.kind === SIDECARS_CONTRIBUTE) return REFUSAL_KEY.contribute_open; if (e.figures === true && !e.agent_ready) return 'bridge.generate.agentNotReady' as const; return null; } diff --git a/src/components/bridge/generate-flow.test.tsx b/src/components/bridge/generate-flow.test.tsx index ebe353fa..069d3d94 100644 --- a/src/components/bridge/generate-flow.test.tsx +++ b/src/components/bridge/generate-flow.test.tsx @@ -300,6 +300,37 @@ describe('the confirmation', () => { ); }); + // Bridge Phase 2b: while the console shares the book's characters and recaps (a + // sidecars.contribute job queued or working) the server refuses a generate job (409 + // contribute_open), and the estimate names it as open_job. + it("holds it back while the book's characters and recaps are being shared", async () => { + mockEstimate.mockResolvedValue( + bridgeEstimate({ open_job: bridgeJob({ kind: 'sidecars.contribute', state: 'running' }) }), + ); + await mount(); + await fireEvent.press(await screen.findByTestId('generate-action')); + expect(await screen.findByTestId('generate-blocked')).toBeTruthy(); + expect(screen.getByText(/being shared with the community/)).toBeTruthy(); + await fireEvent.press(screen.getByTestId('generate-confirm')); + expect(mockQueue).not.toHaveBeenCalled(); + }); + + // A share queued after the estimate was read: the server's refusal says why. + it('says why when the server refuses it for a share still open', async () => { + mockEstimate.mockResolvedValue(bridgeEstimate({ path: TARGET.path })); + mockQueue.mockRejectedValue(new ApiError(409, 'refused', 'contribute_open')); + await mount(); + await fireEvent.press(await screen.findByTestId('generate-action')); + expect(await screen.findByTestId('generate-estimate')).toBeTruthy(); + await fireEvent.press(screen.getByTestId('generate-confirm')); + await waitFor(() => + expect(mockToast).toHaveBeenLastCalledWith({ + title: + "This book's characters and recaps are being shared with the community. Generate once that has finished.", + }), + ); + }); + // A Bridge that sent no figures: its agent_ready is only a default, its transcription // time unknown (never "none" for the audio), and the server would queue the job. it('holds nothing back, and claims nothing, when the Bridge sent no figures', async () => { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 70fc77bd..61c86cb9 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1244,6 +1244,7 @@ "estimateFailed": "Die Bridge konnte keine Schätzung liefern. Versuch es gleich noch einmal.", "unavailable": "Die Bridge bietet das gerade nicht an.", "detectOpen": "Die Kapitel des Buchs werden noch gesucht. Tippe auf Erstellen, sobald das fertig ist: Die Transkription wird wiederverwendet.", + "contributeOpen": "Die Figuren und Rückblicke dieses Buchs werden gerade mit der Community geteilt. Tippe auf Erstellen, sobald das fertig ist.", "confirm": "Erstellen", "confirmMany_one": "{{count}} Buch erstellen", "confirmMany_other": "{{count}} Bücher erstellen", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f329fe3c..e35f4961 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1244,6 +1244,7 @@ "estimateFailed": "Couldn't get an estimate from the Bridge. Try again in a moment.", "unavailable": "The Bridge doesn't offer this right now.", "detectOpen": "The book's chapters are still being found. Generate once that has finished: it reuses the transcription.", + "contributeOpen": "This book's characters and recaps are being shared with the community. Generate once that has finished.", "confirm": "Generate", "confirmMany_one": "Generate {{count}} book", "confirmMany_other": "Generate {{count}} books", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index ce2683ab..62a7b215 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1244,6 +1244,7 @@ "estimateFailed": "No se pudo obtener una estimación del Bridge. Inténtalo de nuevo en un momento.", "unavailable": "El Bridge no ofrece esto ahora mismo.", "detectOpen": "Todavía se están buscando los capítulos del libro. Pulsa Generar cuando termine: se reutiliza la transcripción.", + "contributeOpen": "Los personajes y resúmenes de este libro se están compartiendo con la comunidad. Pulsa Generar cuando termine.", "confirm": "Generar", "confirmMany_one": "Generar {{count}} libro", "confirmMany_other": "Generar {{count}} libros", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 46a5712a..24eb1030 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1244,6 +1244,7 @@ "estimateFailed": "Impossible d'obtenir une estimation du Bridge. Réessayez dans un instant.", "unavailable": "Le Bridge ne propose pas cela pour le moment.", "detectOpen": "Les chapitres du livre sont encore en cours de recherche. Appuyez sur Générer une fois que c'est fini : la transcription est réutilisée.", + "contributeOpen": "Les personnages et résumés de ce livre sont en cours de partage avec la communauté. Appuyez sur Générer une fois que c'est fini.", "confirm": "Générer", "confirmMany_one": "Générer {{count}} livre", "confirmMany_other": "Générer {{count}} livres", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index a4af1d38..69cf81b6 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1244,6 +1244,7 @@ "estimateFailed": "Impossibile ottenere una stima dal Bridge. Riprova tra un attimo.", "unavailable": "Il Bridge non offre questa funzione al momento.", "detectOpen": "I capitoli del libro sono ancora in ricerca. Premi Genera quando ha finito: la trascrizione viene riutilizzata.", + "contributeOpen": "I personaggi e i riassunti di questo libro sono in fase di condivisione con la community. Premi Genera quando ha finito.", "confirm": "Genera", "confirmMany_one": "Genera {{count}} libro", "confirmMany_other": "Genera {{count}} libri", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 062d99af..1e0b0561 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1244,6 +1244,7 @@ "estimateFailed": "Não foi possível obter uma estimativa do Bridge. Tente de novo daqui a pouco.", "unavailable": "O Bridge não oferece isto no momento.", "detectOpen": "Os capítulos do livro ainda estão sendo procurados. Toque em Gerar quando terminar: a transcrição é reutilizada.", + "contributeOpen": "Os personagens e resumos deste livro estão sendo compartilhados com a comunidade. Toque em Gerar quando terminar.", "confirm": "Gerar", "confirmMany_one": "Gerar {{count}} livro", "confirmMany_other": "Gerar {{count}} livros", diff --git a/src/lib/bridge-jobs.ts b/src/lib/bridge-jobs.ts index 85d9d4c0..645c9cf6 100644 --- a/src/lib/bridge-jobs.ts +++ b/src/lib/bridge-jobs.ts @@ -14,6 +14,11 @@ export const SIDECARS_DESCRIBE = 'sidecars.describe'; * the book (409 `detect_open`). */ export const CHAPTERS_DETECT = 'chapters.detect'; +/** The Bridge job kind that shares a book's generated characters and recaps with the + * community (Bridge Phase 2b). The console queues it, never the player; while one is queued + * or working the server refuses a generate job for the book (409 `contribute_open`). */ +export const SIDECARS_CONTRIBUTE = 'sidecars.contribute'; + /** The kinds whose `done` job wrote the book's `.audiosilo/` files (see `latestDone`). */ const WRITES_BOOK: ReadonlySet = new Set([ SIDECARS_GENERATE, From 031fc3bac6f6a6d852eff7c2d93d531198d57867 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Tue, 20 Oct 2026 01:04:58 +0100 Subject: [PATCH 2/5] Simplify pass --- src/components/bridge/bridge-model.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/components/bridge/bridge-model.ts b/src/components/bridge/bridge-model.ts index 08117b27..7aaa143c 100644 --- a/src/components/bridge/bridge-model.ts +++ b/src/components/bridge/bridge-model.ts @@ -329,20 +329,28 @@ export function bridgeRefusalKey(error: unknown, fallback: F) : fallback; } +/** The open job kinds the server refuses a generate job for, by 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> = { + [CHAPTERS_DETECT]: 'detect_open', + [SIDECARS_CONTRIBUTE]: 'contribute_open', +}; + /** 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`), its characters and recaps are being shared - * with the community (Phase 2b: a `sidecars.contribute` `open_job`, 409 `contribute_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, ) { if (!e.writable) return REFUSAL_KEY.library_not_writable; - if (e.open_job?.kind === CHAPTERS_DETECT) return REFUSAL_KEY.detect_open; - if (e.open_job?.kind === SIDECARS_CONTRIBUTE) return REFUSAL_KEY.contribute_open; + const kind = e.open_job?.kind; + if (kind && Object.hasOwn(OPEN_JOB_REFUSAL, kind)) return REFUSAL_KEY[OPEN_JOB_REFUSAL[kind]]; if (e.figures === true && !e.agent_ready) return 'bridge.generate.agentNotReady' as const; return null; } From c2c37fee4c29a72cc62345acc38f583066a57efe Mon Sep 17 00:00:00 2001 From: KodeStar Date: Tue, 20 Oct 2026 01:55:06 +0100 Subject: [PATCH 3/5] Code review fixes --- CONVENTIONS.md | 17 +++-- STYLEGUIDE.md | 5 +- src/api/hooks.ts | 22 +++++- src/components/bridge/bridge-model.test.ts | 14 ++++ src/components/bridge/bridge-model.ts | 51 ++++++++------ src/components/bridge/generate-dialog.tsx | 9 ++- src/components/bridge/generate-flow.test.tsx | 72 +++++++++++++++++++- src/components/bridge/use-generate.ts | 9 ++- src/lib/bridge-jobs.test.ts | 24 +++++++ src/lib/bridge-jobs.ts | 18 +++++ 10 files changed, 204 insertions(+), 37 deletions(-) diff --git a/CONVENTIONS.md b/CONVENTIONS.md index cd1d6379..60a58681 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -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`, @@ -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` diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 8d3799b9..eb4f8426 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -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, diff --git a/src/api/hooks.ts b/src/api/hooks.ts index 34dbd091..aa341cd1 100644 --- a/src/api/hooks.ts +++ b/src/api/hooks.ts @@ -20,6 +20,7 @@ import { AppState } from 'react-native'; import { bridgePollInterval, + estimatePollInterval, latestDone, midRunCheckpoint, SIDECARS_GENERATE, @@ -2116,14 +2117,22 @@ export function useBridgeJobs( } /** 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( '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'), + }, ); } @@ -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) }); + } + }, ); } diff --git a/src/components/bridge/bridge-model.test.ts b/src/components/bridge/bridge-model.test.ts index 1c947cfe..106bf078 100644 --- a/src/components/bridge/bridge-model.test.ts +++ b/src/components/bridge/bridge-model.test.ts @@ -371,6 +371,20 @@ describe('bridgeRefusalKey and blockedReason', () => { 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", () => { diff --git a/src/components/bridge/bridge-model.ts b/src/components/bridge/bridge-model.ts index 7aaa143c..6579af9d 100644 --- a/src/components/bridge/bridge-model.ts +++ b/src/components/bridge/bridge-model.ts @@ -1,4 +1,4 @@ -import { apiErrorKind } from '@/api/client'; +import { type ApiErrorCode, type ApiErrorKind, apiErrorKind } from '@/api/client'; import type { BookMetaGenerated, BookRef, @@ -306,7 +306,8 @@ 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', @@ -317,27 +318,39 @@ const REFUSAL_KEY = { // (a `sidecars.contribute` job queued or working, queued from the console); a generate run // would replace what is being filed. contribute_open: 'bridge.generate.contributeOpen', -} as const; +} as const satisfies Partial>; -/** 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 or whose - * characters and recaps are being shared, else `fallback`. */ -export function bridgeRefusalKey(error: unknown, fallback: F) { - const kind = apiErrorKind(error); - return Object.hasOwn(REFUSAL_KEY, kind) - ? REFUSAL_KEY[kind as keyof typeof REFUSAL_KEY] - : fallback; -} +/** A refusal's words (`REFUSAL_KEY`'s values). */ +type RefusalKey = (typeof REFUSAL_KEY)[keyof typeof REFUSAL_KEY]; -/** The open job kinds the server refuses a generate job for, by 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 +/** `REFUSAL_KEY` read by any kind `apiErrorKind` answers: a kind it doesn't name is absent. */ +const REFUSAL_BY_KIND: Readonly>> = 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> = { - [CHAPTERS_DETECT]: 'detect_open', - [SIDECARS_CONTRIBUTE]: 'contribute_open', +const OPEN_JOB_REFUSAL: Readonly< + Record +> = { + [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 = 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 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(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 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, @@ -350,7 +363,7 @@ export function blockedReason( ) { if (!e.writable) return REFUSAL_KEY.library_not_writable; const kind = e.open_job?.kind; - if (kind && Object.hasOwn(OPEN_JOB_REFUSAL, kind)) return REFUSAL_KEY[OPEN_JOB_REFUSAL[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; } diff --git a/src/components/bridge/generate-dialog.tsx b/src/components/bridge/generate-dialog.tsx index 86ce1e80..3b83b168 100644 --- a/src/components/bridge/generate-dialog.tsx +++ b/src/components/bridge/generate-dialog.tsx @@ -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 ( diff --git a/src/components/bridge/generate-flow.test.tsx b/src/components/bridge/generate-flow.test.tsx index 069d3d94..23895a5c 100644 --- a/src/components/bridge/generate-flow.test.tsx +++ b/src/components/bridge/generate-flow.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react-native'; +import { AppState } from 'react-native'; import type { BridgeEstimate, BridgeJob, BridgeJobRequest, ServerInfo, User } from '@/api/types'; @@ -37,6 +38,7 @@ jest.mock('expo-router', () => ({ useIsFocused: () => true })); /* eslint-disable import/first */ import { ApiError } from '@/api/client'; import { qk } from '@/api/hooks'; +import { BRIDGE_POLL_MS } from '@/lib/bridge-jobs'; import { useSession } from '@/stores/session'; import { ADMIN, bridgeEstimate, bridgeJob, bridgeServer } from '@/testing/bridge-fixtures'; import { notifyQueriesSynchronously } from '@/testing/query-notify'; @@ -315,9 +317,13 @@ describe('the confirmation', () => { expect(mockQueue).not.toHaveBeenCalled(); }); - // A share queued after the estimate was read: the server's refusal says why. + // A share queued after the estimate was read: the server's refusal says why, and the + // estimate is read again, so the confirmation holds Generate back with the reason. it('says why when the server refuses it for a share still open', async () => { - mockEstimate.mockResolvedValue(bridgeEstimate({ path: TARGET.path })); + const share = bridgeJob({ kind: 'sidecars.contribute', state: 'running' }); + mockEstimate + .mockResolvedValueOnce(bridgeEstimate({ path: TARGET.path })) + .mockResolvedValue(bridgeEstimate({ path: TARGET.path, open_job: share })); mockQueue.mockRejectedValue(new ApiError(409, 'refused', 'contribute_open')); await mount(); await fireEvent.press(await screen.findByTestId('generate-action')); @@ -329,6 +335,66 @@ describe('the confirmation', () => { "This book's characters and recaps are being shared with the community. Generate once that has finished.", }), ); + expect(await screen.findByTestId('generate-blocked')).toBeTruthy(); + expect(screen.getByTestId('generate-confirm')).toBeDisabled(); + }); + + // A share ends in seconds: while one holds Generate back the confirmation reads the + // estimate again (every 10 s while it runs), and the hold lifts once it has ended. + it('reads the estimate again while a share holds it back, and lifts the hold', async () => { + // The app in the foreground (the poll stops in the background). + const was = Object.getOwnPropertyDescriptor(AppState, 'currentState'); + Object.defineProperty(AppState, 'currentState', { get: () => 'active', configurable: true }); + try { + const share = bridgeJob({ kind: 'sidecars.contribute', state: 'running' }); + mockEstimate.mockResolvedValue(bridgeEstimate({ path: TARGET.path, open_job: share })); + const qc = await mount(); + await fireEvent.press(await screen.findByTestId('generate-action')); + expect(await screen.findByTestId('generate-blocked')).toBeTruthy(); + const key = qk.bridgeEstimate('c1', TARGET.libraryId, TARGET.path); + const query = qc.getQueryCache().find({ queryKey: key }); + const every = query?.observers[0]?.options.refetchInterval; + const interval = () => (typeof every === 'function' && query ? every(query) : every); + expect(interval()).toBe(BRIDGE_POLL_MS); + + // The share has ended: the next read holds nothing back, and stops the poll. + mockEstimate.mockResolvedValue(bridgeEstimate({ path: TARGET.path })); + await act(() => qc.refetchQueries({ queryKey: key })); + await waitFor(() => expect(screen.queryByTestId('generate-blocked')).toBeNull()); + expect(screen.getByTestId('generate-confirm')).toBeEnabled(); + expect(interval()).toBe(false); + } finally { + if (was) Object.defineProperty(AppState, 'currentState', was); + } + }); + + // An earlier book, queued first, refused for an open job of its own: those words name the + // book refused, and this one isn't it, so the plain failure is said instead. + it.each([ + ['Saga/Book 1', 'contribute_open', { title: "Couldn't queue it. Nothing was started." }], + [ + 'Saga/Book 2', + 'detect_open', + { + title: "Queued 1 of 3 books. The rest couldn't be queued.", + description: "This book isn't queued yet. Press Generate again to queue the rest.", + }, + ], + ])("never words %s's %s refusal as this book's", async (refusedPath, code, said) => { + mockQueue.mockImplementation(async (job) => { + if (job.path === refusedPath) throw new ApiError(409, 'refused', code); + return bridgeJob({ + id: nextId++, + state: 'queued', + library_id: job.library_id, + path: job.path, + }); + }); + await mount(); + await fireEvent.press(await screen.findByTestId('generate-action')); + expect(await screen.findByTestId('generate-estimate')).toBeTruthy(); + await fireEvent.press(screen.getByTestId('generate-confirm')); + await waitFor(() => expect(mockToast).toHaveBeenLastCalledWith(said)); }); // A Bridge that sent no figures: its agent_ready is only a default, its transcription diff --git a/src/components/bridge/use-generate.ts b/src/components/bridge/use-generate.ts index 73f41a1f..070774b3 100644 --- a/src/components/bridge/use-generate.ts +++ b/src/components/bridge/use-generate.ts @@ -116,11 +116,16 @@ export function useGenerate({ queueJob.mutateAsync({ kind: SIDECARS_GENERATE, ...book, params: GENERATE_PARAMS }), ); setQueueing(false); + // The refusal stopped the run at books[queued.length]: an earlier book of the series + // (queued first) isn't this one, so a refusal about its own open job is never worded + // as this book's (`bridgeRefusalKey`'s `otherBook`). + const refused = books[queued.length]; + const otherBook = !!refused && (refused.library_id !== libraryId || refused.path !== path); if (queued.length === 0) { // A CapabilityError was never sent (the Bridge went offline meanwhile): the button // goes with the flag, so there is nothing to say. if (!(error instanceof CapabilityError)) { - toast({ title: t(bridgeRefusalKey(error, 'bridge.generate.queueFailed')) }); + toast({ title: t(bridgeRefusalKey(error, 'bridge.generate.queueFailed', otherBook)) }); } return false; } @@ -130,7 +135,7 @@ export function useGenerate({ // press again: the books already queued answer with their open job. toast({ title: t('bridge.generate.queuedSome', { count: queued.length, total: books.length }), - description: t(bridgeRefusalKey(error, 'bridge.generate.queuedRestFailed')), + description: t(bridgeRefusalKey(error, 'bridge.generate.queuedRestFailed', otherBook)), }); return false; } diff --git a/src/lib/bridge-jobs.test.ts b/src/lib/bridge-jobs.test.ts index a857138b..33925808 100644 --- a/src/lib/bridge-jobs.test.ts +++ b/src/lib/bridge-jobs.test.ts @@ -5,10 +5,12 @@ import { BRIDGE_POLL_MS, bridgePollInterval, CHAPTERS_DETECT, + estimatePollInterval, isOpenJob, latestDone, latestJob, midRunCheckpoint, + SIDECARS_CONTRIBUTE, SIDECARS_DESCRIBE, SIDECARS_GENERATE, } from './bridge-jobs'; @@ -71,6 +73,28 @@ describe('bridgePollInterval', () => { }); }); +// Bridge Phase 2b: a share holding Generate back ends in seconds, so the confirmation's +// estimate is read again until it has. +describe('estimatePollInterval', () => { + const share = (state: string) => job({ kind: SIDECARS_CONTRIBUTE, state }); + + it('polls while a share holds Generate back: every 10 s while it works, a minute while it waits', () => { + for (const state of ['claimed', 'running']) + expect(estimatePollInterval(share(state), true)).toBe(BRIDGE_POLL_MS); + expect(estimatePollInterval(share('queued'), true)).toBe(BRIDGE_IDLE_POLL_MS); + }); + + it("never for a detection, the book's own generate job, an ended share, none, or off screen", () => { + expect(estimatePollInterval(job({ kind: CHAPTERS_DETECT, state: 'running' }), true)).toBe( + false, + ); + expect(estimatePollInterval(job({ state: 'running' }), true)).toBe(false); + expect(estimatePollInterval(share('done'), true)).toBe(false); + expect(estimatePollInterval(undefined, true)).toBe(false); + expect(estimatePollInterval(share('running'), false)).toBe(false); + }); +}); + describe('latestDone', () => { it('picks the newest done generate job, ignoring open, failed and other kinds', () => { const jobs = [ diff --git a/src/lib/bridge-jobs.ts b/src/lib/bridge-jobs.ts index 645c9cf6..d0edcbdb 100644 --- a/src/lib/bridge-jobs.ts +++ b/src/lib/bridge-jobs.ts @@ -82,6 +82,24 @@ export function bridgePollInterval( return interval; } +/** + * How often the confirmation's estimate is read again while it is open and `active` (the + * app in the foreground): while its `open_job` is a share holding Generate back (an open + * `sidecars.contribute`, Bridge Phase 2b: it files issues, no agent, so it ends in seconds), + * every `BRIDGE_POLL_MS` once it is claimed or running and every `BRIDGE_IDLE_POLL_MS` while + * it waits, so the hold lifts once it ends. A chapter detection (hours) is not waited for in + * the dialog. Else never. + */ +export function estimatePollInterval( + openJob: BridgeJob | undefined, + active: boolean, +): number | false { + if (!active || openJob?.kind !== SIDECARS_CONTRIBUTE || !isOpenJob(openJob)) return false; + return openJob.state === 'claimed' || openJob.state === 'running' + ? BRIDGE_POLL_MS + : BRIDGE_IDLE_POLL_MS; +} + /** A book's newest `done` job that wrote its `.audiosilo/` files (`sidecars.generate`, the * console's `sidecars.describe`, or its `chapters.detect`): the book was rescanned, so what * the book's answers say about it (its chapters included) may be stale. */ From 45bf265e5d2de58e536ee6c0a5456bbb5e6f7c13 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Tue, 20 Oct 2026 02:54:33 +0100 Subject: [PATCH 4/5] Bridge 2b: a share held while the module is off never blocks Generate (comments) --- src/api/types.ts | 3 ++- src/components/bridge/bridge-model.ts | 4 ++-- src/lib/bridge-jobs.ts | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/api/types.ts b/src/api/types.ts index 4a737509..e593c838 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1410,6 +1410,7 @@ export type BridgeEstimate = { * server answers a generate job for the book 409 `detect_open` until it ends; the * 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). Its `kind` says which; absent for none. */ + * way; a queued one held while the Contributions module is off is not reported). Its `kind` + * says which; absent for none. */ open_job?: BridgeJob; }; diff --git a/src/components/bridge/bridge-model.ts b/src/components/bridge/bridge-model.ts index 6579af9d..e36855a3 100644 --- a/src/components/bridge/bridge-model.ts +++ b/src/components/bridge/bridge-model.ts @@ -315,8 +315,8 @@ const REFUSAL_KEY = { // queued or working); a generate job queued after it reuses its transcription. detect_open: 'bridge.generate.detectOpen', // 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 generate run - // would replace what is being filed. + // (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>; diff --git a/src/lib/bridge-jobs.ts b/src/lib/bridge-jobs.ts index d0edcbdb..23afe6db 100644 --- a/src/lib/bridge-jobs.ts +++ b/src/lib/bridge-jobs.ts @@ -16,7 +16,8 @@ export const CHAPTERS_DETECT = 'chapters.detect'; /** The Bridge job kind that shares a book's generated characters and recaps with the * community (Bridge Phase 2b). The console queues it, never the player; while one is queued - * or working the server refuses a generate job for the book (409 `contribute_open`). */ + * or working the server refuses a generate job for the book (409 `contribute_open`), except a + * queued one held while the Contributions module is off, which a new generate cancels. */ export const SIDECARS_CONTRIBUTE = 'sidecars.contribute'; /** The kinds whose `done` job wrote the book's `.audiosilo/` files (see `latestDone`). */ From 48a73981017021ca9afbceade6832273659bf3b6 Mon Sep 17 00:00:00 2001 From: KodeStar Date: Tue, 20 Oct 2026 03:06:05 +0100 Subject: [PATCH 5/5] Bridge 2b: registry and queueBridgeJob comments --- src/api/client.ts | 3 ++- src/modules/registry.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 05254a2e..95f99e54 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1178,7 +1178,8 @@ export class ApiClient { * 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 409 `contribute_open` for one while a - * `sidecars.contribute` job is queued or working for it (Phase 2b), a 404 for no such book + * `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('POST', '/admin/bridge/jobs', { body: job }); diff --git a/src/modules/registry.ts b/src/modules/registry.ts index e1648929..12162be9 100644 --- a/src/modules/registry.ts +++ b/src/modules/registry.ts @@ -50,8 +50,8 @@ export type YouModuleSection = { render: (o: { phone: boolean }) => ReactNode; }; -/** The player's modules, in the order their slots show them. Empty in Phase 2a: the - * Contributions module's player part arrives with Phase 2b. */ +/** The player's modules, in the order their slots show them. Empty: the Contributions + * module (Bridge Phase 2b) has no player part, as sharing and corrections are console only. */ export const PLAYER_MODULES: readonly PlayerModule[] = []; /** No modules: one shared empty list, so an unchanged answer keeps its identity. */