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:
1 parent
168e5c9846
commit
a298d154b6
31 files changed
+11937
-112
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user