An unknown event type on a whole, chained line, or a journal.format_set
declaring a newer journal format, now raises StoreError::NewerJournal
(update wanderbooks) instead of JournalCorrupt. Corruption (bad hash,
seq, or JSON) is still corruption. The gate runs in the store, the
Snapshot and the GUI, after each line's parse, seq and hash checks, and
never skips a line it can't read. Adds the journal.format_set event
(format 1 today; nothing writes it yet) and a registry of every event
type this version reads, tested against the replay and projection arms.
Review fixes (P0 review, 2026-10-16):
- A line's own hash is checked only by the next line's prev, and the
gate used to refuse a line before reading the next: a flipped bit in a
middle line's type (or a format_set edited from 1 to 2) was reported
as "newer, the books are not damaged". Now the reader settles every
refusal against the next line (format::settle): a next line that
doesn't chain makes it corrupt; one that chains makes it newer and
verified; with no next line the message says "newer or damaged; update,
then run verify", never "not damaged" (NewerJournal.verified).
- A newer format number is judged newer before its features are parsed:
a newer format may shape them differently.
- Snapshot treats a blank complete line as corrupt, as the writer does.
- docs/spec/rules.md B-27, which the code cites, now exists.
Round-2 review fixes (2026-10-16):
- The envelope {seq, ts, type, payload, prev} and its hash (SHA-256 of
the canonical JSON) are frozen across formats (rules.md B-27); new
fields go in payload. settle reads only the next line's prev, leniently,
so a newer format's next line (a float, a string seq, keys of its own)
is never called corrupt: no readable prev means unverified newer.
- A key beside the envelope's was silently dropped (and the chain then
broke as "corrupt"): it is now NewerJournal, hashed over the raw
canonical JSON so the next line still checks it; JournalLine::parse
refuses such a line.
- "the books are not damaged" overclaimed: a verified refusal says line N
is intact and this version can't check the lines after it.
- The writer read the journal with BufRead::lines (invalid UTF-8 was an
I/O error); it now splits bytes on newlines as the Snapshot does, so a
non-UTF-8 line is JournalCorrupt in both.
- Store::open truncated a torn tail before the gate ran, altering newer
books (perhaps mid-append) before refusing them. The gate now runs over
the complete lines first; the tail is set aside (and journaled) only
when they pass.
- Mutation testing (docs/testing.md, P0): the viewer and the writer say
the same words about the same damaged line (the newline is no part of
it); read_journal_and_format returns every line and the format in
force; the journal page tells a format declaration.
Plan: plan/served.md P0 and 5.1.
Review round 3 (S6, S7). Store::open replays every complete line in
memory (the first line's currency, then the ledger) before a cut-off
last line is set aside, so a complete line the books can't apply
refuses the open with the journal byte for byte as it was and nothing
set aside (B-27 SCENARIO 3; test
a_bad_complete_line_before_a_torn_tail_leaves_the_journal_as_it_was);
catch_up now only projects. The viewer
applies the writer's first-line rule (created_currency, shared) instead
of guessing USD: any other first line, or a store.created without a
sound base_currency, is corrupt to both (test
the_first_line_is_store_created_for_the_viewer_and_the_writer). fuzz_store_replay varies the
first line (14,696 runs clean).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
226 lines
7.9 KiB
Rust
226 lines
7.9 KiB
Rust
//! The live loader. A background thread owns the read-only [`Snapshot`],
|
|
//! watches the data directory, and on every change applies the new journal
|
|
//! lines and sends a fresh [`Books`] to the UI, then asks for a repaint.
|
|
//! A change made by the agent in another window shows up within a frame or
|
|
//! two. Filesystem events are the trigger; a slow poll backs them up in case
|
|
//! an event is missed (network drives, odd editors).
|
|
|
|
use crate::books::{Books, Integrity};
|
|
use chrono::NaiveDate;
|
|
use ledger_config::BooksSettings;
|
|
use ledger_store::{Snapshot, StoreError};
|
|
use notify::{RecursiveMode, Watcher};
|
|
use std::collections::HashSet;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
/// Where the books come from.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Source {
|
|
/// The `books-data` directory (journal and blobs).
|
|
pub data_dir: PathBuf,
|
|
/// The company's `wanderbooks.toml`, if any.
|
|
pub books_toml: Option<PathBuf>,
|
|
/// Fix "today" (tests, screenshots); otherwise the local date.
|
|
pub today: Option<NaiveDate>,
|
|
}
|
|
|
|
pub enum Update {
|
|
Books(Arc<Books>),
|
|
Error(String),
|
|
/// The agent asked the viewer to show something (`books.show`).
|
|
Show(ledger_store::viewer::ViewerTarget),
|
|
}
|
|
|
|
pub struct Loader {
|
|
rx: Receiver<Update>,
|
|
}
|
|
|
|
impl Loader {
|
|
pub fn spawn(source: Source, ctx: egui::Context) -> Loader {
|
|
let (tx, rx) = channel();
|
|
std::thread::Builder::new()
|
|
.name("books-loader".into())
|
|
.spawn(move || run(source, ctx, tx))
|
|
.expect("spawn loader");
|
|
Loader { rx }
|
|
}
|
|
|
|
/// Every update since the last call, in order (books before the
|
|
/// navigation that refers to them).
|
|
pub fn poll(&self) -> Vec<Update> {
|
|
self.rx.try_iter().collect()
|
|
}
|
|
|
|
/// Block until the first update (tests, headless).
|
|
pub fn wait(&self, timeout: Duration) -> Option<Update> {
|
|
self.rx.recv_timeout(timeout).ok()
|
|
}
|
|
}
|
|
|
|
/// Load once, synchronously (tests and one-shot tools).
|
|
pub fn load_once(source: &Source) -> Result<Books, String> {
|
|
let snap = Snapshot::load(&source.data_dir).map_err(|e| {
|
|
problem(&e, || {
|
|
format!("Can't read the books in {}", source.data_dir.display())
|
|
})
|
|
})?;
|
|
let integrity = Integrity::check(&snap, &mut HashSet::new());
|
|
Books::build(&snap, settings(source)?, today(source), None, integrity)
|
|
}
|
|
|
|
/// What the viewer says when the books can't be read. Books from a newer
|
|
/// wanderbooks aren't broken, so they get the plain "update" message on its
|
|
/// own, never a failure framing; anything else is `context: error`.
|
|
pub fn problem(e: &StoreError, context: impl FnOnce() -> String) -> String {
|
|
match e {
|
|
StoreError::NewerJournal { .. } => {
|
|
let text = e.to_string();
|
|
let mut chars = text.chars();
|
|
chars
|
|
.next()
|
|
.map(|c| c.to_uppercase().chain(chars).collect())
|
|
.unwrap_or_default()
|
|
}
|
|
_ => format!("{}: {e}", context()),
|
|
}
|
|
}
|
|
|
|
fn today(source: &Source) -> NaiveDate {
|
|
source
|
|
.today
|
|
.unwrap_or_else(|| chrono::Local::now().date_naive())
|
|
}
|
|
|
|
fn settings(source: &Source) -> Result<BooksSettings, String> {
|
|
match &source.books_toml {
|
|
Some(p) if p.exists() => ledger_config::load(p),
|
|
_ => Ok(BooksSettings::default()),
|
|
}
|
|
}
|
|
|
|
fn mtime(p: &Option<PathBuf>) -> Option<SystemTime> {
|
|
p.as_ref()
|
|
.and_then(|p| std::fs::metadata(p).ok())
|
|
.and_then(|m| m.modified().ok())
|
|
}
|
|
|
|
fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
|
|
let send = |u: Update| {
|
|
let _ = tx.send(u);
|
|
ctx.request_repaint();
|
|
};
|
|
let mut snap = loop {
|
|
match Snapshot::load(&source.data_dir) {
|
|
Ok(s) => break s,
|
|
Err(e) => {
|
|
send(Update::Error(problem(&e, || {
|
|
format!("Can't read the books in {}", source.data_dir.display())
|
|
})));
|
|
std::thread::sleep(Duration::from_secs(2));
|
|
}
|
|
}
|
|
};
|
|
let mut verified: HashSet<String> = HashSet::new();
|
|
// Build the picture; also return the database check it was built with.
|
|
let mut build = |snap: &Snapshot, prev: Option<u64>| -> (Update, ledger_store::DiskCheck) {
|
|
let integrity = Integrity::check(snap, &mut verified);
|
|
let disk = integrity.disk.clone();
|
|
let update = match settings(&source)
|
|
.and_then(|s| Books::build(snap, s, today(&source), prev, integrity))
|
|
{
|
|
Ok(b) => Update::Books(Arc::new(b)),
|
|
Err(e) => Update::Error(e),
|
|
};
|
|
(update, disk)
|
|
};
|
|
let (first, disk) = build(&snap, None);
|
|
let mut last_disk = Some(disk);
|
|
send(first);
|
|
|
|
// Requests to show something: apply new ones, and on start one made in
|
|
// the last two minutes (the agent said "open the viewer"); never an
|
|
// old one.
|
|
let started = chrono::Utc::now();
|
|
let mut last_show = 0u64;
|
|
let mut check_show = |send: &dyn Fn(Update)| {
|
|
if let Some(cmd) = ledger_store::viewer::read(&source.data_dir)
|
|
&& cmd.seq != last_show
|
|
{
|
|
let first_look = last_show == 0;
|
|
last_show = cmd.seq;
|
|
let recent = chrono::DateTime::parse_from_rfc3339(&cmd.at)
|
|
.is_ok_and(|t| started.signed_duration_since(t) < chrono::Duration::minutes(2));
|
|
if !first_look || recent {
|
|
send(Update::Show(cmd.target));
|
|
}
|
|
}
|
|
};
|
|
check_show(&send);
|
|
|
|
let (ev_tx, ev_rx) = channel();
|
|
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
|
if res.is_ok() {
|
|
let _ = ev_tx.send(());
|
|
}
|
|
})
|
|
.ok();
|
|
if let Some(w) = watcher.as_mut() {
|
|
let _ = w.watch(&source.data_dir, RecursiveMode::NonRecursive);
|
|
let cmd = ledger_store::viewer::command_path(&source.data_dir);
|
|
if let Some(dir) = cmd.parent() {
|
|
let _ = std::fs::create_dir_all(dir);
|
|
let _ = w.watch(dir, RecursiveMode::NonRecursive);
|
|
}
|
|
if let Some(t) = &source.books_toml
|
|
&& let Some(dir) = t.parent()
|
|
{
|
|
let _ = w.watch(dir, RecursiveMode::NonRecursive);
|
|
}
|
|
}
|
|
let mut toml_mtime = mtime(&source.books_toml);
|
|
let mut day = today(&source);
|
|
loop {
|
|
match ev_rx.recv_timeout(Duration::from_secs(3)) {
|
|
Ok(()) => {
|
|
// Let a burst of writes settle, then take them all at once.
|
|
std::thread::sleep(Duration::from_millis(30));
|
|
while ev_rx.try_recv().is_ok() {}
|
|
}
|
|
Err(RecvTimeoutError::Timeout) => {}
|
|
Err(RecvTimeoutError::Disconnected) => {
|
|
std::thread::sleep(Duration::from_secs(3));
|
|
}
|
|
}
|
|
let before = snap.journal_seq();
|
|
let grew = match snap.refresh() {
|
|
Ok(n) => n > 0,
|
|
Err(e) => {
|
|
send(Update::Error(problem(&e, || {
|
|
"Reading the journal failed".into()
|
|
})));
|
|
continue;
|
|
}
|
|
};
|
|
let toml_now = mtime(&source.books_toml);
|
|
let new_day = today(&source);
|
|
// The writer journals first and updates its database right after:
|
|
// a "behind" database usually catches up a moment later, so look
|
|
// again rather than leave a stale warning up.
|
|
let disk_changed = !grew
|
|
&& last_disk
|
|
.as_ref()
|
|
.is_some_and(|d| *d != ledger_store::DiskCheck::Matches && snap.check_disk() != *d);
|
|
if grew || disk_changed || toml_now != toml_mtime || new_day != day {
|
|
toml_mtime = toml_now;
|
|
day = new_day;
|
|
let (update, disk) = build(&snap, grew.then_some(before));
|
|
last_disk = Some(disk);
|
|
send(update);
|
|
}
|
|
check_show(&send);
|
|
}
|
|
}
|