Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
39bd5da
docs(plans): commit the reconciled phase 7 plan
rsml Jul 21, 2026
a3469ae
test(migrations): anchor the books ignore and commit v1 fixture libra…
rsml Jul 21, 2026
df8995e
test(migrations): add red tests for schema versioning and migration c…
rsml Jul 21, 2026
22b06fa
feat(migrations): add pure schema versioning and migration chains
rsml Jul 21, 2026
79f02f8
feat(adapters): guard reads and stamp writes with the current schema …
rsml Jul 21, 2026
4ea1ec9
test(ports): add red tests for the LibraryMigrator port
rsml Jul 21, 2026
da233c6
feat(migrations): add the real LibraryMigrator adapter and run it at …
rsml Jul 21, 2026
ac23992
test(server): pin the AI error taxonomy and retry policy before they …
rsml Jul 21, 2026
7bb37c0
feat(server): add retry and a typed error taxonomy to TextGeneration
rsml Jul 21, 2026
65a0c35
docs(plans): reconcile the phase 7 plan with what the code turned out…
rsml Jul 21, 2026
50f0dde
test(server): pin the job journal contract before it exists
rsml Jul 21, 2026
84e0c29
feat(server): persist in-flight background jobs to a journal
rsml Jul 21, 2026
bf89fe0
test(routes): reproduce the empty chapter-generation stream over a re…
rsml Jul 21, 2026
4b8892b
fix(routes): listen for disconnect on the response, not the request
rsml Jul 21, 2026
2fba22b
refactor(domain): name both job statuses and say where the stamp is t…
rsml Jul 21, 2026
e34e436
fix(adapters): stop a failed journal write from killing the process
rsml Jul 21, 2026
89516c1
refactor(server): expose the built ports so startup acts on the real …
rsml Jul 21, 2026
04ba189
feat(server): journal generate-chapter jobs and let the hub seed an i…
rsml Jul 21, 2026
d63af72
test(server): add red tests for resuming interrupted jobs at boot
rsml Jul 21, 2026
9eec82e
feat(server): resume interrupted background jobs at boot
rsml Jul 21, 2026
e1cdeb4
fix(routes): journal checkpoints on the live path, not only on resume
rsml Jul 21, 2026
a61b205
feat(server): surface the AI failure class on error bodies and SSE ev…
rsml Jul 21, 2026
319e7c1
feat(reader): surface an interrupted generation and route auth failures
rsml Jul 21, 2026
e186099
docs(plans): record the last three findings from execution
rsml Jul 21, 2026
ef79cea
docs(adapters): warn that importing narration makes any rejection fatal
rsml Jul 21, 2026
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
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ node_modules/
dist/
dist-electron/
release/
books/
# Anchored to the repo root on purpose. An unanchored `books/` matches a
# directory named books at ANY depth, which silently swallowed the committed
# migration fixture libraries under server/migrations/__fixtures__/, since
# each one mirrors a real data directory and therefore contains its own
# books/ folder.
/books/
.env
*.log
.worktrees/
Expand Down
5 changes: 5 additions & 0 deletions client/features/reader/ReaderPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ export function ReaderPage({ book, onBack, onQuizReview, onUpdateProfile }: {
bufferBoundaryRef,
userHasScrolledRef,
scrollRef,
// An auth failure is the one generation error the reader can actually
// do something about, so it opens the dialog that fixes it rather than
// leaving the user staring at a retry button that will fail identically
// until the key is corrected.
onAuthFailure: () => setMissingKeyAlert(true),
})

// Poll for external chapter updates (e.g. from Claude Code via MCP)
Expand Down
26 changes: 24 additions & 2 deletions client/features/reader/hooks/useGenerationResume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ export interface UseGenerationResumeOptions {
bufferBoundaryRef: MutableRefObject<number>
userHasScrolledRef: MutableRefObject<boolean>
scrollRef: RefObject<HTMLElement | null>
/**
* Called when a generation failed specifically because the provider
* rejected the credentials. The reader opens its existing missing-key
* dialog, which is actionable, instead of leaving the user with a generic
* error they have no way to resolve from the reader.
*/
onAuthFailure?: () => void
}

/**
Expand Down Expand Up @@ -59,6 +66,7 @@ export function useGenerationResume({
bufferBoundaryRef,
userHasScrolledRef,
scrollRef,
onAuthFailure,
}: UseGenerationResumeOptions): void {
useEffect(() => {
let cancelled = false
Expand All @@ -72,8 +80,21 @@ export function useGenerationResume({
// Check merged generation status
if (data.generation.active) {
const gen = data.generation
// If already done/error, just use the metadata we already have
if (gen.stage === 'done' || gen.stage === 'error') return
if (gen.stage === 'done') return

// A generation that already failed before this reader mounted,
// including one the server marked interrupted when it restarted
// mid-chapter. Previously this returned silently, which left the
// reader sitting in whatever phase it had, showing nothing and
// offering no way forward, even though the retry affordance for
// exactly this case already existed one phase away.
if (gen.stage === 'error') {
setGenerationStage(null)
setGenerationError(gen.error ?? 'Generation was interrupted.')
setPhase('generation-error')
if (gen.errorKind === 'auth-failed') onAuthFailure?.()
return
}

// Active generation — set phase immediately and connect to stream
setGeneratingChapterNum(gen.chapterNum)
Expand Down Expand Up @@ -107,6 +128,7 @@ export function useGenerationResume({
setGenerationStage(null)
setGenerationError(event.message)
setPhase('generation-error')
if (event.kind === 'auth-failed') onAuthFailure?.()
}
})
}
Expand Down
165 changes: 165 additions & 0 deletions docs/plans/refactor/phase-7.md

Large diffs are not rendered by default.

20 changes: 12 additions & 8 deletions e2e/journeys/adaptive-loop.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@ import { bookRepository, seedBook } from '../support/seed.js'
* response's, filed as github.com/rsml/tutor/issues/50 and out of scope for
* this phase. TEST 1 therefore never asserts on-screen chapter 2 content and
* never waits on it, it waits on the model's own recorded request instead.
* TEST 2 is the on-screen assertion, quarantined with test.fixme until issue
* 50 lands, so a reader does not mistake TEST 1's silence on rendering for
* an oversight.
* TEST 2 is the on-screen assertion. It was quarantined with test.fixme
* until issue 50 landed, so a reader would not mistake TEST 1's silence on
* rendering for an oversight. Phase 7 fixed issue 50 and TEST 2 now runs.
* TEST 1 is deliberately left as it is rather than folded into TEST 2,
* because asserting the adaptive loop through the model's recorded request
* is a different and stronger claim than asserting pixels.
*
* See support/journeys/quiz.ts for why every quiz option below is located by
* exact text rather than position. generate-quiz.ts shuffles the options
Expand Down Expand Up @@ -96,11 +99,12 @@ test('quiz answers and chapter feedback shape the chapter-2 prompt the model act
expect(chapterTwo?.prompt).toContain(wronglyAnswered.question)
})

test.fixme('renders chapter 2 in the reader once generation finishes', async ({ page, app }) => {
// Quarantined by github.com/rsml/tutor/issues/50. pipeHubToSse closes the
// SSE reply with zero bytes before the browser ever hears "done", so
// chapter 2 never reaches the screen today. Flip back to test() once that
// lands, nothing else here should need to change.
test('renders chapter 2 in the reader once generation finishes', async ({ page, app }) => {
// Was quarantined by github.com/rsml/tutor/issues/50, where pipeHubToSse
// closed the SSE reply with zero bytes before the browser ever heard
// "done", so chapter 2 never reached the screen. Fixed in Phase 7 by
// watching the response for disconnect instead of the request. Nothing
// else in this test had to change, exactly as predicted.
await seedBook(app.dataDir, { generatedUpTo: 1 })

await page.goto('/')
Expand Down
53 changes: 41 additions & 12 deletions e2e/journeys/generation-failure.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type GenerationFailureCase,
} from '../support/journeys/generation-failure.js'
import { test } from '../support/app.js'
import { TextGenerationError } from '@server/ports/text-generation.js'

/**
* Journey (h) locks in that a scripted provider failure reaches the reader
Expand All @@ -16,18 +17,18 @@ import { test } from '../support/app.js'
* to CASES below. The navigation and assertions in the support module do
* not change.
*
* The reader-path cases below are `test.fixme`, not `test`, because issue
* #50 means `POST /api/books/:id/generate-next` never delivers an SSE event
* to a real browser at all, success or failure, so no assertion against
* GenerationPanel can pass yet. The wizard case is unaffected and carries
* journey (h)'s claim today. Flip `test.fixme` back to `test` once #50
* lands.
* The reader-path cases below were quarantined with `test.fixme` while
* issue #50 meant `POST /api/books/:id/generate-next` never delivered an SSE
* event to a real browser at all, success or failure, so no assertion
* against GenerationPanel could pass. Phase 7 fixed that, the listener now
* watches the response rather than the request, so they run.
*/

/**
* A stand-in for whatever typed error class Phase 7's AI error taxonomy
* introduces. The point of this case is only that a subclass survives
* `throws` all the way to the screen, not this particular shape.
* A stand-in kept from before Phase 7's taxonomy existed. It still earns its
* place: it proves an arbitrary subclass survives `throws` all the way to
* the screen, which is a weaker and more general claim than the
* TextGenerationError cases below make.
*/
class ScriptedProviderError extends Error {
constructor(message: string) {
Expand All @@ -36,6 +37,35 @@ class ScriptedProviderError extends Error {
}
}

/**
* One case per error class Phase 7's taxonomy can produce, built with the
* real `TextGenerationError` rather than a stand-in. `reason` is the
* class's `message`, and the SSE error event carries exactly that, so what
* the user reads is what the adapter decided.
*
* The three chosen are the ones whose handling genuinely differs.
* `auth-failed` never retries and routes the reader to the missing-key
* dialog, `rate-limited` retries up to four times, and `content-refused`
* never retries because retrying a refusal just earns another refusal.
*/
const TAXONOMY_CASES: GenerationFailureCase[] = [
{
name: 'an auth-failed TextGenerationError',
thrown: new TextGenerationError('auth-failed', 'No API key configured for provider: anthropic', false),
expected: /No API key configured for provider: anthropic/,
},
{
name: 'a rate-limited TextGenerationError',
thrown: new TextGenerationError('rate-limited', 'The provider is rate limiting this key. Try again shortly.', true),
expected: /rate limiting this key/,
},
{
name: 'a content-refused TextGenerationError',
thrown: new TextGenerationError('content-refused', 'The provider declined to generate this content.', false),
expected: /declined to generate this content/,
},
]

const CASES: GenerationFailureCase[] = [
{
name: 'a plain error with a distinctive message',
Expand All @@ -47,12 +77,11 @@ const CASES: GenerationFailureCase[] = [
thrown: new ScriptedProviderError('rate limited after 3 retries, provider returned HTTP 529'),
expected: /rate limited after 3 retries, provider returned HTTP 529/,
},
...TAXONOMY_CASES,
]

for (const failureCase of CASES) {
// Quarantined behind issue #50 (pipeHubToSse ends /generate-next's SSE
// reply before any event is delivered). Not run until that lands.
test.fixme(`chapter generation failure reaches the reader intact: ${failureCase.name}`, async ({ page, app, model }) => {
test(`chapter generation failure reaches the reader intact: ${failureCase.name}`, async ({ page, app, model }) => {
await runGenerationFailureCase({ page, app, model }, failureCase)
})
}
Expand Down
Loading
Loading