P2 slice 3: opening and closing the books (plan 3.9, D-21, D-23)

One wanderbooks executable. The books are open while the viewer, an
agent session or a terminal holds them: a host process takes leases on
a socket, starts the server (the hidden mode `wanderbooks server`) with
a pipe on its input, and closes it 10 s after the last lease ends. No
background service. One host even in a race (run/host.lock).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:19 -06:00
1 parent 168e5c9846
commit a298d154b6
31 files changed
+11937 -112

No files matched your search

+369
View File
@@ -0,0 +1,369 @@
//! The admin socket's messages (`run/admin.sock`, plan 3.1, 3.9): how the
//! command line asks the books server to list, create and adopt books.
//!
//! ```text
//! GOAL: the command line makes and adopts books without ever touching
//! the data root itself: the server, its sole writer, does it.
//! SOURCE: plan/served.md 3.1 (the CLI "talks to run/admin.sock ... it can
//! create or adopt books"), 5.5 (adoption), 3.9 (protocol versions
//! N and N-1).
//! INVARIANTS:
//! 1. One request per connection, one JSON line each way, at most
//! MAX_LINE bytes; anything else is refused with a plain reason.
//! 2. A request carries its protocol version; the server accepts this
//! version and the one before, and refuses others with a message
//! that says to restart.
//! 3. No answer names the data root or a path in it, nor echoes a path
//! the caller gave (which could be the root under another name).
//! 4. Adopting is two steps: without `confirmed` the answer is a
//! preview, and nothing is written; `confirmed: true` adopts, and
//! only books still at the head the preview read (`expect_head`).
//! 5. Nothing in a settings file is dropped silently: entries the books
//! don't know are refused unless `skip_unknown`, and its notes and
//! comments are in the preview.
//! ```
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// This protocol's version (INVARIANT 2).
pub const PROTO: u32 = 1;
/// The oldest version this side still accepts (INVARIANT 2: this one and
/// the one before; there is none before the first).
pub const OLDEST: u32 = 1;
/// Whether a request of protocol version `proto` is accepted.
pub fn accepts(proto: u32) -> bool {
(OLDEST..=PROTO).contains(&proto)
}
/// The longest line either side sends, its newline not counted
/// (INVARIANT 1): settings texts ride in an adopt request.
pub const MAX_LINE: usize = 4 * 1024 * 1024;
/// A request, with its protocol version.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Envelope {
/// The sender's protocol version.
pub proto: u32,
/// What is asked.
#[serde(flatten)]
pub request: Request,
}
/// What the command line can ask.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
pub enum Request {
/// The books the server holds.
List,
/// Start new, empty books.
Create {
/// The new books' id.
id: String,
/// Their base currency (ISO 4217), fixed for good.
currency: String,
},
/// Adopt existing books (plan 5.5).
Adopt {
/// The new books' id.
id: String,
/// The data dir to adopt (`journal.jsonl`, `blobs/`).
source: PathBuf,
/// The settings file's text, for books that keep no settings.
#[serde(default)]
settings_toml: Option<String>,
/// `config.json`'s text, for its receipt policy.
#[serde(default)]
config_json: Option<String>,
/// The source's TIN vault folder.
#[serde(default)]
vault_dir: Option<PathBuf>,
/// The v1 vault key file.
#[serde(default)]
vault_key: Option<PathBuf>,
/// Adopt (true), or only preview what would be moved in.
#[serde(default)]
confirmed: bool,
/// The head the preview read: adopted only if still so.
#[serde(default)]
expect_head: Option<(u64, String)>,
/// Leave out settings-file entries the books don't know.
#[serde(default)]
skip_unknown: bool,
},
}
/// One book, as listed.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BookStatus {
/// Its id.
pub id: String,
/// Whether it is open and served.
pub open: bool,
/// Why it couldn't be opened, if it couldn't.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub problem: Option<String>,
}
/// A setting an adoption would move in.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SettingChange {
/// Its key.
pub key: String,
/// Its value, as JSON (`None`: the setting is cleared).
pub value: Option<serde_json::Value>,
}
/// What an unconfirmed adoption would do (INVARIANT 4).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdoptPreview {
/// The source's last event, `(seq, hash)`, as read now.
pub source_head: (u64, String),
/// The source already keeps its own settings: none would be moved in.
pub keeps_settings: bool,
/// The settings that would be moved in.
pub settings: Vec<SettingChange>,
/// What the settings file says that isn't a books setting.
pub notes: Vec<String>,
/// Entries in it the books don't know (not moved in).
pub unknown: Vec<String>,
/// The settings file's comments, in order (they aren't settings: keep
/// what matters as a convention or a decision).
pub comments: Vec<String>,
/// The vault's records, each checked to open with the key given.
pub vault_records: usize,
}
/// What an adoption did.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Adopted {
/// The new books' id.
pub id: String,
/// The source's head, verified before copying.
pub source_head: (u64, String),
/// The adopted books' head.
pub head: (u64, String),
/// Stored files copied.
pub blob_files: u64,
/// Stored files the books name, re-hashed through the store.
pub blobs_verified: u64,
/// Stored files copied that the books don't name.
pub unnamed_blobs: u64,
/// Files left behind among the stored ones (unfinished writes, hidden
/// files).
pub skipped_files: u64,
/// Vault records copied.
pub vault_files: u64,
/// Whether the v1 key was copied.
pub key_copied: bool,
/// Settings moved in.
pub settings_imported: usize,
/// Settings were given, but the books keep their own.
pub settings_ignored: bool,
/// The source had no lock file, so one was made beside its journal.
pub source_lock_created: bool,
/// Why the adopted books didn't open, if they didn't (they are kept;
/// closing the books and opening them again tries again).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub problem: Option<String>,
}
/// The server's answer.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Response {
/// The books held.
Books(Vec<BookStatus>),
/// New books made.
Created {
/// Their id.
id: String,
/// Why they didn't open, if they didn't (they are kept).
#[serde(default, skip_serializing_if = "Option::is_none")]
problem: Option<String>,
},
/// What an adoption would do.
Preview(AdoptPreview),
/// Books adopted and open.
Adopted(Adopted),
/// Refused, with why (no path in the data root: INVARIANT 3).
Refused(String),
}
/// Why an admin call has no answer.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CallError {
/// The request never reached the server: nothing was done.
NotSent(String),
/// It was sent, and no answer came: it may or may not have been done.
Unknown(String),
}
impl std::fmt::Display for CallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CallError::NotSent(why) | CallError::Unknown(why) => f.write_str(why),
}
}
}
/// Ask the server on `socket` (the root's `run/admin.sock`), waiting as
/// long as it takes (a create or an adoption).
#[cfg(unix)]
pub fn call(socket: &std::path::Path, request: Request) -> Result<Response, CallError> {
call_within(socket, request, None)
}
/// [`call`], waiting at most `wait` for the answer (a quick look, such
/// as `doctor`'s).
#[cfg(unix)]
pub fn call_within(
socket: &std::path::Path,
request: Request,
wait: Option<std::time::Duration>,
) -> Result<Response, CallError> {
use std::io::{BufRead, Write};
let not_sent = |why: String| CallError::NotSent(why);
// A list changes nothing: its only question is whether it answered.
let reads_only = matches!(request, Request::List);
let unknown = move |why: String| {
if reads_only {
CallError::Unknown(why)
} else {
CallError::Unknown(format!(
"{why}, so it may or may not have been done: `wanderbooks books list` shows the books there are"
))
}
};
let quiet = |e: std::io::Error| match e.kind() {
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => {
"the books server isn't answering".to_string()
}
kind => format!("the books server didn't answer ({kind})"),
};
let (dir, name) = (
socket.parent().unwrap_or(std::path::Path::new(".")),
socket
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default(),
);
let short = ledger_store::root::SocketPath::new(dir, name)
.map_err(|e| not_sent(format!("can't reach the books server: {e}")))?;
let stream =
std::os::unix::net::UnixStream::connect(short.path()).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => {
not_sent("the books server isn't running".to_string())
}
_ => not_sent(format!("can't reach the books server: {}", e.kind())),
})?;
crate::local::check_peer_std(&stream).map_err(not_sent)?;
stream
.set_read_timeout(wait)
.map_err(|e| not_sent(e.to_string()))?;
let mut line = serde_json::to_string(&Envelope {
proto: PROTO,
request,
})
.map_err(|e| not_sent(e.to_string()))?;
line.push('\n');
// A write cut part way may still have reached the server whole.
(&stream).write_all(line.as_bytes()).map_err(|e| {
unknown(format!(
"the books server stopped taking the request ({})",
e.kind()
))
})?;
let mut answer = String::new();
std::io::BufReader::new(std::io::Read::take(&stream, MAX_LINE as u64 + 1))
.read_line(&mut answer)
.map_err(|e| unknown(quiet(e)))?;
if answer.is_empty() {
return Err(unknown("the books server stopped before answering".into()));
}
serde_json::from_str(answer.trim_end())
.map_err(|e| unknown(format!("the books server's answer isn't understood ({e})")))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn requests_are_one_flat_object_with_their_version() {
let line = serde_json::to_value(Envelope {
proto: PROTO,
request: Request::Create {
id: "acme".into(),
currency: "USD".into(),
},
})
.unwrap();
assert_eq!(
line,
json!({"proto": 1, "op": "create", "id": "acme", "currency": "USD"})
);
let back: Envelope = serde_json::from_value(line).unwrap();
assert_eq!(back.proto, 1);
let list: Envelope = serde_json::from_value(json!({"proto": 1, "op": "list"})).unwrap();
assert_eq!(list.request, Request::List);
}
#[test]
fn an_adopt_request_is_a_preview_unless_confirmed() {
let r: Envelope = serde_json::from_value(
json!({"proto": 1, "op": "adopt", "id": "acme", "source": "/x/books-data"}),
)
.unwrap();
match r.request {
Request::Adopt {
confirmed,
settings_toml,
vault_dir,
expect_head,
skip_unknown,
..
} => {
assert!(!confirmed && !skip_unknown);
assert!(settings_toml.is_none() && vault_dir.is_none() && expect_head.is_none());
}
other => panic!("{other:?}"),
}
}
#[test]
fn unknown_operations_and_fields_are_refused() {
for bad in [
json!({"proto": 1, "op": "delete", "id": "acme"}),
json!({"proto": 1, "op": "create", "id": "acme", "currency": "USD", "path": "/x"}),
json!({"proto": 1}),
] {
assert!(
serde_json::from_value::<Envelope>(bad.clone()).is_err(),
"{bad}"
);
}
}
#[test]
fn answers_round_trip() {
for r in [
Response::Books(vec![BookStatus {
id: "acme".into(),
open: false,
problem: Some("damaged".into()),
}]),
Response::Created {
id: "acme".into(),
problem: None,
},
Response::Refused("no".into()),
] {
let text = serde_json::to_string(&r).unwrap();
assert_eq!(serde_json::from_str::<Response>(&text).unwrap(), r);
}
}
}
+19 -1
View File
@@ -18,8 +18,12 @@
//! `/`, the home folder, or holds the books, the vault or a key is refused
//! at startup.
pub mod admin;
mod handbook;
#[cfg(unix)]
pub mod local;
mod local_files;
pub mod opening;
mod policy;
mod server;
mod settings;
@@ -34,6 +38,7 @@ use policy::ReceiptPolicy;
use rmcp::ServiceExt;
use serde::Deserialize;
use server::BooksServer;
pub use server::{NOT_RUN_CLOSING, NOT_RUN_CLOSING_CODE};
use std::path::PathBuf;
/// Server configuration, JSON. All fields optional.
@@ -138,6 +143,14 @@ pub fn run(
}
}
}
// Books in the data root are the books server's alone (plan 3.1):
// a second writer beside it is refused before anything is opened.
if ledger_store::root::inside_a_root(&data_dir) {
eprintln!(
"ledger-mcp: those books are in the books' private folder, which only the books server opens: connect to them with `wanderbooks mcp --book <id>`"
);
return Err(Box::new(Exit(2)));
}
let import_roots = if import_roots.is_empty() {
ImportRoots::defaults()
} else {
@@ -221,7 +234,12 @@ pub fn run(
// vault or a key (local_files.rs INVARIANT 0): a named one stops the
// server here, a default one is dropped.
let settings_now = live.lock().unwrap_or_else(|e| e.into_inner()).current();
let import_roots = match server::guard_import_roots(&store, &settings_now, import_roots) {
let import_roots = match server::guard_import_roots(
&store,
&settings_now,
&server::Layout::BooksDir,
import_roots,
) {
Ok(roots) => roots,
Err(why) => {
eprintln!("ledger-mcp: {why}");
File diff suppressed because it is too large. Load diff
+89
View File
@@ -0,0 +1,89 @@
//! What the agents' bridge answers `initialize` with while the books are
//! still opening (plan 3.9, "The bridge and slow starts").
//!
//! ```text
//! GOAL: an agent whose session starts while the books are closed gets
//! its `initialize` answered within its client's startup timeout,
//! with capabilities that are the server's own.
//! SOURCE: plan/served.md 3.9 (the bridge answers initialize itself after
//! 5 s, "from the tool list built into the same executable and a
//! short generic text that points at books.start").
//! INVARIANTS:
//! 1. The capabilities are the server's (one function builds both).
//! 2. The protocol version is the one asked for when this build knows
//! it and it has an initialize handshake, else the newest that does.
//! 3. The text names no book and no folder: it points at books.start.
//! ```
use rmcp::model::{Implementation, ProtocolVersion, ServerCapabilities, ServerConfig};
/// The text an agent gets while the books open (INVARIANT 3).
pub const OPENING_TEXT: &str = "wanderbooks: double-entry books, opening now. Call books.start first: it returns these books' rules, conventions and open items, and every procedure is in books.handbook. Calls wait while the books open.";
/// The server's capabilities (INVARIANT 1).
pub(crate) fn capabilities() -> ServerCapabilities {
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build()
}
/// The `initialize` result the bridge gives while the books open, for a
/// client that asked for protocol version `requested`.
pub fn opening_initialize(requested: Option<&str>) -> serde_json::Value {
let version = requested
.and_then(|asked| {
ProtocolVersion::KNOWN_VERSIONS
.iter()
.find(|v| v.as_str() == asked && v.has_initialize())
})
.cloned()
.unwrap_or(ProtocolVersion::LATEST_WITH_INITIALIZE);
let mut info = ServerConfig::new(capabilities())
.with_instructions(OPENING_TEXT)
.with_server_info(Implementation::new(
"wanderbooks",
env!("CARGO_PKG_VERSION"),
));
info.protocol_version = version;
serde_json::to_value(info).expect("an initialize result is plain data")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_version_asked_for_is_kept_when_known() {
for v in ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] {
assert_eq!(opening_initialize(Some(v))["protocolVersion"], v);
}
let newest = ProtocolVersion::LATEST_WITH_INITIALIZE.as_str().to_string();
for v in [
Some("1999-01-01"),
Some("2026-07-28"),
Some("nonsense"),
None,
] {
assert_eq!(
opening_initialize(v)["protocolVersion"],
newest.as_str(),
"{v:?}"
);
}
}
#[test]
fn it_names_the_server_and_points_at_books_start() {
let r = opening_initialize(None);
assert_eq!(r["serverInfo"]["name"], "wanderbooks");
assert_eq!(r["serverInfo"]["version"], env!("CARGO_PKG_VERSION"));
assert!(r["instructions"].as_str().unwrap().contains("books.start"));
assert_eq!(
r["capabilities"],
serde_json::to_value(capabilities()).unwrap()
);
assert!(r["capabilities"]["tools"].is_object());
assert!(r["capabilities"]["resources"].is_object());
}
}
+226 -48
View File
@@ -50,7 +50,7 @@ use ledger_store::Store;
use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::model::{
Implementation, ListResourcesResult, ReadResourceRequestParams, ReadResourceResponse,
ReadResourceResult, Resource, ResourceContents, ServerCapabilities, ServerConfig,
ReadResourceResult, Resource, ResourceContents, ServerConfig,
};
use rmcp::service::{RequestContext, RoleServer};
use rmcp::{ServerHandler, tool, tool_handler, tool_router};
@@ -70,6 +70,53 @@ pub struct BooksServer {
store: Arc<SharedStore>,
settings: Arc<Mutex<Live>>,
import_roots: Arc<ImportRoots>,
layout: Arc<Layout>,
}
/// Where a book's own files live around its data dir.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Layout {
/// A books dir, as before P2: the data dir is `books-data/` in a
/// project folder (the books dir), which holds `vault/`, the settings
/// file and perhaps git; the vault's key lives outside it.
BooksDir,
/// A book in the data root (plan 3.4): the data dir is `books/<id>/`,
/// holding its own `vault/` and its v1 key in `keys/legacy.key`. The
/// root (the path given) plays the books dir's part: nothing in it is
/// ever read or written through a path a tool names.
Root(std::path::PathBuf),
}
impl Layout {
/// What a tool never reads or writes through a path: the books dir,
/// or the whole data root.
fn books_dir(&self, store: &Store) -> std::path::PathBuf {
match self {
Layout::BooksDir => books_dir(store),
Layout::Root(root) => root.clone(),
}
}
/// The TIN vault's folder.
fn vault_dir(&self, store: &Store) -> std::path::PathBuf {
match self {
Layout::BooksDir => books_dir(store).join("vault"),
Layout::Root(_) => store.data_dir().join("vault"),
}
}
/// The vault's key file: in the data root always the book's own
/// `keys/legacy.key`, whatever a setting says; in a books dir the one
/// in force (`[vault] key_file`, else the default outside the books).
fn key_file(&self, store: &Store, settings: &Settings) -> std::path::PathBuf {
match self {
Layout::BooksDir => settings.key_file(),
Layout::Root(_) => store
.data_dir()
.join(ledger_store::root::KEYS_DIR)
.join(ledger_store::root::LEGACY_KEY_FILE),
}
}
}
/// The one store every session shares, and whether it may still be used.
@@ -86,10 +133,20 @@ pub struct BooksServer {
/// store), `stopped` is set and never cleared in this process.
/// 2. While `stopped`, every request is refused with STOPPED, before
/// it touches the store; a restart re-verifies the journal.
/// 3. Once `close` begins, no request starts running (each is refused
/// with NOT_RUN_CLOSING, untouched), and `close` returns only when
/// every request that did start has finished its blocking work,
/// whether or not anyone still waits for its answer.
/// ```
pub struct SharedStore {
store: tokio::sync::Mutex<Store>,
stopped: std::sync::atomic::AtomicBool,
/// Closing: no new request runs (INVARIANT 3).
closing: std::sync::atomic::AtomicBool,
/// Requests running (from before their blocking task to its end).
running: std::sync::atomic::AtomicUsize,
/// Woken whenever one ends.
ended: tokio::sync::Notify,
/// The books' base currency, fixed at their creation: read without
/// the lock (the connect text names it).
currency: Currency,
@@ -102,9 +159,31 @@ impl SharedStore {
currency: store.base_currency(),
store: tokio::sync::Mutex::new(store),
stopped: std::sync::atomic::AtomicBool::new(false),
closing: std::sync::atomic::AtomicBool::new(false),
running: std::sync::atomic::AtomicUsize::new(0),
ended: tokio::sync::Notify::new(),
})
}
/// Stop new requests from running (INVARIANT 3), and wait until every
/// one already running has ended: its blocking task finished, even if
/// its client hung up.
pub async fn close(&self) {
self.closing
.store(true, std::sync::atomic::Ordering::SeqCst);
loop {
let ended = self.ended.notified();
if self.running.load(std::sync::atomic::Ordering::SeqCst) == 0 {
return;
}
ended.await;
}
}
fn closing(&self) -> bool {
self.closing.load(std::sync::atomic::Ordering::SeqCst)
}
fn stopped(&self) -> bool {
self.stopped.load(std::sync::atomic::Ordering::SeqCst)
}
@@ -179,7 +258,15 @@ where
if shared.stopped() {
return Err(rmcp::ErrorData::internal_error(STOPPED, None));
}
// SharedStore INVARIANT 3: counted before the closing check, so a
// close never misses it; the count rides in the blocking task, which
// runs to its end even when this future is dropped.
let running = Running::new(shared);
if shared.closing() {
return Err(not_run_closing());
}
let joined = tokio::task::spawn_blocking(move || {
let _running = running;
struct Unpark(std::thread::Thread);
impl std::task::Wake for Unpark {
fn wake(self: Arc<Self>) {
@@ -205,6 +292,43 @@ where
})
}
/// A request running, counted in its store (SharedStore INVARIANT 3).
struct Running(Arc<SharedStore>);
impl Running {
fn new(shared: &Arc<SharedStore>) -> Self {
shared
.running
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Self(shared.clone())
}
}
impl Drop for Running {
fn drop(&mut self) {
self.0
.running
.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
self.0.ended.notify_waiters();
}
}
/// The JSON-RPC error code of a request refused because the books are
/// closing: it didn't run, so a client may send it again once they are
/// open (the agents' bridge does).
pub const NOT_RUN_CLOSING_CODE: i32 = -32050;
/// What a request refused while closing is told.
pub const NOT_RUN_CLOSING: &str = "the books are closing: this call wasn't run";
fn not_run_closing() -> rmcp::ErrorData {
rmcp::ErrorData::new(
rmcp::model::ErrorCode(NOT_RUN_CLOSING_CODE),
NOT_RUN_CLOSING,
None,
)
}
#[cfg(test)]
pub use crate::settings::CashRoles;
@@ -403,9 +527,17 @@ impl BooksServer {
..Settings::default()
}))),
import_roots: Arc::new(ImportRoots::defaults()),
layout: Arc::new(Layout::BooksDir),
}
}
/// Serve a book kept in the data root (`Layout::Root`) rather than
/// in a books dir.
pub fn with_layout(mut self, layout: Layout) -> Self {
self.layout = Arc::new(layout);
self
}
/// The folders `books.import_local_file` may read from (local_files.rs).
pub fn with_import_roots(mut self, roots: ImportRoots) -> Self {
self.import_roots = Arc::new(roots);
@@ -727,9 +859,10 @@ impl BooksServer {
Ok(out)
}
/// The TIN vault's directory: `vault/` in the books dir (git-ignored).
fn vault_dir(store: &Store) -> std::path::PathBuf {
books_dir(store).join("vault")
/// The TIN vault's directory: `vault/` in the books dir (git-ignored),
/// or in the book's own folder in the data root.
fn vault_dir(&self, store: &Store) -> std::path::PathBuf {
self.layout.vault_dir(store)
}
/// What is never read through a path, even inside an import root
@@ -739,7 +872,7 @@ impl BooksServer {
/// the rest of the books dir.
fn protected(&self, store: &Store) -> Protected {
Protected::of(
&places(store, &self.settings()),
&places(store, &self.settings(), &self.layout),
&self.import_roots.usable(),
)
}
@@ -757,7 +890,7 @@ impl BooksServer {
file_name: &str,
) -> Result<(), String> {
tin_files::refuse(
&Self::vault_dir(store),
&self.vault_dir(store),
bytes,
mime_type,
file_name,
@@ -809,7 +942,7 @@ impl BooksServer {
/// the books (outside_books) and outside every import root, so it can
/// never be filed back in as a receipt and read out again.
fn tin_file_dest(&self, store: &Store, path: &str) -> Result<std::path::PathBuf, String> {
let dest = outside_books(store, path)?;
let dest = outside_books(&self.layout.books_dir(store), path)?;
self.outside_import_roots(&dest)?;
Ok(dest)
}
@@ -854,8 +987,17 @@ impl BooksServer {
/// Record a file read or written with full TINs (tin_files.rs
/// INVARIANT 2), before it is written out: every session, and every
/// later server for these books, refuses it.
fn wrote_tins(store: &Store, bytes: &[u8]) -> Result<(), String> {
tin_files::record(&Self::vault_dir(store), bytes)
fn wrote_tins(&self, store: &Store, bytes: &[u8]) -> Result<(), String> {
// The record goes in the vault's folder: in the data root, made
// private first, as the vault itself makes it.
if let Layout::Root(_) = *self.layout {
ledger_store::root::private_book_folder(
store.data_dir(),
ledger_store::root::VAULT_DIR,
)
.map_err(|e| format!("the TIN vault can't be written: {e}"))?;
}
tin_files::record(&self.vault_dir(store), bytes)
}
/// Open the TIN vault with the key file in force (`[vault] key_file`,
@@ -865,13 +1007,23 @@ impl BooksServer {
/// records it unlocks. Storing into books kept in git is refused
/// unless their `.gitignore` lists `vault/`.
fn open_vault(&self, store: &Store, create: bool) -> Result<ledger_vault::Vault, String> {
let key = self.settings().key_file();
if is_inside(&books_dir(store), &key) {
let key = self.layout.key_file(store, &self.settings());
// In the data root the key is the book's own `keys/legacy.key`;
// its folder and the vault's are made private when a TIN is first
// stored.
if let Layout::Root(_) = *self.layout {
if create {
for folder in [ledger_store::root::KEYS_DIR, "vault"] {
ledger_store::root::private_book_folder(store.data_dir(), folder)
.map_err(|e| format!("the TIN vault can't store anything yet: {e}"))?;
}
}
} else if is_inside(&books_dir(store), &key) {
return Err(KEY_IN_BOOKS.to_string());
}
// Writing: a git repository must ignore the vault, or its records
// would be committed (and could never be purged).
let books = books_dir(store);
let books = self.layout.books_dir(store);
if create && books.join(".git").exists() {
let ignored = std::fs::read_to_string(books.join(".gitignore"))
.unwrap_or_default()
@@ -882,7 +1034,7 @@ impl BooksServer {
}
}
// No message names the key file or the books' folders.
ledger_vault::Vault::open(&Self::vault_dir(store), &key, create).map_err(|e| match e {
ledger_vault::Vault::open(&self.vault_dir(store), &key, create).map_err(|e| match e {
ledger_vault::Error::NoKey(_) => "the TIN vault is locked: its key file is missing (restore it from the human's password manager, or set [vault] key_file in the settings)".to_string(),
ledger_vault::Error::BadKey(_) => "the TIN vault's key file is not a vault key (64 hex digits): restore it from the human's password manager".to_string(),
ledger_vault::Error::Io(e) => format!("the TIN vault can't be read: {}", e.kind()),
@@ -1732,13 +1884,23 @@ fn known_tin_last4(store: &Store) -> Vec<u32> {
/// Where these books keep what an import must never read
/// (local_files.rs): the data dir, the books dir, the vault, the key file
/// in force and the default one, and the home folder.
fn places(store: &Store, settings: &Settings) -> Places {
fn places(store: &Store, settings: &Settings, layout: &Layout) -> Places {
Places {
data_dir: store.data_dir().to_path_buf(),
books_dir: books_dir(store),
vault_dir: BooksServer::vault_dir(store),
key_files: vec![settings.key_file(), ledger_vault::default_key_file()],
home: std::env::var_os("HOME").map(std::path::PathBuf::from),
books_dir: layout.books_dir(store),
vault_dir: layout.vault_dir(store),
key_files: vec![
layout.key_file(store, settings),
settings.key_file(),
ledger_vault::default_key_file(),
],
// In the data root the home comes from the account's record, as
// the root's own does; never only from the environment.
home: match layout {
Layout::Root(_) => ledger_store::root::account_home().ok(),
Layout::BooksDir => None,
}
.or_else(|| std::env::var_os("HOME").map(std::path::PathBuf::from)),
}
}
@@ -1750,9 +1912,10 @@ fn places(store: &Store, settings: &Settings) -> Places {
pub fn guard_import_roots(
store: &Store,
settings: &Settings,
layout: &Layout,
roots: ImportRoots,
) -> Result<ImportRoots, String> {
let places = places(store, settings);
let places = places(store, settings, layout);
let explicit = roots.explicit();
let (roots, refused) = roots.guard(places.home.as_deref(), &places.keep_out());
if let Some((root, why)) = refused.first() {
@@ -1775,12 +1938,12 @@ fn is_inside(dir: &std::path::Path, path: &std::path::Path) -> bool {
/// Where a tool may write a file holding a full TIN: a new file, in a
/// folder that exists, outside the books dir (which git commits).
fn outside_books(store: &Store, path: &str) -> Result<std::path::PathBuf, String> {
fn outside_books(books: &std::path::Path, path: &str) -> Result<std::path::PathBuf, String> {
let p = std::path::PathBuf::from(path.trim());
if path.trim().is_empty() {
return Err("give a path to write to".into());
}
if is_inside(&books_dir(store), &p) {
if is_inside(books, &p) {
return Err(INSIDE_BOOKS.to_string());
}
match p.parent() {
@@ -1804,7 +1967,7 @@ const EXISTS: &str = "a file is there already: name a new file";
/// folder. A new file, in a folder that exists; with TINs, outside the
/// books dir (which git commits, `exports/` included).
fn package_dest(
store: &Store,
books: &std::path::Path,
to: &str,
file_name: &str,
tins: bool,
@@ -1827,7 +1990,7 @@ fn package_dest(
));
};
if tins {
return outside_books(store, &dest.to_string_lossy()).map_err(|e| {
return outside_books(books, &dest.to_string_lossy()).map_err(|e| {
format!("{e} (the CPA package holds full TINs; save it outside the books)")
});
}
@@ -4880,7 +5043,7 @@ impl BooksServer {
let dest = if p.include_tins {
self.tin_file_dest(&store, path)?
} else {
outside_books(&store, path)?
outside_books(&self.layout.books_dir(&store), path)?
};
let mut csv =
String::from("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4");
@@ -4907,7 +5070,7 @@ impl BooksServer {
csv.push('\n');
}
if p.include_tins {
Self::wrote_tins(&store, csv.as_bytes())?;
self.wrote_tins(&store, csv.as_bytes())?;
}
write_private_new(&dest, csv.as_bytes())?;
Some(dest.display().to_string())
@@ -6967,7 +7130,7 @@ impl BooksServer {
.as_ref()
.is_some_and(|t| party_tin.as_ref().is_some_and(|pt| pt.last4 != t.last4()));
let mut tin_facts: Option<ledger_core::TinFacts> = None;
let vault_dir = Self::vault_dir(&store);
let vault_dir = self.vault_dir(&store);
let (held_tin, held_w9) =
ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?;
let facts = match facts {
@@ -7028,7 +7191,7 @@ impl BooksServer {
// never filed into the books (tin_files.rs INVARIANT
// 1). Only now: every check has passed.
if let Some(w) = &w9_file {
Self::wrote_tins(&store, &w.bytes)?;
self.wrote_tins(&store, &w.bytes)?;
}
let tin_hash = match &tin {
Some(t) => Some(vault.put_tin(id, t).map_err(|e| e.to_string())?),
@@ -7044,10 +7207,14 @@ impl BooksServer {
None => w9_hash,
}
} else if held_tin {
ledger_vault::Vault::open(&vault_dir, &self.settings().key_file(), false)
.ok()
.and_then(|v| v.record_hash(id).ok().flatten())
.or_else(|| existing.as_ref().and_then(|e| e.vault_record.clone()))
ledger_vault::Vault::open(
&vault_dir,
&self.layout.key_file(&store, &self.settings()),
false,
)
.ok()
.and_then(|v| v.record_hash(id).ok().flatten())
.or_else(|| existing.as_ref().and_then(|e| e.vault_record.clone()))
} else if held_w9 {
existing.as_ref().and_then(|e| e.vault_record.clone())
} else {
@@ -7215,7 +7382,7 @@ impl BooksServer {
"the TIN vault holds no W-9 file for party {id}: store one with books.set_w9 w9_file_path"
)
})?;
Self::wrote_tins(&store, &file.bytes)?;
self.wrote_tins(&store, &file.bytes)?;
write_private_new(&dest, &file.bytes)?;
Ok(Json(GetW9Out {
party_id: id,
@@ -7270,7 +7437,7 @@ impl BooksServer {
let mut store = self.lock()?;
let id = resolve_party(&store, &p.party)?;
let party = store.ledger().get_party(id).expect("resolved").clone();
let vault_dir = Self::vault_dir(&store);
let vault_dir = self.vault_dir(&store);
let (held_tin, held_w9) =
ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?;
let recorded = party.w9.as_ref().is_some_and(|f| f.vault_record.is_some())
@@ -8279,7 +8446,15 @@ impl BooksServer {
let dest = p
.save_to
.as_deref()
.map(|to| package_dest(&store, to, &file_name, p.include_tins))
.map(|to| {
package_dest(
&self.layout.books_dir(&store),
to,
&file_name,
// In the data root nothing is ever written, TINs or not.
p.include_tins || matches!(*self.layout, Layout::Root(_)),
)
})
.transpose()?;
if p.include_tins
&& let Some(d) = &dest
@@ -8653,7 +8828,7 @@ impl BooksServer {
files.push(k1);
}
let seq = store.journal_seq();
let vault_dir = Self::vault_dir(&store);
let vault_dir = self.vault_dir(&store);
drop(store);
files.push((
"general-journal.iif".into(),
@@ -9385,6 +9560,11 @@ impl ServerHandler for BooksServer {
request: rmcp::model::CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
// Closing: refused as a protocol error, so a client knows it
// didn't run (SharedStore INVARIANT 3).
if self.store.closing() {
return Err(not_run_closing());
}
// Pick up an edited settings file before the tool runs.
self.live().refresh();
let timezone = self.settings().timezone.clone();
@@ -9402,6 +9582,9 @@ impl ServerHandler for BooksServer {
// plain refusal, redacted like every other.
let result = match result {
Ok(out) => out,
// Not run because the books are closing: a protocol error, so
// the client knows (SharedStore INVARIANT 3).
Err(e) if e.code.0 == NOT_RUN_CLOSING_CODE => return Err(e),
Err(e) => Ok(rmcp::model::CallToolResponse::Complete(
rmcp::model::CallToolResult::error(vec![rmcp::model::ContentBlock::text(
e.message.to_string(),
@@ -9421,19 +9604,14 @@ impl ServerHandler for BooksServer {
.sync(store.ledger().settings(), self.store.currency.code());
}
self.live().refresh();
ServerConfig::new(
ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build(),
)
// The rules in brief, with the policy these books actually use;
// the rest is in the handbook.
.with_instructions(handbook::connect_text(&self.settings(), self.currency()))
.with_server_info(Implementation::new(
"wanderbooks",
env!("CARGO_PKG_VERSION"),
))
ServerConfig::new(crate::opening::capabilities())
// The rules in brief, with the policy these books actually use;
// the rest is in the handbook.
.with_instructions(handbook::connect_text(&self.settings(), self.currency()))
.with_server_info(Implementation::new(
"wanderbooks",
env!("CARGO_PKG_VERSION"),
))
}
async fn list_resources(
+29 -1
View File
@@ -30,6 +30,9 @@
//! base currency, and a vault key file given on the command line are
//! fixed when the server starts (the key file given wins over any
//! file's [vault] key_file).
//! 6. A book in the data root (plan 3.4) has no settings file at all:
//! its settings are its own, the defaults until the first is set,
//! and no file is ever read for it.
//! ```
use crate::policy::ReceiptPolicy;
@@ -178,6 +181,9 @@ pub struct Live {
/// Whether the settings files may be read: false once the books keep
/// their own (INVARIANT 1).
read_files: bool,
/// A book in the data root: its settings are only ever its own
/// (INVARIANT 6).
own: bool,
}
/// What books.settings reports about the files.
@@ -212,6 +218,26 @@ impl Live {
journal_seq: None,
vault_key_file: None,
read_files: false,
own: false,
}
}
/// For a book in the data root (INVARIANT 6): no settings file, now
/// or later; `sync` brings the books' settings in once they hold any.
pub fn own() -> Self {
Self {
current: Arc::new(Settings {
journaled: true,
..Settings::default()
}),
source: None,
seen: (None, None),
error: None,
loads: 0,
journal_seq: None,
vault_key_file: None,
read_files: false,
own: true,
}
}
@@ -229,6 +255,7 @@ impl Live {
journal_seq: None,
vault_key_file: None,
read_files: true,
own: false,
})
}
@@ -249,6 +276,7 @@ impl Live {
journal_seq: None,
vault_key_file: None,
read_files: false,
own: false,
}
}
@@ -296,7 +324,7 @@ impl Live {
/// Whether the settings in force are the books' own.
pub fn is_journaled(&self) -> bool {
self.journal_seq.is_some() || (self.source.is_some() && !self.read_files)
self.own || self.journal_seq.is_some() || (self.source.is_some() && !self.read_files)
}
/// The settings in force.
+3 -1
View File
@@ -1166,7 +1166,9 @@ impl BooksServer {
async fn export_settings(&self) -> Result<Json<ExportSettingsOut>, String> {
let store = self.lock()?;
let s = store.ledger().settings();
if !s.is_journaled() {
// Books in the data root read no file: theirs are the defaults
// until the first is set.
if !s.is_journaled() && !self.live().status().journaled {
return Err(
"these books still read their settings file, which is the copy: move it in with books.import_settings first"
.into(),