textmachine/backend/internal/pipeline/runevents_test.go

673 lines
22 KiB
Go

package pipeline
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/chunk/chunktest"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/runevents"
"textmachine/backend/internal/store"
)
// runevents_test.go: the driver's half of the run-event seam (row 103) — the stream a real run leaves
// behind, read back exactly as the platform's tailer reads it.
// readJournal returns the journal beside a book config, as decoded envelopes and as raw lines.
func readJournal(t *testing.T, bookPath string) ([]runevents.Envelope, [][]byte) {
t.Helper()
body, err := os.ReadFile(filepath.Join(filepath.Dir(bookPath), runevents.JournalFile))
if err != nil {
t.Fatalf("read journal: %v", err)
}
if len(body) > 0 && body[len(body)-1] != '\n' {
t.Fatalf("the journal ends mid-line — a line and its newline leave in one write: %q", tail(body))
}
var envs []runevents.Envelope
var raw [][]byte
for _, line := range strings.Split(strings.TrimSuffix(string(body), "\n"), "\n") {
if line == "" {
continue
}
var ev runevents.Envelope
if err := json.Unmarshal([]byte(line), &ev); err != nil {
t.Fatalf("malformed journal line %q: %v", line, err)
}
envs = append(envs, ev)
raw = append(raw, []byte(line))
}
return envs, raw
}
func tail(b []byte) string {
if len(b) > 120 {
return string(b[len(b)-120:])
}
return string(b)
}
// payload decodes one event's data.
func payload[T any](t *testing.T, ev runevents.Envelope) T {
t.Helper()
var v T
if err := json.Unmarshal(ev.Data, &v); err != nil {
t.Fatalf("decode %s payload: %v", ev.Type, err)
}
return v
}
// assertOneStream checks the invariants the reader enforces over ONE process's region: it opens with a
// hello at seq 1 and every following number is exactly one more than the last.
func assertOneStream(t *testing.T, envs []runevents.Envelope) {
t.Helper()
if len(envs) == 0 {
t.Fatal("empty stream")
}
if envs[0].Type != runevents.TypeHello || envs[0].Seq != 1 {
t.Fatalf("a stream must open with hello at seq 1, got %s at seq %d", envs[0].Type, envs[0].Seq)
}
for i, ev := range envs {
if ev.Seq != int64(i+1) {
t.Fatalf("seq %d at position %d — the numbering must be dense, a gap is fatal to the reader", ev.Seq, i)
}
}
}
// assertProgressSane checks what a progress bar is allowed to do: never pass its own denominator, and
// never go backwards. A reader ASSIGNS these counters, so both would be visible to a user.
func assertProgressSane(t *testing.T, envs []runevents.Envelope) {
t.Helper()
var prev runevents.Progress
for _, ev := range envs {
if ev.Type != runevents.TypeProgress {
continue
}
p := payload[runevents.Progress](t, ev)
if p.Draft.Done > p.Draft.Total || p.Edit.Done > p.Edit.Total {
t.Fatalf("progress %+v passes its own denominator at seq %d", p, ev.Seq)
}
if p.Draft.Done < prev.Draft.Done || p.Edit.Done < prev.Edit.Done {
t.Fatalf("progress went backwards at seq %d: %+v after %+v", ev.Seq, p, prev)
}
prev = p
}
}
// regions splits a journal into its per-PROCESS streams: a resumed run appends a second hello to the
// same file, which is exactly what the tailer keys on.
func regions(envs []runevents.Envelope) [][]runevents.Envelope {
var out [][]runevents.Envelope
for _, ev := range envs {
if ev.Type == runevents.TypeHello {
out = append(out, nil)
}
if len(out) == 0 {
continue
}
out[len(out)-1] = append(out[len(out)-1], ev)
}
return out
}
func tracedCtx() context.Context {
return obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
}
func TestARunLeavesAStreamThatOpensWithItsHandshake(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
r := newRunner(t, bookPath)
if _, err := r.TranslateBook(tracedCtx()); err != nil {
t.Fatal(err)
}
r.Close()
envs, _ := readJournal(t, bookPath)
assertOneStream(t, envs)
assertProgressSane(t, envs)
h := payload[runevents.Hello](t, envs[0])
if h.StreamVersion != runevents.StreamVersion || h.BookID != "test-book" || h.EngineRunID == "" {
t.Fatalf("handshake does not identify the stream: %+v", h)
}
if h.ChunkerVersion != chunkerVersion {
t.Fatalf("hello must carry the chunker version the manifest was cut by: %q", h.ChunkerVersion)
}
seen := map[runevents.Type]int{}
for _, ev := range envs {
seen[ev.Type]++
}
for _, want := range []runevents.Type{runevents.TypeProgress, runevents.TypeUnitDone, runevents.TypeSpend, runevents.TypeFinished} {
if seen[want] == 0 {
t.Errorf("a clean run must announce %s", want)
}
}
last := envs[len(envs)-1]
if last.Type != runevents.TypeFinished {
t.Fatalf("the last line of a finished run must be `finished`, got %s", last.Type)
}
if got := payload[runevents.Finished](t, last).Outcome; got != runevents.OutcomeClean {
t.Fatalf("outcome %q, want %q", got, runevents.OutcomeClean)
}
// The counters are in OUTPUT UNITS and both waves finish the one unit this fixture has.
var lastProgress runevents.Progress
for _, ev := range envs {
if ev.Type == runevents.TypeProgress {
lastProgress = payload[runevents.Progress](t, ev)
}
}
if lastProgress.Draft != (runevents.Counter{Done: 1, Total: 1}) || lastProgress.Edit != (runevents.Counter{Done: 1, Total: 1}) {
t.Fatalf("final counters %+v, want 1/1 in both waves", lastProgress)
}
}
func TestEveryLineOnTheFileIsTheOutboxRowVerbatim(t *testing.T) {
// The projection is a COPY, never a re-render: the reader compares a re-read line against the sha256
// it stored, so a line rebuilt with a fresher timestamp reads as corruption and quarantines the run's
// projection. This is the property that makes retrying a failed journal write safe.
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
r := newRunner(t, bookPath)
if _, err := r.TranslateBook(tracedCtx()); err != nil {
t.Fatal(err)
}
envs, raw := readJournal(t, bookPath)
runID := payload[runevents.Hello](t, envs[0]).EngineRunID
stored, err := r.Store.PendingEvents(runID, 0, 1_000_000)
if err != nil {
t.Fatal(err)
}
r.Close()
if len(stored) != len(raw) {
t.Fatalf("the outbox holds %d lines, the file %d", len(stored), len(raw))
}
for i := range raw {
if stored[i].Seq != int64(i+1) || string(stored[i].Line) != string(raw[i]) {
t.Fatalf("line %d diverged:\n file %s\noutbox %s", i+1, raw[i], stored[i].Line)
}
}
}
func TestAResumedRunOpensItsOwnStreamAndDoesNotRecountFinishedUnits(t *testing.T) {
// A resumed run walks every finished unit again at $0. `unit_done` is the vocabulary's only COUNTING
// event, so re-announcing those units would add a whole book to a reader's chapter counters on every
// resume — and `progress`, which a reader ASSIGNS, must not walk its bar backwards either.
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(tracedCtx()); err != nil {
t.Fatal(err)
}
r1.Close()
r2 := newRunner(t, bookPath)
if _, err := r2.TranslateBook(tracedCtx()); err != nil {
t.Fatal(err)
}
r2.Close()
envs, _ := readJournal(t, bookPath)
rs := regions(envs)
if len(rs) != 2 {
t.Fatalf("want two process regions in one book journal, got %d", len(rs))
}
first, second := rs[0], rs[1]
for _, region := range rs {
assertOneStream(t, region)
assertProgressSane(t, region)
}
idA := payload[runevents.Hello](t, first[0]).EngineRunID
idB := payload[runevents.Hello](t, second[0]).EngineRunID
if idA == idB {
t.Fatal("each process must name itself: the idempotency key is (engine_run_id, seq)")
}
for _, ev := range second {
if ev.Type == runevents.TypeUnitDone {
t.Fatalf("the resumed run re-announced a unit it only replayed at $0: %s", ev.Data)
}
}
// Its first progress line carries the BOOK's state, not this process's work.
for _, ev := range second {
if ev.Type == runevents.TypeProgress {
p := payload[runevents.Progress](t, ev)
if p.Draft.Done != 1 || p.Edit.Done != 1 {
t.Fatalf("a resumed run's first counters %+v — a bar that starts at zero walks backwards", p)
}
break
}
}
}
func TestACeilingHaltAnnouncesItselfAndNamesWhichCeiling(t *testing.T) {
// PD-113: the stop is resumable, so it is not a failure — and the platform's contract forbids calling
// it `failed`. Before this event, a ceiling stop reached the platform only as exit 1.
for _, c := range []struct {
name string
bookUSD float64
dayUSD float64
wantScope string
}{
{"the book ceiling", 0.0000001, 2.0, runevents.ScopeBook},
{"the day ceiling", 1.0, 0.0000001, runevents.ScopeDay},
} {
t.Run(c.name, func(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupCeilingProject(t, srv.URL, c.bookUSD, c.dayUSD)
r := newRunner(t, bookPath)
_, err := r.TranslateBook(tracedCtx())
r.Close()
var halt *CeilingHalt
if !errors.As(err, &halt) {
t.Fatalf("want a typed ceiling halt, got %v", err)
}
if halt.Scope != c.wantScope {
t.Fatalf("scope %q, want %q", halt.Scope, c.wantScope)
}
if !errors.Is(err, errReserveCeiling) {
t.Fatal("the typed halt must keep matching the sentinel every degrade path uses")
}
envs, _ := readJournal(t, bookPath)
assertOneStream(t, envs)
if len(envs) < 2 {
t.Fatalf("want the ceiling and the terminal line, got %d events", len(envs))
}
ceiling, finished := envs[len(envs)-2], envs[len(envs)-1]
if ceiling.Type != runevents.TypeCeiling {
t.Fatalf("want a ceiling event before the terminal line, got %s", ceiling.Type)
}
c2 := payload[runevents.Ceiling](t, ceiling)
if !c2.Halted || c2.Scope != c.wantScope {
t.Fatalf("ceiling event %+v, want halted with scope %q", c2, c.wantScope)
}
if got := payload[runevents.Finished](t, finished).Outcome; got != runevents.OutcomeCeiling {
t.Fatalf("terminal outcome %q, want %q — `failed` is what the contract forbids here", got, runevents.OutcomeCeiling)
}
// Money never leaves the engine except inside `spend`: the halt says THAT, never how much.
if body := string(ceiling.Data); strings.Contains(body, "$") || strings.Contains(body, "usd") {
t.Fatalf("the ceiling event must carry the fact and no figures: %s", body)
}
})
}
}
func TestAReadOnlyProjectionWritesNoStream(t *testing.T) {
// `status`/`report` describe a run; they do not run one. Appending to a stream that says "a process
// is working on this book" from a read-only command would be a lie a reader acts on.
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
r := newRunner(t, bookPath)
if _, err := r.TranslateBook(tracedCtx()); err != nil {
t.Fatal(err)
}
r.Close()
before, err := os.ReadFile(filepath.Join(filepath.Dir(bookPath), runevents.JournalFile))
if err != nil {
t.Fatal(err)
}
ro, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
if _, err := ro.Status(tracedCtx()); err != nil {
t.Fatal(err)
}
ro.Close()
after, err := os.ReadFile(filepath.Join(filepath.Dir(bookPath), runevents.JournalFile))
if err != nil {
t.Fatal(err)
}
if string(before) != string(after) {
t.Fatal("a read-only projection appended to the run stream")
}
}
func TestTheStreamCountsUnitsAcrossAMultiChapterBook(t *testing.T) {
// The draft wave fans out over CHUNKS, the seam counts OUTPUT UNITS: one announcement per unit per
// wave, no more and no fewer, so a reader's per-chapter counters land on the manifest's own totals.
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
waveWorkers: 3,
epub: []chunktest.Chapter{
{ID: "c1", Href: "ch1.xhtml", Body: `<p>静かな図書館の朝。</p>`},
{ID: "c2", Href: "ch2.xhtml", Body: `<p>静かな図書館の昼。</p>`},
{ID: "c3", Href: "ch3.xhtml", Body: `<p>静かな図書館の夜。</p>`},
},
spine: []string{"c1", "c2", "c3"},
})
r := newRunner(t, bookPath)
res, err := r.TranslateBook(tracedCtx())
if err != nil {
t.Fatal(err)
}
r.Close()
envs, _ := readJournal(t, bookPath)
assertOneStream(t, envs)
assertProgressSane(t, envs)
perWave := map[string]map[int]int{runevents.WaveDraft: {}, runevents.WaveEdit: {}}
for _, ev := range envs {
if ev.Type != runevents.TypeUnitDone {
continue
}
u := payload[runevents.UnitDone](t, ev)
perWave[u.Wave][u.Chapter*1000+u.Unit]++
}
for wave, units := range perWave {
if len(units) != len(res.Chunks) {
t.Fatalf("%s wave announced %d units, the run produced %d", wave, len(units), len(res.Chunks))
}
for key, n := range units {
if n != 1 {
t.Fatalf("%s wave announced unit %d %d times — the reader folds this by increment", wave, key, n)
}
}
}
}
// setupCeilingProject is setupProject with an explicit day ceiling, so the two ceiling scopes can be
// exercised apart. (setupProjectOpts pins day_usd at 2.0.)
func setupCeilingProject(t *testing.T, providerURL string, bookUSD, dayUSD float64) string {
t.Helper()
base := setupProjectOpts(t, providerURL, projectOpts{bookUSD: 1.0})
body, err := os.ReadFile(base)
if err != nil {
t.Fatal(err)
}
patched := strings.Replace(string(body), "ceilings: { book_usd: 1, day_usd: 2.0 }",
fmt.Sprintf("ceilings: { book_usd: %g, day_usd: %g }", bookUSD, dayUSD), 1)
if patched == string(body) {
t.Fatalf("the ceilings line moved; fixture needs updating:\n%s", body)
}
writeFile(t, base, patched)
return base
}
// storedRows is the book's dispositions, for the crash test's cross-check.
func storedRows(t *testing.T, r *Runner) []store.ChunkStatus {
t.Helper()
rows, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
if err != nil {
t.Fatal(err)
}
return rows
}
func TestAReusedRunIdDoesNotReopenTheEarlierStream(t *testing.T) {
// Since row 102 the CALLER supplies the run id (TM_TRACE_ID), and a caller that exports it in a
// wrapper reuses it. The seam's identity is per PROCESS, so a reused id used to make the second run
// continue the first one's numbering: its projection cursor starts at zero, so it re-appended the
// whole earlier stream and then wrote its handshake at a seq far past 1. A reader re-applies every
// counting event it already applied and finally refuses the handshake (hello must carry seq 1).
// Measured on the real binary before the guard: 33 lines, 15 byte-identical duplicates, hello at 16.
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
reused := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: "a-caller-that-reuses-its-id"})
for i := 0; i < 2; i++ {
r := newRunner(t, bookPath)
if _, err := r.TranslateBook(reused); err != nil {
t.Fatal(err)
}
r.Close()
}
envs, raw := readJournal(t, bookPath)
seen := map[string]int{}
for _, line := range raw {
seen[string(line)]++
}
for line, n := range seen {
if n > 1 {
t.Fatalf("line appears %d times — the second run re-projected the first one's stream: %s", n, line)
}
}
rs := regions(envs)
if len(rs) != 2 {
t.Fatalf("want one region per process, got %d", len(rs))
}
for _, region := range rs {
assertOneStream(t, region) // each opens with hello at seq 1 and is dense
}
ids := map[string]bool{}
for _, region := range rs {
ids[payload[runevents.Hello](t, region[0]).EngineRunID] = true
}
if len(ids) != 2 {
t.Fatalf("the two streams must not share an identity: %v", ids)
}
}
func TestAJournalThatCannotBeOpenedDoesNotStopThePaidRun(t *testing.T) {
// The PD-60 policy this pack argues for at length — degrade loudly, never let the journal stop the
// run — was exercised by nothing. A paid book must not die because a freshness side-channel is
// unavailable: the money protection is the platform's hold and the engine's own ceiling, neither of
// which depends on this file.
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
// A directory where the journal should be: every open of it fails, for the whole run.
if err := os.Mkdir(filepath.Join(filepath.Dir(bookPath), runevents.JournalFile), 0o755); err != nil {
t.Fatal(err)
}
r := newRunner(t, bookPath)
res, err := r.TranslateBook(tracedCtx())
r.Close()
if err != nil {
t.Fatalf("an unusable journal must not fail the run: %v", err)
}
if len(res.Chunks) != 1 || res.Flagged != 0 {
t.Fatalf("the run must have completed normally, got %+v", res)
}
if rec.count() == 0 {
t.Fatal("the run made no provider calls — it did not actually run")
}
}
func TestWhatTheUnitDidTravelsWithIt(t *testing.T) {
// shipped/flagged/reason are the payload a reader derives unit state from — shipped→translated,
// flagged without text→withheld. Nothing asserted them, so dropping `reason` or collapsing shipped
// into !flagged passed the whole battery.
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "", "stop" // the edit produces nothing usable → the unit is flagged and ships nothing
}
return "ЧЕРНОВИК ПЕРЕВОДА", "stop"
})
defer srv.Close()
bookPath := setupProject(t, srv.URL)
r := newRunner(t, bookPath)
if _, err := r.TranslateBook(tracedCtx()); err != nil {
t.Fatal(err)
}
r.Close()
envs, _ := readJournal(t, bookPath)
var draft, edit *runevents.UnitDone
for _, ev := range envs {
if ev.Type != runevents.TypeUnitDone {
continue
}
u := payload[runevents.UnitDone](t, ev)
switch u.Wave {
case runevents.WaveDraft:
draft = &u
case runevents.WaveEdit:
edit = &u
}
}
if draft == nil || edit == nil {
t.Fatalf("both waves must announce their unit, got draft=%v edit=%v", draft, edit)
}
if !draft.Shipped || draft.Flagged || draft.Reason != "" {
t.Fatalf("the draft resolved cleanly and must say so: %+v", *draft)
}
if edit.Shipped || !edit.Flagged || edit.Reason == "" {
t.Fatalf("a flagged unit that ships nothing must carry flagged + a reason and shipped=false: %+v", *edit)
}
}
func TestTheJournalFollowsTheBookNotTheProjectDatabase(t *testing.T) {
// The whole reason config.Book.Dir exists. The platform tails `events.jsonl` in the directory it
// spawned the run in — the one holding book.yaml — while `project_db` may point anywhere; pointing
// the journal at the database's directory instead passed every other test.
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
dir := filepath.Dir(bookPath)
if err := os.Mkdir(filepath.Join(dir, "state"), 0o755); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(bookPath)
if err != nil {
t.Fatal(err)
}
writeFile(t, bookPath, string(body)+"\nproject_db: state/test-book.db\n")
r := newRunner(t, bookPath)
if _, err := r.TranslateBook(tracedCtx()); err != nil {
t.Fatal(err)
}
r.Close()
if _, err := os.Stat(filepath.Join(dir, runevents.JournalFile)); err != nil {
t.Fatalf("the journal must sit beside book.yaml: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "state", runevents.JournalFile)); err == nil {
t.Fatal("the journal followed the project database instead of the book")
}
}
func TestTheStreamCarriesAnEstimateOnceItHasMeasuredOne(t *testing.T) {
// Omitting the field is not leaving it alone: the consumer's progress handler ASSIGNS the column
// (`eta_seconds = $6` with etaOrNil), so an absent field decodes to 0 and NULLs on every line the
// estimate the `status --json` resync had just written. The first line carries none — nothing has
// been measured yet — and every line after a resolved unit must.
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
epub: []chunktest.Chapter{
{ID: "c1", Href: "ch1.xhtml", Body: `<p>静かな図書館の朝。</p>`},
{ID: "c2", Href: "ch2.xhtml", Body: `<p>静かな図書館の昼。</p>`},
{ID: "c3", Href: "ch3.xhtml", Body: `<p>静かな図書館の夜。</p>`},
},
spine: []string{"c1", "c2", "c3"},
})
r := newRunner(t, bookPath)
if _, err := r.TranslateBook(tracedCtx()); err != nil {
t.Fatal(err)
}
r.Close()
envs, _ := readJournal(t, bookPath)
withETA, withWorkLeft := 0, 0
for _, ev := range envs {
if ev.Type != runevents.TypeProgress {
continue
}
p := payload[runevents.Progress](t, ev)
left := (p.Draft.Total - p.Draft.Done) + (p.Edit.Total - p.Edit.Done)
if left == 0 {
continue // a finished book has nothing to estimate
}
withWorkLeft++
if p.ETASeconds > 0 {
withETA++
}
}
if withWorkLeft == 0 {
t.Fatal("no progress line reported work remaining — the fixture cannot exercise this")
}
if withETA == 0 {
t.Fatalf("%d progress lines reported work remaining and none carried an estimate — each of them NULLs the reader's column", withWorkLeft)
}
}
func TestTwoBooksSharingOneProjectDatabaseBothGetAnnounced(t *testing.T) {
// The announce-once ledger lives in the project database, and `project_db` is a path a config may
// share. Without the book in the key, the second book's units collide with the first book's and are
// announced never — its chapters would sit at zero forever with no repair channel.
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
first := setupProject(t, srv.URL)
dir := filepath.Dir(first)
body, err := os.ReadFile(first)
if err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "source2.txt"), "静かな図書館の夜。")
second := filepath.Join(dir, "book2.yaml")
writeFile(t, second, strings.NewReplacer(
"book_id: test-book", "book_id: other-book",
"source_file: source.txt", "source_file: source2.txt",
).Replace(string(body))+"\nproject_db: test-book.db\n")
for _, path := range []string{first, second} {
r := newRunner(t, path)
if _, err := r.TranslateBook(tracedCtx()); err != nil {
t.Fatal(err)
}
r.Close()
}
envs, _ := readJournal(t, bookPathOf(dir))
perBook := map[string]int{}
book := ""
for _, ev := range envs {
if ev.Type == runevents.TypeHello {
book = payload[runevents.Hello](t, ev).BookID
}
if ev.Type == runevents.TypeUnitDone {
perBook[book]++
}
}
for _, id := range []string{"test-book", "other-book"} {
if perBook[id] == 0 {
t.Fatalf("book %q announced no unit; the ledger key collided across books: %v", id, perBook)
}
}
}
// bookPathOf names the journal's directory for readJournal, which takes a config path.
func bookPathOf(dir string) string { return filepath.Join(dir, "book.yaml") }