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
@@ -17,14 +17,20 @@ default = ["gui"]
|
||||
gui = ["dep:ledger-gui"]
|
||||
|
||||
[dependencies]
|
||||
ledger-host = { path = "../ledger-host" }
|
||||
ledger-mcp = { path = "../ledger-mcp" }
|
||||
ledger-store = { path = "../ledger-store" }
|
||||
ledger-gui = { path = "../ledger-gui", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = "0.4"
|
||||
libc = "0.2"
|
||||
reqwest = "0.13"
|
||||
rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
ledger-config = { path = "../ledger-config" }
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
ledger-vault = { path = "../ledger-vault" }
|
||||
serde_json = "1"
|
||||
toml = "1"
|
||||
tempfile = "3"
|
||||
|
||||
@@ -394,11 +394,19 @@ fn print_usage() {
|
||||
eprintln!(" wanderbooks gui [DIR | --data-dir PATH ...]");
|
||||
eprintln!(" wanderbooks dev-stdio [--data-dir PATH ...]");
|
||||
eprintln!(" wanderbooks setup [--root PATH] [--home DIR]");
|
||||
eprintln!(" wanderbooks books list | create ID | adopt DATA-DIR --id ID");
|
||||
eprintln!(" wanderbooks mcp --book ID");
|
||||
eprintln!(" wanderbooks serve | close | doctor");
|
||||
eprintln!();
|
||||
eprintln!(" new make a books directory and start the agent in it (alias: init)");
|
||||
eprintln!(" gui open the live, read-only view of a books directory");
|
||||
eprintln!(" dev-stdio run the books server over stdio (`dev-stdio --help` for its flags)");
|
||||
eprintln!(" setup make the books' private folder (only your account can open it)");
|
||||
eprintln!(" books list, start or adopt books (`books --help`)");
|
||||
eprintln!(" mcp connect an agent to books, over stdio (what `claude mcp add` runs)");
|
||||
eprintln!(" serve hold the books open until Ctrl-C");
|
||||
eprintln!(" close close the books now: everything holding them lets go");
|
||||
eprintln!(" doctor whether the books are open, what holds them, what went wrong");
|
||||
eprintln!(" DIR the books directory (default: current directory)");
|
||||
eprintln!(" --force rewrite the scaffold in an existing books directory");
|
||||
eprintln!(" --no-start don't start the agent afterwards");
|
||||
@@ -847,7 +855,14 @@ fn run_setup(args: Vec<String>) -> i32 {
|
||||
look there (tests and unusual setups only)"
|
||||
);
|
||||
}
|
||||
let xdg = env::var_os("XDG_DATA_HOME").map(PathBuf::from);
|
||||
// XDG_DATA_HOME is this account's, for this account's home: with
|
||||
// another home given (tests), it is never read, so it can't aim at
|
||||
// this account's real books.
|
||||
let xdg = if account.as_ref() == Some(&home) {
|
||||
env::var_os("XDG_DATA_HOME").map(PathBuf::from)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match ledger_store::root::Root::setup(&home, root.as_deref(), xdg.as_deref()) {
|
||||
Ok((_, report)) => {
|
||||
let what = if report.created {
|
||||
@@ -884,6 +899,62 @@ fn run_dev_stdio(args: Vec<String>) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// `wanderbooks server`: the books server for the data root, on its
|
||||
/// local sockets (plan 3.9). The host starts it.
|
||||
#[cfg(unix)]
|
||||
fn run_server(args: Vec<String>) -> i32 {
|
||||
match ledger_mcp::local::run("wanderbooks server", args) {
|
||||
Ok(()) => 0,
|
||||
Err(e) => match e.downcast_ref::<ledger_mcp::Exit>() {
|
||||
Some(exit) => exit.0,
|
||||
None => {
|
||||
eprintln!("error: {e}");
|
||||
1
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The modes that open and hold the books (plan 3.9): the host, the
|
||||
/// helper, the agents' bridge, and the owner's commands.
|
||||
#[cfg(unix)]
|
||||
fn run_local(mode: &str, args: Vec<String>) -> i32 {
|
||||
let prog = format!("wanderbooks {mode}");
|
||||
let result = match mode {
|
||||
"host" => ledger_host::host::run(&prog, args),
|
||||
"helper" => ledger_host::helper::run(&prog, args),
|
||||
"mcp" => ledger_host::bridge::run(&prog, args),
|
||||
"serve" => ledger_host::commands::serve(&prog, args),
|
||||
"close" => ledger_host::commands::close(&prog, args),
|
||||
"doctor" => ledger_host::commands::doctor(&prog, args),
|
||||
_ => ledger_host::commands::books(&prog, args),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => 0,
|
||||
Err(e) => match e.downcast_ref::<ledger_host::Exit>() {
|
||||
Some(exit) => exit.0,
|
||||
None => {
|
||||
eprintln!("error: {e}");
|
||||
1
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The modes that hold the books, where local sockets aren't built yet.
|
||||
#[cfg(not(unix))]
|
||||
fn run_local(_mode: &str, _args: Vec<String>) -> i32 {
|
||||
eprintln!("error: opening the books this way comes to this system with P11");
|
||||
2
|
||||
}
|
||||
|
||||
/// `wanderbooks server` where local sockets aren't built yet.
|
||||
#[cfg(not(unix))]
|
||||
fn run_server(_args: Vec<String>) -> i32 {
|
||||
eprintln!("error: the books server's local sockets come to this system with P11");
|
||||
2
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// The modes run in this process and take their own flags.
|
||||
let mut all = env::args().skip(1);
|
||||
@@ -891,6 +962,11 @@ fn main() {
|
||||
Some("gui") => std::process::exit(run_gui(all.collect())),
|
||||
Some("dev-stdio") => std::process::exit(run_dev_stdio(all.collect())),
|
||||
Some("setup") => std::process::exit(run_setup(all.collect())),
|
||||
Some("server") => std::process::exit(run_server(all.collect())),
|
||||
Some(mode @ ("host" | "helper" | "mcp" | "serve" | "close" | "books" | "doctor")) => {
|
||||
let mode = mode.to_string();
|
||||
std::process::exit(run_local(&mode, all.collect()))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut dir: Option<PathBuf> = None;
|
||||
@@ -921,7 +997,11 @@ fn main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(mode @ ("gui" | "dev-stdio" | "setup")) = cmd.as_deref() {
|
||||
if let Some(
|
||||
mode @ ("gui" | "dev-stdio" | "setup" | "server" | "host" | "helper" | "mcp" | "serve"
|
||||
| "close" | "books" | "doctor"),
|
||||
) = cmd.as_deref()
|
||||
{
|
||||
eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -103,6 +103,14 @@ fn each_mode_names_itself_in_its_usage() {
|
||||
"{err}"
|
||||
);
|
||||
|
||||
let out = wanderbooks().args(["server", "--help"]).output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
err.contains("usage: wanderbooks server [--root DIR]"),
|
||||
"{err}"
|
||||
);
|
||||
|
||||
let out = wanderbooks().arg("--help").output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
for line in [
|
||||
@@ -129,9 +137,9 @@ fn the_viewer_names_itself_in_its_usage() {
|
||||
#[test]
|
||||
fn a_mode_refuses_what_it_does_not_know() {
|
||||
let modes: &[&str] = if cfg!(feature = "gui") {
|
||||
&["dev-stdio", "gui"]
|
||||
&["dev-stdio", "gui", "server"]
|
||||
} else {
|
||||
&["dev-stdio"]
|
||||
&["dev-stdio", "server"]
|
||||
};
|
||||
for &mode in modes {
|
||||
let out = wanderbooks()
|
||||
@@ -150,7 +158,7 @@ fn a_mode_refuses_what_it_does_not_know() {
|
||||
|
||||
#[test]
|
||||
fn a_mode_comes_first() {
|
||||
for mode in ["gui", "dev-stdio"] {
|
||||
for mode in ["gui", "dev-stdio", "server"] {
|
||||
let out = wanderbooks().args(["--codex", mode]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
@@ -218,3 +226,65 @@ fn the_server_only_build_has_no_viewer() {
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Books in the data root are the books server's alone (plan 3.1): the
|
||||
/// test server refuses them, and the books server needs a root.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn dev_stdio_refuses_books_in_the_private_folder() {
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-one-root-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let root = home.path().join(".local/share/wanderbooks");
|
||||
for inside in [
|
||||
root.join("books/acme"),
|
||||
root.clone(),
|
||||
root.join("books/a/b"),
|
||||
] {
|
||||
let out = wanderbooks()
|
||||
.args(["dev-stdio", "--data-dir"])
|
||||
.arg(&inside)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{inside:?}: {out:?}");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("only the books server opens"), "{err}");
|
||||
assert!(!inside.join("journal.jsonl").exists(), "{inside:?}");
|
||||
}
|
||||
assert!(!root.join("books/acme").exists());
|
||||
// A relative path, from inside the root.
|
||||
let out = wanderbooks()
|
||||
.args(["dev-stdio", "--data-dir", "acme"])
|
||||
.current_dir(root.join("books"))
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{out:?}");
|
||||
assert!(!root.join("books/acme").exists());
|
||||
|
||||
// No root yet: the books server says to run setup.
|
||||
let empty = tempfile::Builder::new()
|
||||
.prefix("wb-one-noroot-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let out = wanderbooks()
|
||||
.args(["server", "--root"])
|
||||
.arg(empty.path().join("none"))
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{out:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("run `wanderbooks setup` first"),
|
||||
"{out:?}"
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -69,16 +69,20 @@ fn setup_makes_the_private_folder_and_never_names_it() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_elsewhere_is_noted_and_xdg_is_honoured() {
|
||||
fn a_root_elsewhere_is_noted_for_next_time() {
|
||||
// (XDG_DATA_HOME is honoured only for this account's own home, which
|
||||
// a test never touches: Root::setup's tests cover it.)
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-setup-xdg-")
|
||||
.prefix("wb-setup-else-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let xdg = home.path().join("xdg");
|
||||
let elsewhere = home.path().join("elsewhere").join("wanderbooks");
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env("XDG_DATA_HOME", &xdg)
|
||||
.arg("--root")
|
||||
.arg(&elsewhere)
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
@@ -86,13 +90,10 @@ fn a_root_elsewhere_is_noted_and_xdg_is_honoured() {
|
||||
String::from_utf8_lossy(&out.stdout).contains("noted for next time"),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(mode(&xdg.join("wanderbooks")), 0o700);
|
||||
assert_eq!(mode(&elsewhere), 0o700);
|
||||
let noted =
|
||||
std::fs::read_to_string(home.path().join(".local/share/wanderbooks.location")).unwrap();
|
||||
assert_eq!(
|
||||
noted.trim_end(),
|
||||
xdg.join("wanderbooks").display().to_string()
|
||||
);
|
||||
assert_eq!(noted.trim_end(), elsewhere.display().to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -122,3 +123,32 @@ fn setup_refuses_what_it_does_not_know_and_unsafe_folders() {
|
||||
let out = wanderbooks().args(["--force", "setup"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
}
|
||||
|
||||
/// With another home given (tests), this account's XDG_DATA_HOME is never
|
||||
/// read: setup can't be aimed at this account's real books by it.
|
||||
#[test]
|
||||
fn another_home_ignores_this_accounts_data_home() {
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-setup-xdg-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let elsewhere = home.path().join("elsewhere");
|
||||
std::fs::create_dir(&elsewhere).unwrap();
|
||||
let out = wanderbooks()
|
||||
.args(["setup", "--home"])
|
||||
.arg(home.path())
|
||||
.env("XDG_DATA_HOME", &elsewhere)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
assert!(
|
||||
home.path()
|
||||
.join(".local/share/wanderbooks/.wanderbooks-root")
|
||||
.is_file()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_dir(&elsewhere).unwrap().count(),
|
||||
0,
|
||||
"XDG_DATA_HOME was used"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user