Files
wanderbooks/crates/ledger-gui/src/data.rs
T
swaitsandClaude Opus 5.5 7741e96eb3
check / check (push) Canceled after 0s
refactor!: one way to serve books: the stdio server is gone (D-44)
The owner (2026-10-23): "We should probably just delete the stdio path
altogether. THere's no point in that. And it sitting there unused will
just lead to bitrot. Nuke it?"

Books are served only by the books server (`wanderbooks server`),
reached by agents through the bridge (`wanderbooks mcp --book ID`),
which stays.

Removed: `wanderbooks dev-stdio`, the `ledger-mcp` binary, and
`ledger_mcp::run` (stdio and `--http` over a bare data dir), with all
that only that mode had:
- files the books make coming back inline: `HandedFile.content_base64`
  is gone, and `download` and `expires_at` are always there;
- books with no access log or backups: `books.access_log`,
  `books.backup` and `books.back_up_now` are always offered, by scope;
- the settings file read and reloaded (`legacy_files`), the vault key
  named by a file or `--vault-key-file`, and the books dir's
  key-inside-the-books and git checks (with `paths.rs`);
- `wire.rs` (the bridge frames an agent's lines itself);
- `books.settings`' file fields and `books.start`'s `settings_source`.

`BooksServer` is built one way, from `Served` (a book as the books
server serves it), in production and in the unit tests. An unknown tool
is now a protocol error (mcp-tools.md). Inline file input is unchanged.

Then the owner's three decisions (2026-10-23, the question tool):
(a) `books.import_settings` is removed entirely (118 tools): old books
come in whole by `wanderbooks books adopt`, which reads their settings
file once; ledger-config keeps its settings-file parsing only as far as
adoption uses it (`load` and the path-reading `effective_receipt_policy`
are gone), and `books.chart_template` gives its settings as
`books.set_setting` changes. (b) The viewer's loose-books-folder mode is
removed (`--data-dir`, `--books-toml`, `--vault-key-file`, the settings
file and config.json followed): it opens only books of the data root,
reading settings from the journal and the vault from the book's own
`vault/` and `keys/`. (c) `wanderbooks dev-stdio` has no stub: it is an
unknown command like any other word. Also: an upload's expiry past the
calendar is an error, never an empty string.

Tests: every suite that drove the stdio server moved to
crates/ledger-cli/tests/ and drives a real `wanderbooks server` on a
data root of its own (tests/common), handed files by their download;
mcp_stdio.rs is mcp_journeys.rs. Tests of what is gone are deleted and
listed in docs/testing.md. Docs: plan D-44 (D-18 superseded), STATUS,
testing, rules, mcp-interface, the handbook, README; justfile recipes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-23 19:45:18 -06:00

256 lines
9.1 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;
/// Where the books come from: their folder, a book of the data root
/// (`wanderbooks gui ID`; D-44). Their settings are their own, in their
/// journal (plan 3.6), read as the server reads them.
#[derive(Clone, Debug)]
pub struct Source {
/// The books' folder (journal and blobs).
pub data_dir: 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::ops::ViewerTarget),
}
pub struct Loader {
rx: Receiver<Update>,
/// Set when this is dropped: the loader's thread then ends, letting go
/// of the books (and the viewer's presence on them).
gone: Arc<std::sync::atomic::AtomicBool>,
}
impl Drop for Loader {
fn drop(&mut self) {
self.gone.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
impl Loader {
pub fn spawn(source: Source, ctx: egui::Context) -> Loader {
let (tx, rx) = channel();
let gone = Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop = gone.clone();
std::thread::Builder::new()
.name("books-loader".into())
.spawn(move || run(source, ctx, tx, &stop))
.expect("spawn loader");
Loader { rx, gone }
}
/// 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());
let settings = settings(&snap);
let today = today(source, &settings);
Books::build(&snap, settings, today, 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()),
}
}
/// Today: as fixed for tests, else in the company's time zone, else the
/// computer's.
fn today(source: &Source, settings: &BooksSettings) -> NaiveDate {
source
.today
.unwrap_or_else(|| ledger_core::settings::today_in(settings.timezone.as_deref()))
}
/// The settings: the books' own, from their journal, as the server reads
/// them (the defaults until the first is set).
pub fn settings(snap: &Snapshot) -> BooksSettings {
ledger_config::codec::books_settings(
snap.ledger().settings(),
snap.ledger().base_currency().code(),
)
}
fn run(
source: Source,
ctx: egui::Context,
tx: Sender<Update>,
gone: &std::sync::atomic::AtomicBool,
) {
let gone = || gone.load(std::sync::atomic::Ordering::SeqCst);
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())
})));
if gone() {
return;
}
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 s = settings(snap);
let today = today(&source, &s);
let update = match Books::build(snap, s, today, 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::ops::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);
}
// The books' `ops/`, once the server has made it (the viewer makes
// nothing): its show requests watched, and this viewer's presence
// held there while it runs (ledger_store::ops INVARIANT 3).
let ops = ledger_store::ops::ops_dir(&source.data_dir);
let mut ops_watched = false;
let mut presence: Option<ledger_store::ops::Presence> = None;
let mut look_at_ops = |watcher: &mut Option<notify::RecommendedWatcher>| {
if !ops_watched
&& ops.is_dir()
&& let Some(w) = watcher.as_mut()
{
ops_watched = w.watch(&ops, RecursiveMode::NonRecursive).is_ok();
}
if presence.is_none() {
presence = ledger_store::ops::Presence::hold(&source.data_dir);
}
};
look_at_ops(&mut watcher);
let day_now = |snap: &Snapshot| today(&source, &settings(snap));
let mut day = day_now(&snap);
loop {
if gone() {
return;
}
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;
}
};
look_at_ops(&mut watcher);
let new_day = day_now(&snap);
// 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 || new_day != day {
day = new_day;
let (update, disk) = build(&snap, grew.then_some(before));
last_disk = Some(disk);
send(update);
}
check_show(&send);
}
}