P2 slice 8: the viewer's menu, setup, no confinement (D-32)
No Landlock, no seccomp, no helper: isolation is "same_user (advisory)". The lease socket keeps two intents (close, format-upgrade consent). The viewer holds the books open while it runs and gains a Books menu: New books, Back up now, Restore (preview, then confirm), Close, Connect Claude Code / Codex. New commands `wanderbooks books back-up` and `wanderbooks connect claude|codex`. The scaffold (`new`/`init` and its templates) is gone; install.sh needs no sudo. 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
019d8d76a3
commit
3cf359a0cd
28 files changed
+1976
-1931
No files matched your search
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "wanderbooks CLI: one command from nothing to a working books directory."
|
||||
description = "wanderbooks: the one executable (viewer, books server, host, agents' bridge, owner's commands)."
|
||||
|
||||
[[bin]]
|
||||
name = "wanderbooks"
|
||||
@@ -24,17 +24,14 @@ ledger-gui = { path = "../ledger-gui", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
base64 = "0.22"
|
||||
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"
|
||||
sha2 = "0.10"
|
||||
toml = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
|
||||
+138
-796
File diff suppressed because it is too large.
Load diff
@@ -1,458 +0,0 @@
|
||||
//! Integration tests for `wanderbooks new`: one command lands a complete
|
||||
//! books dir with the MCP server registered for it, the TOML and JSON
|
||||
//! parse, and re-running is refused without `--force` (which never
|
||||
//! touches the company profile).
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn bin() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||
}
|
||||
|
||||
/// Stand-ins for the installed binaries, so tests never run cargo install.
|
||||
fn fake_bins(tmp: &Path) -> PathBuf {
|
||||
let dir = tmp.join("bin");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
for b in ["ledger-mcp", "ledger-gui"] {
|
||||
fs::write(dir.join(b), "#!/bin/sh\n").unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
fn run(tmp: &Path, args: &[&str]) -> (bool, String) {
|
||||
let path = format!(
|
||||
"{}:{}",
|
||||
fake_bins(tmp).display(),
|
||||
std::env::var("PATH").unwrap_or_default()
|
||||
);
|
||||
let out = Command::new(bin())
|
||||
.args(args)
|
||||
.arg("--no-start")
|
||||
.env("PATH", path)
|
||||
.output()
|
||||
.expect("run wanderbooks");
|
||||
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
s.push_str(&String::from_utf8_lossy(&out.stderr));
|
||||
(out.status.success(), s)
|
||||
}
|
||||
|
||||
fn new(tmp: &Path, books: &Path, extra: &[&str]) -> (bool, String) {
|
||||
let mut args = vec!["new", books.to_str().unwrap()];
|
||||
args.extend(extra);
|
||||
run(tmp, &args)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_lands_complete() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let books = tmp.path().join("acme-books");
|
||||
let (ok, out) = new(tmp.path(), &books, &[]);
|
||||
assert!(ok, "new should succeed: {out}");
|
||||
for f in [
|
||||
"justfile",
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"wanderbooks.toml",
|
||||
".gitignore",
|
||||
".mcp.json",
|
||||
".claude/settings.json",
|
||||
".codex/hooks.json",
|
||||
] {
|
||||
let p = books.join(f);
|
||||
assert!(p.exists(), "{f} exists");
|
||||
assert!(!fs::read_to_string(&p).unwrap().is_empty(), "{f} non-empty");
|
||||
}
|
||||
assert!(books.join("books-data").is_dir(), "books-data dir exists");
|
||||
assert!(books.join(".git").is_dir(), "fresh dir gets git init");
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline"])
|
||||
.current_dir(&books)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&log.stdout).contains("init"),
|
||||
"scaffold committed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_server_is_registered_for_this_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let books = tmp.path().join("b");
|
||||
assert!(new(tmp.path(), &books, &[]).0);
|
||||
let books = books.canonicalize().unwrap();
|
||||
let mcp: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(books.join(".mcp.json")).unwrap()).unwrap();
|
||||
let server = &mcp["mcpServers"]["wanderbooks"];
|
||||
assert!(server["command"].as_str().unwrap().ends_with("ledger-mcp"));
|
||||
assert_eq!(
|
||||
server["args"],
|
||||
serde_json::json!(["--data-dir", books.join("books-data").to_string_lossy()])
|
||||
);
|
||||
let settings: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(books.join(".claude/settings.json")).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
settings["enabledMcpjsonServers"],
|
||||
serde_json::json!(["wanderbooks"])
|
||||
);
|
||||
// Every session starts by checking the viewer against open_at_start.
|
||||
let hook = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
||||
.as_str()
|
||||
.expect("SessionStart hook command");
|
||||
assert!(hook.ends_with("just session-start"), "{hook}");
|
||||
let codex: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(books.join(".codex/hooks.json")).unwrap())
|
||||
.unwrap();
|
||||
let hook = codex["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
||||
.as_str()
|
||||
.expect("Codex SessionStart hook command");
|
||||
assert!(hook.ends_with("just session-start"), "{hook}");
|
||||
// The justfile opens this dir's books in the GUI.
|
||||
let just = fs::read_to_string(books.join("justfile")).unwrap();
|
||||
assert!(just.contains("ledger-gui\" --data-dir \"{{justfile_directory()}}/books-data\""));
|
||||
for recipe in [
|
||||
"agent:",
|
||||
"gui:",
|
||||
"gui-fg:",
|
||||
"gui-status:",
|
||||
"session-start:",
|
||||
"backup:",
|
||||
"update:",
|
||||
] {
|
||||
assert!(just.contains(recipe), "justfile has {recipe}");
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read_to_string(books.join("CLAUDE.md")).unwrap().trim(),
|
||||
"@AGENTS.md"
|
||||
);
|
||||
let toml: toml::Value =
|
||||
toml::from_str(&fs::read_to_string(books.join("wanderbooks.toml")).unwrap()).unwrap();
|
||||
assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_md_carries_the_contract() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let books = tmp.path().join("b");
|
||||
assert!(new(tmp.path(), &books, &[]).0);
|
||||
let md = fs::read_to_string(books.join("AGENTS.md")).unwrap();
|
||||
for phrase in [
|
||||
"preview",
|
||||
"Posted is forever",
|
||||
"reversing entries",
|
||||
"receipt",
|
||||
"onboard",
|
||||
"chart_template",
|
||||
"to the cent",
|
||||
"just gui",
|
||||
"just gui-status",
|
||||
"open_at_start",
|
||||
] {
|
||||
assert!(md.contains(phrase), "AGENTS.md mentions {phrase:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The agent reaches the books only through the MCP tools. The scaffolded
|
||||
/// AGENTS.md once said "the journal is the evidence — read it", and an
|
||||
/// agent `cat`ed the journal file instead of asking the books.
|
||||
#[test]
|
||||
fn agents_md_reaches_the_books_only_through_tools() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let books = tmp.path().join("b");
|
||||
assert!(new(tmp.path(), &books, &[]).0);
|
||||
let md = fs::read_to_string(books.join("AGENTS.md")).unwrap();
|
||||
for phrase in [
|
||||
"only through the",
|
||||
"wanderbooks MCP tools",
|
||||
"Never open, list or parse",
|
||||
"books.get_entry",
|
||||
"books.search_entries",
|
||||
"general_ledger",
|
||||
"can't answer, say so.",
|
||||
] {
|
||||
assert!(md.contains(phrase), "AGENTS.md says {phrase:?}");
|
||||
}
|
||||
// Nothing points the agent at the books' files.
|
||||
for path in ["books-data", "journal.jsonl", "wanderbooks.db", "read it"] {
|
||||
assert!(!md.contains(path), "AGENTS.md never mentions {path:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rerun_refused_without_force_and_never_clobbers_the_profile() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let books = tmp.path().join("b");
|
||||
assert!(new(tmp.path(), &books, &[]).0);
|
||||
let (ok, out) = new(tmp.path(), &books, &[]);
|
||||
assert!(!ok, "second new without --force fails");
|
||||
assert!(out.contains("--force"), "error points at --force: {out}");
|
||||
fs::write(
|
||||
books.join("wanderbooks.toml"),
|
||||
"[company]\nname = \"Acme LLC\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let (ok, _) = new(tmp.path(), &books, &["--force"]);
|
||||
assert!(ok, "second new with --force succeeds");
|
||||
assert!(
|
||||
fs::read_to_string(books.join("wanderbooks.toml"))
|
||||
.unwrap()
|
||||
.contains("Acme LLC"),
|
||||
"--force rewrites the scaffold, never the company profile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_is_an_alias_and_respects_an_existing_repo() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let books = tmp.path().join("b");
|
||||
fs::create_dir_all(&books).unwrap();
|
||||
assert!(
|
||||
Command::new("git")
|
||||
.args(["init", "-q"])
|
||||
.current_dir(&books)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
let (ok, out) = run(tmp.path(), &["init", books.to_str().unwrap()]);
|
||||
assert!(ok, "{out}");
|
||||
assert!(books.join("wanderbooks.toml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_gets_its_own_named_server() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bin = fake_bins(tmp.path());
|
||||
let log = tmp.path().join("codex-args");
|
||||
// A stand-in codex that records how it was called.
|
||||
fs::write(
|
||||
bin.join("codex"),
|
||||
format!("#!/bin/sh\necho \"$@\" > {}\n", log.display()),
|
||||
)
|
||||
.unwrap();
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(bin.join("codex"), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let books = tmp.path().join("Acme Books");
|
||||
let (ok, out) = new(tmp.path(), &books, &["--codex"]);
|
||||
assert!(ok, "{out}");
|
||||
let args = fs::read_to_string(&log).unwrap();
|
||||
let books = books.canonicalize().unwrap();
|
||||
assert!(
|
||||
args.starts_with("mcp add wanderbooks-acme-books -- "),
|
||||
"{args}"
|
||||
);
|
||||
assert!(
|
||||
args.trim_end().ends_with(&format!(
|
||||
"--data-dir {}",
|
||||
books.join("books-data").display()
|
||||
)),
|
||||
"{args}"
|
||||
);
|
||||
let just = fs::read_to_string(books.join("justfile")).unwrap();
|
||||
assert!(just.contains("codex mcp add \"wanderbooks-acme-books\""));
|
||||
assert!(out.contains("just codex"), "{out}");
|
||||
}
|
||||
|
||||
/// The `wanderbooks.toml` a fresh `new` writes.
|
||||
fn scaffolded_toml(tmp: &Path) -> (PathBuf, String) {
|
||||
let books = tmp.join("b");
|
||||
assert!(new(tmp, &books, &[]).0);
|
||||
let path = books.join("wanderbooks.toml");
|
||||
let text = fs::read_to_string(&path).unwrap();
|
||||
(path, text)
|
||||
}
|
||||
|
||||
/// The example a commented line holds, if it is one: `# key = value`, or
|
||||
/// `# [section]` / `# [[section]]`. Prose comments are not examples.
|
||||
fn example(line: &str) -> Option<&str> {
|
||||
let rest = line.strip_prefix("# ")?;
|
||||
let is_key = |k: &str| !k.is_empty() && k.bytes().all(|b| b.is_ascii_lowercase() || b == b'_');
|
||||
let header = rest
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.split('.')
|
||||
.all(is_key);
|
||||
let assignment = rest
|
||||
.split_once(" = ")
|
||||
.is_some_and(|(k, v)| is_key(k) && !v.trim().is_empty());
|
||||
((rest.starts_with('[') && rest.ends_with(']') && header) || assignment).then_some(rest)
|
||||
}
|
||||
|
||||
/// The template with the examples `pick` chooses uncommented.
|
||||
fn uncomment(text: &str, pick: impl Fn(&str) -> bool) -> String {
|
||||
text.lines()
|
||||
.map(|l| match example(l) {
|
||||
Some(ex) if pick(ex) => ex,
|
||||
_ => l,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scaffolded_toml_loads_with_the_defaults() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (path, text) = scaffolded_toml(tmp.path());
|
||||
let s = ledger_config::load(&path).expect("scaffolded wanderbooks.toml loads");
|
||||
let c = &s.company;
|
||||
assert_eq!(c.name, "Unnamed company", "name is left for onboarding");
|
||||
assert_eq!(c.fiscal_year_start, (1, 1));
|
||||
assert_eq!(c.basis, "Accrual");
|
||||
assert!(c.members.is_empty());
|
||||
assert_eq!(c.tax_form, None);
|
||||
assert_eq!(c.close_into, None);
|
||||
assert!(c.close_sweep.is_empty());
|
||||
assert_eq!(
|
||||
c.profile,
|
||||
[
|
||||
("base_currency".to_string(), "USD".to_string()),
|
||||
("fiscal_year_start".to_string(), "01-01".to_string()),
|
||||
]
|
||||
);
|
||||
assert_eq!(s.receipt_threshold.as_deref(), Some("25.00"));
|
||||
assert!(s.cash_accounts.is_empty());
|
||||
assert!(s.financing_accounts.is_empty());
|
||||
assert!(s.investing_accounts.is_empty());
|
||||
let toml: toml::Value = toml::from_str(&text).unwrap();
|
||||
assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data"));
|
||||
// The justfile's session-start reads this line with sed, from column 0.
|
||||
assert!(
|
||||
text.lines().any(|l| l == "open_at_start = \"ask\""),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("SSN"), "the template warns off storing SSNs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_example_in_the_toml_is_valid() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, text) = scaffolded_toml(tmp.path());
|
||||
let examples = text.lines().filter_map(example).count();
|
||||
assert!(examples >= 20, "only {examples} examples found");
|
||||
// close_into is for a single owner and refused with members, so it
|
||||
// gets a pass of its own; together the two passes cover every example.
|
||||
let single_owner = |ex: &str| ex.starts_with("close_into ");
|
||||
let all = ledger_config::parse(&uncomment(&text, |ex| !single_owner(ex)))
|
||||
.unwrap_or_else(|e| panic!("examples (without close_into) parse: {e}"));
|
||||
let c = &all.company;
|
||||
assert_eq!(c.name, "Acme Widgets LLC");
|
||||
assert_eq!(c.tax_form, Some(ledger_core::TaxForm::Form1065));
|
||||
assert_eq!(c.members.len(), 2);
|
||||
assert_eq!(c.members[1].distribution_account.as_deref(), Some("3101"));
|
||||
assert_eq!(c.close_sweep, ["3100"]);
|
||||
assert!(c.profile.iter().any(|(k, _)| k == "ein"));
|
||||
assert_eq!(all.cash_accounts, ["1000", "1010"]);
|
||||
assert_eq!(all.financing_accounts, ["2100"]);
|
||||
assert_eq!(all.investing_accounts, ["1400"]);
|
||||
let single = ledger_config::parse(&uncomment(&text, single_owner))
|
||||
.unwrap_or_else(|e| panic!("close_into example parses: {e}"));
|
||||
assert_eq!(single.company.close_into.as_deref(), Some("3000"));
|
||||
assert!(
|
||||
ledger_config::parse(&uncomment(&text, |_| true)).is_err(),
|
||||
"close_into with members is refused, as the template says"
|
||||
);
|
||||
}
|
||||
|
||||
/// The serde field names of `struct name` in ledger-config's source.
|
||||
fn fields(src: &str, name: &str) -> Vec<String> {
|
||||
let start = src
|
||||
.find(&format!("struct {name} {{"))
|
||||
.unwrap_or_else(|| panic!("ledger-config has struct {name}"));
|
||||
let body = &src[start..];
|
||||
let body = &body[..body.find("\n}").expect("struct ends")];
|
||||
let found: Vec<String> = body
|
||||
.lines()
|
||||
.skip(1)
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.starts_with("//") && !l.starts_with("#["))
|
||||
.filter_map(|l| l.split_once(':'))
|
||||
.map(|(k, _)| k.trim_start_matches("pub ").to_string())
|
||||
.collect();
|
||||
assert!(!found.is_empty(), "struct {name} has fields");
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_toml_shows_every_setting_the_config_reads() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, text) = scaffolded_toml(tmp.path());
|
||||
// (section, key) for every assignment, examples included.
|
||||
let mut section = String::new();
|
||||
let mut present = Vec::new();
|
||||
for line in uncomment(&text, |_| true).lines() {
|
||||
if line.starts_with('[') {
|
||||
section = line.trim_matches(['[', ']']).to_string();
|
||||
} else if let Some((k, _)) = line.split_once(" = ") {
|
||||
present.push((section.clone(), k.to_string()));
|
||||
}
|
||||
}
|
||||
let has = |s: &str, k: &str| present.iter().any(|(ps, pk)| ps == s && pk == k);
|
||||
// Read from the config crate's serde structs, so a new setting there
|
||||
// fails here until the template shows it.
|
||||
let src = include_str!("../../ledger-config/src/lib.rs");
|
||||
assert_eq!(fields(src, "BooksToml"), ["company", "books", "vault"]);
|
||||
for (strukt, sect) in [
|
||||
("CompanyToml", "company"),
|
||||
("MemberToml", "company.members"),
|
||||
("BooksSection", "books"),
|
||||
("VaultSection", "vault"),
|
||||
] {
|
||||
for key in fields(src, strukt) {
|
||||
if strukt == "CompanyToml" && key == "members" {
|
||||
assert!(text.contains("# [[company.members]]"));
|
||||
continue;
|
||||
}
|
||||
assert!(has(sect, &key), "[{sect}] {key} is in the template");
|
||||
}
|
||||
}
|
||||
// Read elsewhere: the justfile's session-start (main.rs), and the two
|
||||
// the scaffold has always written live.
|
||||
for (sect, key) in [
|
||||
("viewer", "open_at_start"),
|
||||
("company", "base_currency"),
|
||||
("books", "data_dir"),
|
||||
] {
|
||||
assert!(has(sect, key), "[{sect}] {key} is in the template");
|
||||
}
|
||||
}
|
||||
|
||||
/// Moving the scaffolded settings file, and every example in it, into
|
||||
/// books: each imports, reads as the file does (the import refuses
|
||||
/// otherwise), and exporting it back and importing the export changes
|
||||
/// nothing (plan/served.md P1: toml_round_trip_zero_events).
|
||||
#[test]
|
||||
fn the_toml_and_its_examples_import_and_round_trip() {
|
||||
use ledger_core::settings::{Settings, Source};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let (_, text) = scaffolded_toml(tmp.path());
|
||||
let single_owner = |ex: &str| ex.starts_with("close_into ");
|
||||
let source = Source {
|
||||
decided_by: "onboarding".into(),
|
||||
decided_on: chrono_date(),
|
||||
citation: None,
|
||||
note: None,
|
||||
};
|
||||
for file in [
|
||||
text.clone(),
|
||||
uncomment(&text, |ex| !single_owner(ex)),
|
||||
uncomment(&text, single_owner),
|
||||
] {
|
||||
let import = ledger_config::codec::import(&file, None, &Settings::default(), "USD")
|
||||
.unwrap_or_else(|e| panic!("imports: {e}\n{file}"));
|
||||
assert!(import.unknown.is_empty(), "{:?}", import.unknown);
|
||||
let mut books = Settings::default();
|
||||
let changes = books.check_changes(&import.changes, &source).unwrap();
|
||||
books.apply(&changes, &source, 1).unwrap();
|
||||
let export = ledger_config::codec::export(&books, "USD");
|
||||
let again = ledger_config::codec::import(&export, None, &books, "USD")
|
||||
.unwrap_or_else(|e| panic!("the export imports: {e}\n{export}"));
|
||||
assert!(again.changes.is_empty(), "{:?}\n{export}", again.changes);
|
||||
}
|
||||
}
|
||||
|
||||
fn chrono_date() -> chrono::NaiveDate {
|
||||
chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap()
|
||||
}
|
||||
@@ -150,6 +150,20 @@ fn a_mode_refuses_what_it_does_not_know() {
|
||||
assert_eq!(out.status.code(), Some(2), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_books_folder_scaffold_is_gone_and_says_so() {
|
||||
for gone in ["new", "init"] {
|
||||
let out = wanderbooks().args([gone, "acme"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{gone}: {out:?}");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
err.contains(&format!("`wanderbooks {gone}` is gone")),
|
||||
"{err}"
|
||||
);
|
||||
assert!(err.contains("wanderbooks books create ID"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mode_comes_first() {
|
||||
for mode in ["gui", "dev-stdio", "server"] {
|
||||
|
||||
@@ -170,7 +170,7 @@ impl Drop for Home {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let mut found = false;
|
||||
for mode in ["serve", "mcp", "helper", "host", "server"] {
|
||||
for mode in ["serve", "mcp", "host", "server"] {
|
||||
for pid in self.pids(mode) {
|
||||
found = true;
|
||||
// SAFETY: a process of this test's own root.
|
||||
@@ -922,19 +922,15 @@ fn the_lease_socket_takes_only_the_owners_intents() {
|
||||
),
|
||||
(
|
||||
"{\"proto\":1,\"intent\":{\"back_up_now\":{\"book\":\"acme\"}}}\n",
|
||||
"books.back_up_now",
|
||||
"not understood",
|
||||
),
|
||||
(
|
||||
"{\"proto\":1,\"intent\":{\"restore\":{\"source\":\"/x\",\"id\":\"acme\",\"replace\":true}}}\n",
|
||||
"wanderbooks books restore",
|
||||
"{\"proto\":1,\"intent\":{\"restore\":{\"source\":\"/x\",\"id\":\"acme\"}}}\n",
|
||||
"not understood",
|
||||
),
|
||||
(
|
||||
"{\"proto\":1,\"intent\":{\"new_books\":{\"id\":\"beta\",\"currency\":\"USD\"}}}\n",
|
||||
"new books beta made",
|
||||
),
|
||||
(
|
||||
"{\"proto\":1,\"intent\":{\"new_books\":{\"id\":\"beta\",\"currency\":\"USD\"}}}\n",
|
||||
"already books called",
|
||||
"not understood",
|
||||
),
|
||||
] {
|
||||
let answer = home.lease_line(line);
|
||||
@@ -946,7 +942,7 @@ fn the_lease_socket_takes_only_the_owners_intents() {
|
||||
}
|
||||
let out = home.books(&["list"]);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stdout).contains("beta open"),
|
||||
!String::from_utf8_lossy(&out.stdout).contains("beta"),
|
||||
"{out:?}"
|
||||
);
|
||||
}
|
||||
@@ -1920,9 +1916,8 @@ fn the_books_command_takes_its_flags_and_asks_before_adopting() {
|
||||
|
||||
/// A program that starts the bridge with its stderr closed (some launchers
|
||||
/// do) still gets a working bridge, its descriptor 2 on /dev/null: Rust's
|
||||
/// runtime opens it before main, so the helper's socket can't take its
|
||||
/// number and be sent the error messages (asked in slice 3's mutation
|
||||
/// testing: the helper's descriptor 3 then is never its own pair's end).
|
||||
/// runtime opens it before main, so no socket the bridge opens can take
|
||||
/// its number and be sent the error messages.
|
||||
#[test]
|
||||
fn a_bridge_started_without_stderr_keeps_its_descriptors_apart() {
|
||||
use std::os::unix::process::CommandExt;
|
||||
@@ -2464,3 +2459,242 @@ fn a_backup_is_restored_from_the_command_line() {
|
||||
assert_eq!(start["head"]["seq"].as_u64(), Some(head), "{start}");
|
||||
fresh.end();
|
||||
}
|
||||
|
||||
/// A stand-in for an agent's own command line (`claude`, `codex`): it
|
||||
/// notes each call's arguments, a line each.
|
||||
fn fake_agent(home: &Home, name: &str) -> (PathBuf, PathBuf) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let bin = home.dir.path().join("agent-bin");
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
let log = home.dir.path().join(format!("{name}.log"));
|
||||
let script = bin.join(name);
|
||||
std::fs::write(
|
||||
&script,
|
||||
format!("#!/bin/sh\nprintf '%s\\n' \"$*\" >> '{}'\n", log.display()),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
(bin, log)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_is_connected_and_reaches_the_books_by_what_it_was_given() {
|
||||
let home = Home::new("connect");
|
||||
let out = home.books(&["create", "beta"]);
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let (bin, log) = fake_agent(&home, "claude");
|
||||
let connect = |agent: &str, path: &Path| {
|
||||
home.cmd("connect")
|
||||
.args([agent, "--book", "beta"])
|
||||
.env("PATH", format!("{}:/usr/bin:/bin", path.display()))
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap()
|
||||
};
|
||||
let out = connect("claude", &bin);
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let said = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(said.contains("Made the books beta' agent token"), "{said}");
|
||||
assert!(
|
||||
said.contains("Claude Code reaches the books beta"),
|
||||
"{said}"
|
||||
);
|
||||
let calls = std::fs::read_to_string(&log).unwrap();
|
||||
let lines: Vec<&str> = calls.lines().collect();
|
||||
assert_eq!(lines[0], "mcp remove --scope user wanderbooks-beta");
|
||||
let add = lines[1];
|
||||
assert!(
|
||||
add.starts_with("mcp add --scope user wanderbooks-beta -- "),
|
||||
"{add}"
|
||||
);
|
||||
assert!(add.contains(" mcp --book beta --root "), "{add}");
|
||||
assert!(!said.contains("wb_"), "a token's text was shown");
|
||||
// The command it was given reaches the books (with the token kept).
|
||||
let given: Vec<&str> = add.split(" -- ").nth(1).unwrap().split(' ').collect();
|
||||
let mut c = Command::new(given[0]);
|
||||
c.args(&given[1..])
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env("HOME", home.dir.path());
|
||||
let mut agent = Agent::from(c);
|
||||
agent.initialize();
|
||||
let start = agent.call("books.start", json!({})).unwrap();
|
||||
assert!(start["head"]["seq"].as_u64().is_some(), "{start}");
|
||||
agent.end();
|
||||
// Again: the token kept is used, not made anew.
|
||||
let out = connect("claude", &bin);
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
assert!(
|
||||
!String::from_utf8_lossy(&out.stdout).contains("Made the books"),
|
||||
"{out:?}"
|
||||
);
|
||||
// Codex: its own list, no scope.
|
||||
let (bin, log) = fake_agent(&home, "codex");
|
||||
let out = connect("codex", &bin);
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let calls = std::fs::read_to_string(&log).unwrap();
|
||||
assert!(
|
||||
calls
|
||||
.lines()
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.starts_with("mcp add wanderbooks-beta -- "),
|
||||
"{calls}"
|
||||
);
|
||||
// An agent that isn't installed: said. (Never where a real one could
|
||||
// be found and run: skipped on a machine with one installed system-wide.)
|
||||
if ["/usr/local/bin/claude", "/opt/homebrew/bin/claude"]
|
||||
.iter()
|
||||
.any(|p| Path::new(p).exists())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let empty = home.dir.path().join("empty-bin");
|
||||
std::fs::create_dir_all(&empty).unwrap();
|
||||
let out = home
|
||||
.cmd("connect")
|
||||
.args(["claude", "--book", "beta"])
|
||||
.env("PATH", empty.display().to_string())
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("isn't installed"),
|
||||
"{out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn books_are_backed_up_now_from_the_command_line() {
|
||||
let home = Home::new("back-up");
|
||||
home.create("acme");
|
||||
// None set up: said, and failing.
|
||||
let out = home.books(&["back-up", "acme"]);
|
||||
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("No backups are set up"),
|
||||
"{out:?}"
|
||||
);
|
||||
let disk = home.dir.path().join("disk");
|
||||
std::fs::create_dir(&disk).unwrap();
|
||||
let mut a = Agent::start(&home, "acme");
|
||||
a.initialize();
|
||||
a.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "backup.folder", "value": disk.display().to_string()}],
|
||||
"decided_by": "Test Owner"}),
|
||||
)
|
||||
.unwrap();
|
||||
let out = home.books(&["back-up", "acme"]);
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stdout).contains("Backed up to the backup folder"),
|
||||
"{out:?}"
|
||||
);
|
||||
assert!(disk.join("acme.git").join("HEAD").is_file());
|
||||
// A folder gone since: said, and --new-disk begins it again.
|
||||
std::fs::rename(disk.join("acme.git"), home.dir.path().join("away.git")).unwrap();
|
||||
let out = home.books(&["back-up", "acme"]);
|
||||
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("holds no backup"),
|
||||
"{out:?}"
|
||||
);
|
||||
let out = home.books(&["back-up", "acme", "--new-disk"]);
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
assert!(disk.join("acme.git").join("HEAD").is_file());
|
||||
a.end();
|
||||
}
|
||||
|
||||
/// The viewer's owner (ledger-cli's `ViewerOwner`, over these ledger-host
|
||||
/// functions): its hold is a lease of kind app; new books are made by the
|
||||
/// server; "Close the books" ends every lease, the viewer's hold with it.
|
||||
/// (A `serve` holds the books first: run in this test's process, a hold
|
||||
/// that had to start the host would start this test's own program.)
|
||||
#[test]
|
||||
fn the_viewers_hold_new_books_and_close_the_books() {
|
||||
use ledger_host::commands::{Common, close_books, hold_for_viewer, new_books};
|
||||
let home = Home::new("viewer-owner");
|
||||
home.create("acme");
|
||||
let serving = serve(&home);
|
||||
let common = Common {
|
||||
root: Some(home.root()),
|
||||
knobs: ledger_host::knobs::Knobs::parse(KNOBS).unwrap(),
|
||||
};
|
||||
let hold = hold_for_viewer(&common, Some("acme".into()));
|
||||
assert!(
|
||||
wait_for(20, || home
|
||||
.lease_line("{\"proto\":1,\"leases\":null}\n")
|
||||
.contains("\"app\"")),
|
||||
"the viewer's lease never showed"
|
||||
);
|
||||
assert_eq!(
|
||||
new_books(&common, "beta", "USD").unwrap(),
|
||||
"Made new books \"beta\" (USD)."
|
||||
);
|
||||
assert!(
|
||||
new_books(&common, "beta", "USD")
|
||||
.unwrap_err()
|
||||
.contains("already books called")
|
||||
);
|
||||
let out = home.books(&["list"]);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stdout).contains("beta open"),
|
||||
"{out:?}"
|
||||
);
|
||||
let said = close_books(&common).unwrap();
|
||||
assert!(said.starts_with("The books are closing"), "{said}");
|
||||
// The viewer's hold lets go, for good (closed by the owner).
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(hold.join().is_ok());
|
||||
});
|
||||
assert_eq!(rx.recv_timeout(Duration::from_secs(30)), Ok(true));
|
||||
serving.expect("closed", 30);
|
||||
assert!(home.wait_closed(30));
|
||||
// Closed: said so.
|
||||
assert_eq!(close_books(&common).unwrap(), "The books are closed.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_makes_anew_a_token_the_keyring_lost_and_says_when_an_agent_refuses() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let home = Home::new("connect-lost");
|
||||
home.create("acme");
|
||||
// The server keeps the books' agent token; this computer's keyring
|
||||
// lost it.
|
||||
std::fs::remove_dir_all(home.run_dir().join("test-keyring")).unwrap();
|
||||
let (bin, _log) = fake_agent(&home, "claude");
|
||||
let connect = |path: &Path| {
|
||||
home.cmd("connect")
|
||||
.args(["claude", "--book", "acme"])
|
||||
.env("PATH", format!("{}:/usr/bin:/bin", path.display()))
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap()
|
||||
};
|
||||
let out = connect(&bin);
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stdout).contains("Made the books acme' agent token"),
|
||||
"{out:?}"
|
||||
);
|
||||
// The agent's own command refuses: said, with what it said.
|
||||
let refusing = home.dir.path().join("refusing-bin");
|
||||
std::fs::create_dir_all(&refusing).unwrap();
|
||||
let script = refusing.join("claude");
|
||||
std::fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\n[ \"$2\" = add ] && { echo 'no room at the inn' >&2; exit 1; }\nexit 0\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let out = connect(&refusing);
|
||||
assert_eq!(out.status.code(), Some(1), "{out:?}");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
err.contains("Claude Code didn't add the books: no room at the inn"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Company settings from the books dir's `wanderbooks.toml`.
|
||||
//!
|
||||
//! `wanderbooks init` writes that file next to `books-data/`; onboarding
|
||||
//! fills in `[company]`. The server reads the company name (report headers),
|
||||
//! An older books dir keeps that file next to `books-data/` until
|
||||
//! `books.import_settings` moves it into the journal (books of the data
|
||||
//! root never read one). The server reads the company name (report headers),
|
||||
//! the fiscal year start (fiscal-year-to-date reports), the members of a
|
||||
//! multi-member LLC (`[[company.members]]`: name, share, capital account —
|
||||
//! for the statement of members' equity), `[books] accounting_basis`
|
||||
|
||||
@@ -147,6 +147,8 @@ pub struct App {
|
||||
live_label: bool,
|
||||
/// The export menu (menu.rs).
|
||||
menu: crate::menu::Menu,
|
||||
/// The owner's Books menu (owner.rs).
|
||||
owner_menu: crate::owner::OwnerMenu,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -191,6 +193,7 @@ impl App {
|
||||
updated_at: -1e9,
|
||||
live_label: false,
|
||||
menu: Default::default(),
|
||||
owner_menu: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,6 +647,7 @@ impl App {
|
||||
if let Some(b) = &books {
|
||||
ui.add_space(2.0);
|
||||
self.menu.ui(ui, b, false);
|
||||
self.owner_menu.ui(ui, b, false);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
if let Some(b) = &books {
|
||||
@@ -742,6 +746,7 @@ impl App {
|
||||
self.scheme_menu(ui, true);
|
||||
if let Some(b) = &books {
|
||||
self.menu.ui(ui, b, true);
|
||||
self.owner_menu.ui(ui, b, true);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
if let Some(b) = &books {
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod export;
|
||||
pub mod fmt;
|
||||
pub mod menu;
|
||||
pub mod nav;
|
||||
pub mod owner;
|
||||
pub mod pages;
|
||||
pub mod palette;
|
||||
mod prefs;
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
//! The viewer's Books menu (plan P2 "Viewer menu", D-19, D-32): the
|
||||
//! owner's own actions on the books, handed to the books server (back up
|
||||
//! now, restore, new books, close the books) or to the owner's agent
|
||||
//! (connect). The viewer itself still never writes the books.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the owner backs up, restores, starts new books, closes the books
|
||||
//! and connects an agent from the viewer, without a terminal.
|
||||
//! SOURCE: plan P2 ("Viewer menu (D-19)": "Back up now", "Restore…",
|
||||
//! "New books…", "Close the books", "Connect Claude Code" and
|
||||
//! "Connect Codex"; "Nothing in the viewer writes a book"); D-32
|
||||
//! (no confinement: the viewer asks the server itself, as the
|
||||
//! command line does).
|
||||
//! INVARIANTS:
|
||||
//! 1. Every action goes through the [`Owner`] the program gives the
|
||||
//! viewer (the books server, or the agent's own command): nothing
|
||||
//! here writes a file of the books.
|
||||
//! 2. Each runs off the UI thread; what came of it is shown, in words.
|
||||
//! 3. A restore is shown before it is done (the backup proven, what it
|
||||
//! would do), and done only for that backup, at that head.
|
||||
//! 4. Without an owner (the viewer on its own, older books dirs, the
|
||||
//! demo) the menu isn't there.
|
||||
//! ```
|
||||
|
||||
use crate::books::Books;
|
||||
use egui::{RichText, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// The owner's agents the viewer can connect.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Agent {
|
||||
/// Claude Code.
|
||||
Claude,
|
||||
/// Codex.
|
||||
Codex,
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Agent::Claude => "Claude Code",
|
||||
Agent::Codex => "Codex",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A restore previewed: what it would do, and what it is of (INVARIANT 3).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RestorePlan {
|
||||
/// The backup, as the server is told it.
|
||||
pub source: String,
|
||||
/// The books' id it comes back as.
|
||||
pub id: String,
|
||||
/// Whether it replaces the books of that id (kept in legacy/).
|
||||
pub replace: bool,
|
||||
/// The backup's head, proven.
|
||||
pub head: (u64, String),
|
||||
/// What it would do, a line each.
|
||||
pub lines: Vec<String>,
|
||||
}
|
||||
|
||||
/// The owner's actions, done by the program the viewer runs in (ledger-cli
|
||||
/// over the books server and the agents' own commands). Each blocks: the
|
||||
/// menu calls it off the UI thread.
|
||||
pub trait Owner: Send + Sync {
|
||||
/// Close the books: everything holding them lets go.
|
||||
fn close_books(&self) -> Result<String, String>;
|
||||
/// New, empty books `id` in `currency`.
|
||||
fn new_books(&self, id: &str, currency: &str) -> Result<String, String>;
|
||||
/// Back the books `book` up now.
|
||||
fn back_up_now(&self, book: &str) -> Result<Vec<String>, String>;
|
||||
/// Preview restoring the backup at `source` (as `id`, else its own
|
||||
/// books' id), replacing the books of that id or not.
|
||||
fn plan_restore(
|
||||
&self,
|
||||
source: &str,
|
||||
id: Option<String>,
|
||||
replace: bool,
|
||||
) -> Result<RestorePlan, String>;
|
||||
/// Restore as `plan` previewed it.
|
||||
fn restore(&self, plan: &RestorePlan) -> Result<Vec<String>, String>;
|
||||
/// Let `agent` reach the books `book`.
|
||||
fn connect(&self, agent: Agent, book: &str) -> Result<Vec<String>, String>;
|
||||
}
|
||||
|
||||
static OWNER: OnceLock<Arc<dyn Owner>> = OnceLock::new();
|
||||
|
||||
/// Give the viewer its owner (once, at start; INVARIANT 4).
|
||||
pub fn set_owner(owner: Arc<dyn Owner>) {
|
||||
let _ = OWNER.set(owner);
|
||||
}
|
||||
|
||||
fn owner() -> Option<Arc<dyn Owner>> {
|
||||
OWNER.get().cloned()
|
||||
}
|
||||
|
||||
/// The id of books of the data root (their folder's name), or None for
|
||||
/// an older books dir (INVARIANT 4).
|
||||
pub fn book_id(books: &Books) -> Option<String> {
|
||||
ledger_store::root::inside_a_root(&books.data_dir)
|
||||
.then(|| books.data_dir.file_name()?.to_str().map(str::to_string))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
type Outcome = Result<Vec<String>, String>;
|
||||
|
||||
/// A dialog open over the viewer.
|
||||
#[derive(Default)]
|
||||
enum Dialog {
|
||||
#[default]
|
||||
None,
|
||||
/// "New books…": their id and currency.
|
||||
NewBooks { id: String, currency: String },
|
||||
/// "Restore…": the backup, then its preview.
|
||||
Restore(Box<RestoreForm>),
|
||||
/// "Close the books": asked first.
|
||||
Close,
|
||||
}
|
||||
|
||||
/// A restore's check under way, and what it was asked: the backup, the
|
||||
/// id, whether it replaces.
|
||||
type Checking = (
|
||||
Receiver<Result<RestorePlan, String>>,
|
||||
(String, String, bool),
|
||||
);
|
||||
|
||||
#[derive(Default)]
|
||||
struct RestoreForm {
|
||||
source: String,
|
||||
id: String,
|
||||
replace: bool,
|
||||
/// A check under way, and what it was asked (a result for anything
|
||||
/// since edited is dropped).
|
||||
planning: Option<Checking>,
|
||||
plan: Option<RestorePlan>,
|
||||
problem: Option<String>,
|
||||
}
|
||||
|
||||
/// The menu's state: a dialog, the job under way, and the last outcome.
|
||||
#[derive(Default)]
|
||||
pub struct OwnerMenu {
|
||||
/// The books last shown (by identity) and their id: found once for
|
||||
/// each load, not every frame.
|
||||
book: Option<(usize, Option<String>)>,
|
||||
dialog: Dialog,
|
||||
job: Option<(String, Receiver<Outcome>)>,
|
||||
done: Option<(String, Outcome)>,
|
||||
}
|
||||
|
||||
impl OwnerMenu {
|
||||
/// The menu button and a note of the last outcome; its dialogs.
|
||||
pub fn ui(&mut self, ui: &mut Ui, books: &Arc<Books>, compact: bool) {
|
||||
let Some(owner) = owner() else {
|
||||
return;
|
||||
};
|
||||
let key = Arc::as_ptr(books) as usize;
|
||||
if self.book.as_ref().is_none_or(|(k, _)| *k != key) {
|
||||
self.book = Some((key, book_id(books)));
|
||||
}
|
||||
let Some(book) = self.book.as_ref().and_then(|(_, id)| id.clone()) else {
|
||||
return;
|
||||
};
|
||||
let p = crate::theme::pal(ui.ctx());
|
||||
if let Some((what, rx)) = &self.job
|
||||
&& let Ok(done) = rx.try_recv()
|
||||
{
|
||||
self.done = Some((what.clone(), done));
|
||||
self.job = None;
|
||||
}
|
||||
let text = if compact {
|
||||
RichText::new(icon::BOOKS).size(16.0).color(p.muted)
|
||||
} else {
|
||||
RichText::new(format!("{} Books", icon::BOOKS))
|
||||
.font(crate::theme::regular(12.5))
|
||||
.color(p.muted)
|
||||
};
|
||||
let mut button = egui::Button::new(text).frame(false);
|
||||
if compact {
|
||||
button = button.min_size(vec2(32.0, 32.0));
|
||||
}
|
||||
let busy = self.job.is_some();
|
||||
let mut start: Option<(String, Box<dyn FnOnce() -> Outcome + Send>)> = None;
|
||||
let response = ui
|
||||
.add_enabled_ui(!busy, |ui| {
|
||||
egui::containers::menu::MenuButton::from_button(button).ui(ui, |ui| {
|
||||
ui.set_min_width(240.0);
|
||||
if ui
|
||||
.button(format!("{} Back up now", icon::CLOUD_ARROW_UP))
|
||||
.on_hover_text("Back these books up now, to every backup their settings name")
|
||||
.clicked()
|
||||
{
|
||||
let (o, b) = (owner.clone(), book.clone());
|
||||
start = Some(("Back up now".into(), Box::new(move || o.back_up_now(&b))));
|
||||
}
|
||||
if ui
|
||||
.button(format!("{} Restore…", icon::CLOCK_COUNTER_CLOCKWISE))
|
||||
.on_hover_text("Bring back books from a backup, proven whole first")
|
||||
.clicked()
|
||||
{
|
||||
self.dialog = Dialog::Restore(Box::default());
|
||||
}
|
||||
if ui
|
||||
.button(format!("{} New books…", icon::BOOK_OPEN))
|
||||
.on_hover_text("Start new, empty books beside these")
|
||||
.clicked()
|
||||
{
|
||||
self.dialog = Dialog::NewBooks {
|
||||
id: String::new(),
|
||||
currency: "USD".into(),
|
||||
};
|
||||
}
|
||||
ui.separator();
|
||||
for agent in [Agent::Claude, Agent::Codex] {
|
||||
if ui
|
||||
.button(format!("{} Connect {}", icon::PLUGS_CONNECTED, agent.label()))
|
||||
.on_hover_text(format!(
|
||||
"Let {} reach these books (it keeps their token in this computer's keyring)",
|
||||
agent.label()
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
let (o, b) = (owner.clone(), book.clone());
|
||||
start = Some((
|
||||
format!("Connect {}", agent.label()),
|
||||
Box::new(move || o.connect(agent, &b)),
|
||||
));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui
|
||||
.button(format!("{} Close the books", icon::POWER))
|
||||
.on_hover_text("Close the books now: agents' sessions and every other holder let go")
|
||||
.clicked()
|
||||
{
|
||||
self.dialog = Dialog::Close;
|
||||
}
|
||||
})
|
||||
})
|
||||
.inner;
|
||||
if let Some((what, work)) = start {
|
||||
self.start(ui.ctx(), what, work);
|
||||
}
|
||||
self.dialogs(ui.ctx(), &owner);
|
||||
let said = if self.job.is_some() {
|
||||
Some(("Working…".to_string(), p.muted))
|
||||
} else {
|
||||
self.done.as_ref().map(|(what, done)| match done {
|
||||
Ok(lines) => (format!("{what}: {}", lines.join(" ")), p.muted),
|
||||
Err(why) => (format!("{what}: {why}"), p.warning),
|
||||
})
|
||||
};
|
||||
match said {
|
||||
Some((text, _)) if compact => {
|
||||
response.0.on_hover_text(text);
|
||||
}
|
||||
Some((text, color)) => {
|
||||
ui.add(egui::Label::new(RichText::new(text).size(12.0).color(color)).wrap());
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn start(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
what: String,
|
||||
work: Box<dyn FnOnce() -> Outcome + Send>,
|
||||
) {
|
||||
let (tx, rx) = channel();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(work());
|
||||
ctx.request_repaint();
|
||||
});
|
||||
self.job = Some((what, rx));
|
||||
self.done = None;
|
||||
}
|
||||
|
||||
/// The open dialog, if any.
|
||||
fn dialogs(&mut self, ctx: &egui::Context, owner: &Arc<dyn Owner>) {
|
||||
let mut close = false;
|
||||
let mut run: Option<(String, Box<dyn FnOnce() -> Outcome + Send>)> = None;
|
||||
match &mut self.dialog {
|
||||
Dialog::None => {}
|
||||
Dialog::Close => {
|
||||
egui::Modal::new(egui::Id::new("owner-close")).show(ctx, |ui| {
|
||||
ui.heading("Close the books?");
|
||||
ui.label("Agents' sessions and everything else holding them let go. They open again when something holds them.");
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Close the books").clicked() {
|
||||
let o = owner.clone();
|
||||
run = Some(("Close the books".into(), Box::new(move || o.close_books().map(|l| vec![l]))));
|
||||
close = true;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Dialog::NewBooks { id, currency } => {
|
||||
egui::Modal::new(egui::Id::new("owner-new-books")).show(ctx, |ui| {
|
||||
ui.heading("New books");
|
||||
ui.label("Their id: lowercase letters, digits and hyphens.");
|
||||
ui.text_edit_singleline(id);
|
||||
ui.label("Their base currency (fixed for good):");
|
||||
ui.text_edit_singleline(currency);
|
||||
ui.horizontal(|ui| {
|
||||
let ready = !id.trim().is_empty() && !currency.trim().is_empty();
|
||||
if ui
|
||||
.add_enabled(ready, egui::Button::new("Make them"))
|
||||
.clicked()
|
||||
{
|
||||
let (o, i, c) = (owner.clone(), id.clone(), currency.clone());
|
||||
run = Some((
|
||||
"New books".into(),
|
||||
Box::new(move || o.new_books(&i, &c).map(|l| vec![l])),
|
||||
));
|
||||
close = true;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Dialog::Restore(form) => {
|
||||
if let Some((rx, asked)) = &form.planning
|
||||
&& let Ok(planned) = rx.try_recv()
|
||||
{
|
||||
let current = (form.source.clone(), form.id.clone(), form.replace);
|
||||
let still = *asked == current;
|
||||
form.planning = None;
|
||||
match planned {
|
||||
// What was checked is still what is asked.
|
||||
Ok(plan) if still => {
|
||||
form.id = plan.id.clone();
|
||||
form.plan = Some(plan);
|
||||
form.problem = None;
|
||||
}
|
||||
Err(why) if still => form.problem = Some(why),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
egui::Modal::new(egui::Id::new("owner-restore")).show(ctx, |ui| {
|
||||
ui.set_max_width(520.0);
|
||||
ui.heading("Restore books");
|
||||
ui.label("The backup: its repository's address, or its folder (the books' .git folder in your backup folder).");
|
||||
// Not changed while it is being checked.
|
||||
let open = form.planning.is_none();
|
||||
let edited = ui
|
||||
.add_enabled(
|
||||
open,
|
||||
egui::TextEdit::singleline(&mut form.source)
|
||||
.desired_width(f32::INFINITY)
|
||||
.hint_text("/media/backup/acme.git, or git@host:you/books.git"),
|
||||
)
|
||||
.changed();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("As the books");
|
||||
let id_edited = ui
|
||||
.add_enabled(
|
||||
open,
|
||||
egui::TextEdit::singleline(&mut form.id).hint_text("its own books' id"),
|
||||
)
|
||||
.changed();
|
||||
let replace_edited = ui
|
||||
.add_enabled(
|
||||
open,
|
||||
egui::Checkbox::new(&mut form.replace, "replacing them (kept in the legacy folder)"),
|
||||
)
|
||||
.changed();
|
||||
if id_edited || replace_edited {
|
||||
form.plan = None;
|
||||
}
|
||||
});
|
||||
if edited {
|
||||
form.plan = None;
|
||||
form.problem = None;
|
||||
}
|
||||
if let Some(why) = &form.problem {
|
||||
ui.colored_label(crate::theme::pal(ui.ctx()).warning, why);
|
||||
}
|
||||
if let Some(plan) = &form.plan {
|
||||
ui.separator();
|
||||
for line in &plan.lines {
|
||||
ui.label(line);
|
||||
}
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
let planning = form.planning.is_some();
|
||||
match &form.plan {
|
||||
None => {
|
||||
let ready = !planning && !form.source.trim().is_empty();
|
||||
if ui.add_enabled(ready, egui::Button::new("Check it")).clicked() {
|
||||
let (tx, rx) = channel();
|
||||
let (o, s, r) = (owner.clone(), form.source.clone(), form.replace);
|
||||
let id = Some(form.id.trim().to_string()).filter(|i| !i.is_empty());
|
||||
let ctx = ui.ctx().clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(o.plan_restore(&s, id, r));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
let asked = (form.source.clone(), form.id.clone(), form.replace);
|
||||
form.planning = Some((rx, asked));
|
||||
form.problem = None;
|
||||
}
|
||||
if planning {
|
||||
ui.label("Checking the backup…");
|
||||
}
|
||||
}
|
||||
Some(plan) => {
|
||||
if ui.button("Restore it").clicked() {
|
||||
let (o, plan) = (owner.clone(), plan.clone());
|
||||
run = Some(("Restore".into(), Box::new(move || o.restore(&plan))));
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
if close {
|
||||
self.dialog = Dialog::None;
|
||||
}
|
||||
if let Some((what, work)) = run {
|
||||
self.start(ctx, what, work);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
//! The viewer's Books menu (owner.rs, plan P2 "Viewer menu", D-32): each
|
||||
//! item hands its work to the owner the program gives the viewer, off the
|
||||
//! UI thread, and says what came of it; a restore is previewed first; the
|
||||
//! viewer itself writes nothing of the books; without an owner, or for an
|
||||
//! older books dir, the menu isn't there.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use ledger_core::Currency;
|
||||
use ledger_gui::App;
|
||||
use ledger_gui::books::Books;
|
||||
use ledger_gui::data::{self, Source};
|
||||
use ledger_gui::nav::Route;
|
||||
use ledger_gui::owner::{Agent, Owner, RestorePlan};
|
||||
use ledger_store::Store;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
/// An owner that does nothing but say what it was asked.
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
asked: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn said(&self, what: String) {
|
||||
self.asked
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(what);
|
||||
}
|
||||
}
|
||||
|
||||
impl Owner for Fake {
|
||||
fn close_books(&self) -> Result<String, String> {
|
||||
self.said("close".into());
|
||||
Ok("The books are closing.".into())
|
||||
}
|
||||
fn new_books(&self, id: &str, currency: &str) -> Result<String, String> {
|
||||
self.said(format!("new {id} {currency}"));
|
||||
Ok(format!("Made new books \"{id}\" ({currency})."))
|
||||
}
|
||||
fn back_up_now(&self, book: &str) -> Result<Vec<String>, String> {
|
||||
// Slow enough to be seen at work.
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
self.said(format!("back up {book}"));
|
||||
Err("Not backed up to the backup folder: the backup folder isn't there (is its disk connected?)".into())
|
||||
}
|
||||
fn plan_restore(
|
||||
&self,
|
||||
source: &str,
|
||||
id: Option<String>,
|
||||
replace: bool,
|
||||
) -> Result<RestorePlan, String> {
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
self.said(format!("plan {source} {id:?} {replace}"));
|
||||
Ok(RestorePlan {
|
||||
source: source.to_string(),
|
||||
id: id.unwrap_or_else(|| "acme".into()),
|
||||
replace,
|
||||
head: (7, "ab".repeat(32)),
|
||||
lines: vec!["The backup at the disk is whole: through event 7.".into()],
|
||||
})
|
||||
}
|
||||
fn restore(&self, plan: &RestorePlan) -> Result<Vec<String>, String> {
|
||||
self.said(format!(
|
||||
"restore {} {} {}",
|
||||
plan.source, plan.id, plan.head.0
|
||||
));
|
||||
Ok(vec![format!("Restored as \"{}\".", plan.id)])
|
||||
}
|
||||
fn connect(&self, agent: Agent, book: &str) -> Result<Vec<String>, String> {
|
||||
self.said(format!("connect {agent:?} {book}"));
|
||||
Ok(vec!["Claude Code reaches the books.".into()])
|
||||
}
|
||||
}
|
||||
|
||||
/// One test at a time: they share the one owner and its record of calls.
|
||||
fn alone() -> std::sync::MutexGuard<'static, ()> {
|
||||
static ONE: Mutex<()> = Mutex::new(());
|
||||
ONE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// The one owner of this test binary (the viewer takes one, once).
|
||||
fn fake() -> Arc<Fake> {
|
||||
static FAKE: OnceLock<Arc<Fake>> = OnceLock::new();
|
||||
FAKE.get_or_init(|| {
|
||||
let f = Arc::new(Fake::default());
|
||||
ledger_gui::owner::set_owner(f.clone());
|
||||
f
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn today() -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 10, 15).unwrap()
|
||||
}
|
||||
|
||||
/// Books in a data root (`root`: a folder holding the root's marker), or
|
||||
/// an older books dir's.
|
||||
fn books(root: bool) -> (tempfile::TempDir, PathBuf, Books) {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("wb-gui-owner-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let book = if root {
|
||||
std::fs::write(dir.path().join(ledger_store::root::MARKER_FILE), b"").unwrap();
|
||||
dir.path().join("books").join("acme")
|
||||
} else {
|
||||
dir.path().join("books-data")
|
||||
};
|
||||
std::fs::create_dir_all(&book).unwrap();
|
||||
drop(Store::create(&book, Currency::USD).unwrap());
|
||||
let b = data::load_once(&Source {
|
||||
data_dir: book.clone(),
|
||||
today: Some(today()),
|
||||
})
|
||||
.unwrap();
|
||||
(dir, book, b)
|
||||
}
|
||||
|
||||
/// Every file under `dir`, with its bytes.
|
||||
fn files(dir: &Path) -> Vec<(PathBuf, Vec<u8>)> {
|
||||
let mut out = Vec::new();
|
||||
for e in std::fs::read_dir(dir).unwrap() {
|
||||
let p = e.unwrap().path();
|
||||
if p.is_dir() {
|
||||
out.extend(files(&p));
|
||||
} else {
|
||||
out.push((p.clone(), std::fs::read(&p).unwrap()));
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
fn harness(b: Books) -> Harness<'static, App> {
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1200.0, 900.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Overview));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
/// Run until the owner was asked `n` things, and the outcome shows.
|
||||
fn settle(h: &mut Harness<'static, App>, f: &Fake, n: usize) {
|
||||
for _ in 0..200 {
|
||||
h.run_steps(2);
|
||||
if f.asked.lock().unwrap().len() >= n {
|
||||
h.run_steps(4);
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
panic!("the owner wasn't asked: {:?}", f.asked.lock().unwrap());
|
||||
}
|
||||
|
||||
fn open_menu(h: &mut Harness<'static, App>) {
|
||||
h.get_by_label_contains(" Books").click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_books_menu_hands_each_action_to_the_owner_and_writes_nothing() {
|
||||
let _alone = alone();
|
||||
let f = fake();
|
||||
let (_dir, book, b) = books(true);
|
||||
let before = files(&book);
|
||||
let mut h = harness(b);
|
||||
let start = f.asked.lock().unwrap().len();
|
||||
|
||||
// Back up now: done by the owner; its trouble said, in its words.
|
||||
open_menu(&mut h);
|
||||
h.get_by_label_contains("Back up now").click();
|
||||
settle(&mut h, &f, start + 1);
|
||||
assert_eq!(f.asked.lock().unwrap()[start], "back up acme");
|
||||
assert!(h.query_by_label_contains("is its disk connected").is_some());
|
||||
|
||||
// Connect Claude Code.
|
||||
open_menu(&mut h);
|
||||
h.get_by_label_contains("Connect Claude Code").click();
|
||||
settle(&mut h, &f, start + 2);
|
||||
assert_eq!(f.asked.lock().unwrap()[start + 1], "connect Claude acme");
|
||||
|
||||
// New books: asked for, then made.
|
||||
let page_fields = h
|
||||
.query_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.count();
|
||||
open_menu(&mut h);
|
||||
h.get_by_label_contains("New books").click();
|
||||
h.run_steps(3);
|
||||
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.nth(page_fields)
|
||||
.unwrap()
|
||||
.focus();
|
||||
h.run_steps(2);
|
||||
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.nth(page_fields)
|
||||
.unwrap()
|
||||
.type_text("beta");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Make them").click();
|
||||
settle(&mut h, &f, start + 3);
|
||||
assert_eq!(f.asked.lock().unwrap()[start + 2], "new beta USD");
|
||||
|
||||
// Restore: previewed (the backup checked), then done for that plan.
|
||||
open_menu(&mut h);
|
||||
h.get_by_label_contains("Restore…").click();
|
||||
h.run_steps(3);
|
||||
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.nth(page_fields)
|
||||
.unwrap()
|
||||
.focus();
|
||||
h.run_steps(2);
|
||||
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.nth(page_fields)
|
||||
.unwrap()
|
||||
.type_text("/media/disk/acme.git");
|
||||
h.run_steps(2);
|
||||
assert!(
|
||||
h.query_by_label("Restore it").is_none(),
|
||||
"not before it is checked"
|
||||
);
|
||||
h.get_by_label("Check it").click();
|
||||
settle(&mut h, &f, start + 4);
|
||||
assert_eq!(
|
||||
f.asked.lock().unwrap()[start + 3],
|
||||
"plan /media/disk/acme.git None false"
|
||||
);
|
||||
assert!(
|
||||
h.query_by_label_contains("is whole: through event 7")
|
||||
.is_some()
|
||||
);
|
||||
h.get_by_label("Restore it").click();
|
||||
settle(&mut h, &f, start + 5);
|
||||
assert_eq!(
|
||||
f.asked.lock().unwrap()[start + 4],
|
||||
"restore /media/disk/acme.git acme 7"
|
||||
);
|
||||
|
||||
// Close the books: asked first; cancelled, nothing; then done.
|
||||
open_menu(&mut h);
|
||||
h.get_by_label_contains("Close the books").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(f.asked.lock().unwrap().len(), start + 5);
|
||||
open_menu(&mut h);
|
||||
h.get_by_label_contains("Close the books").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Close the books").click();
|
||||
settle(&mut h, &f, start + 6);
|
||||
assert_eq!(f.asked.lock().unwrap()[start + 5], "close");
|
||||
|
||||
// The viewer wrote nothing of the books.
|
||||
drop(h);
|
||||
let after: Vec<_> = files(&book)
|
||||
.into_iter()
|
||||
.filter(|(p, _)| !p.starts_with(book.join("ops")))
|
||||
.collect();
|
||||
let before: Vec<_> = before
|
||||
.into_iter()
|
||||
.filter(|(p, _)| !p.starts_with(book.join("ops")))
|
||||
.collect();
|
||||
assert_eq!(after, before);
|
||||
}
|
||||
|
||||
/// Whether the widget is disabled.
|
||||
fn disabled(node: &egui_kittest::Node<'_>) -> bool {
|
||||
use egui_kittest::kittest::NodeT;
|
||||
node.accesskit_node().is_disabled()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_changed_while_it_is_at_work() {
|
||||
let _alone = alone();
|
||||
let f = fake();
|
||||
let (_dir, _book, b) = books(true);
|
||||
let mut h = harness(b);
|
||||
// A backup under way: the menu waits for it.
|
||||
let n = f.asked.lock().unwrap().len();
|
||||
open_menu(&mut h);
|
||||
h.get_by_label_contains("Back up now").click();
|
||||
h.run_steps(2);
|
||||
assert!(
|
||||
disabled(&h.get_by_label_contains(" Books")),
|
||||
"the menu works on while busy"
|
||||
);
|
||||
settle(&mut h, &f, n + 1);
|
||||
for _ in 0..50 {
|
||||
h.run_steps(2);
|
||||
if !disabled(&h.get_by_label_contains(" Books")) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
assert!(!disabled(&h.get_by_label_contains(" Books")));
|
||||
// A restore being checked: what it asks can't change under it.
|
||||
let page_fields = h
|
||||
.query_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.count();
|
||||
open_menu(&mut h);
|
||||
h.get_by_label_contains("Restore…").click();
|
||||
h.run_steps(3);
|
||||
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.nth(page_fields)
|
||||
.unwrap()
|
||||
.focus();
|
||||
h.run_steps(2);
|
||||
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.nth(page_fields)
|
||||
.unwrap()
|
||||
.type_text("/media/disk/acme.git");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Check it").click();
|
||||
h.run_steps(2);
|
||||
for field in h
|
||||
.get_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.skip(page_fields)
|
||||
{
|
||||
assert!(
|
||||
disabled(&field),
|
||||
"a field could change while it was checked"
|
||||
);
|
||||
}
|
||||
let n = f.asked.lock().unwrap().len();
|
||||
settle(&mut h, &f, n + 1);
|
||||
assert!(h.query_by_label("Restore it").is_some());
|
||||
// Edited after the check: checked again before it can be restored.
|
||||
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.nth(page_fields)
|
||||
.unwrap()
|
||||
.focus();
|
||||
h.run_steps(2);
|
||||
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.nth(page_fields)
|
||||
.unwrap()
|
||||
.type_text("x");
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Restore it").is_none());
|
||||
assert!(h.query_by_label("Check it").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_older_books_dir_has_no_books_menu() {
|
||||
let _alone = alone();
|
||||
let _ = fake();
|
||||
let (_dir, _book, b) = books(false);
|
||||
let h = harness(b);
|
||||
assert!(h.query_by_label_contains(" Books").is_none());
|
||||
}
|
||||
|
||||
/// Screenshots of the menu and its dialogs, for eyes: set `SHOT_DIR`.
|
||||
#[test]
|
||||
fn screenshots() {
|
||||
let Ok(out) = std::env::var("SHOT_DIR") else {
|
||||
return;
|
||||
};
|
||||
let _alone = alone();
|
||||
let out = PathBuf::from(out);
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
let f = fake();
|
||||
let (_dir, _book, b) = books(true);
|
||||
let mut h = harness(b);
|
||||
let shot = |h: &mut Harness<'static, App>, name: &str| {
|
||||
h.run_steps(3);
|
||||
h.render()
|
||||
.unwrap()
|
||||
.save(out.join(format!("{name}.png")))
|
||||
.unwrap();
|
||||
};
|
||||
open_menu(&mut h);
|
||||
shot(&mut h, "menu");
|
||||
h.get_by_label_contains("Back up now").click();
|
||||
let n = f.asked.lock().unwrap().len();
|
||||
settle(&mut h, &f, n + 1);
|
||||
shot(&mut h, "backed-up");
|
||||
let page_fields = h
|
||||
.query_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.count();
|
||||
open_menu(&mut h);
|
||||
h.get_by_label_contains("New books").click();
|
||||
shot(&mut h, "new-books");
|
||||
h.get_by_label("Cancel").click();
|
||||
open_menu(&mut h);
|
||||
h.get_by_label_contains("Restore…").click();
|
||||
h.run_steps(3);
|
||||
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.nth(page_fields)
|
||||
.unwrap()
|
||||
.focus();
|
||||
h.run_steps(2);
|
||||
h.get_all_by_role(egui::accesskit::Role::TextInput)
|
||||
.nth(page_fields)
|
||||
.unwrap()
|
||||
.type_text("/media/disk/acme.git");
|
||||
shot(&mut h, "restore-ask");
|
||||
h.get_by_label("Check it").click();
|
||||
let n = f.asked.lock().unwrap().len();
|
||||
settle(&mut h, &f, n + 1);
|
||||
shot(&mut h, "restore-plan");
|
||||
h.get_by_label("Cancel").click();
|
||||
open_menu(&mut h);
|
||||
h.get_by_label_contains("Close the books").click();
|
||||
shot(&mut h, "close");
|
||||
}
|
||||
@@ -50,7 +50,7 @@
|
||||
|
||||
use crate::knobs::{FLAG, Knobs};
|
||||
use crate::lease::{HostMsg, Kind, Reason};
|
||||
use crate::opener::{Opener, Starter};
|
||||
use crate::opener::Opener;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -1241,15 +1241,7 @@ pub fn run(
|
||||
let book = ledger_store::root::BookId::parse(&book)
|
||||
.map_err(|e| e.to_string())?
|
||||
.to_string();
|
||||
// The helper first, before any thread (helper.rs INVARIANT 4).
|
||||
let helper = Arc::new(crate::helper::Client::spawn(root.as_deref(), &knobs)?);
|
||||
let opener = Opener::new(
|
||||
root.as_deref(),
|
||||
knobs,
|
||||
Starter::Helper(helper.clone()),
|
||||
Kind::Bridge,
|
||||
Some(book.clone()),
|
||||
)?;
|
||||
let opener = Opener::new(root.as_deref(), knobs, Kind::Bridge, Some(book.clone()))?;
|
||||
// Kept for the whole session: the client reaches the socket by it.
|
||||
let socket =
|
||||
ledger_store::root::SocketPath::new(opener.run_dir(), ledger_mcp::local::MCP_SOCKET)
|
||||
@@ -1337,7 +1329,6 @@ pub fn run(
|
||||
Ok::<(), String>(())
|
||||
});
|
||||
rt.shutdown_timeout(Duration::from_secs(1));
|
||||
drop(helper);
|
||||
result.map_err(Into::into)
|
||||
}
|
||||
|
||||
|
||||
+553
-123
@@ -1,10 +1,14 @@
|
||||
//! The owner's commands: `wanderbooks serve`, `close`, `books` and
|
||||
//! `token` (plan 3.3, 3.9, 5.5).
|
||||
//! The owner's commands: `wanderbooks serve`, `close`, `books` (list,
|
||||
//! create, adopt, restore, back-up), `token`, `connect`, `doctor` and
|
||||
//! `passphrase` (plan 3.3, 3.7, 3.9, 5.5), and the same work for the
|
||||
//! viewer's Books menu (`new_books`, `back_up_now`, `plan_restore`,
|
||||
//! `carry_out_restore`, `connect_agent`, `close_books`, `hold_for_viewer`).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: from a terminal the owner holds the books open, closes them, and
|
||||
//! makes, lists and adopts books, without ever naming or touching
|
||||
//! the books' private folder.
|
||||
//! GOAL: from a terminal, or the viewer's menu, the owner holds the books
|
||||
//! open, closes them, makes, adopts, backs up and restores books and
|
||||
//! connects agents, without ever naming or touching the books'
|
||||
//! private folder.
|
||||
//! SOURCE: plan/served.md 3.9 (a terminal running `wanderbooks serve`, and
|
||||
//! any command that needs the server, holds a lease; "Close the
|
||||
//! books" ends every lease), 3.1 (the CLI talks to admin.sock and
|
||||
@@ -20,12 +24,16 @@
|
||||
//! 4. A token made is kept in the keyring before anything is said of it,
|
||||
//! and its text is printed only by `token print`; one that can't be
|
||||
//! kept is revoked again. A token revoked is forgotten by the keyring
|
||||
//! too.
|
||||
//! too. `connect` makes the books' agent token only when the keyring
|
||||
//! has none, replacing the server's (no one could be using it: the
|
||||
//! bridge reads its token from this keyring).
|
||||
//! 5. The viewer's hold lets go only when the owner closes the books;
|
||||
//! books that stop, or won't open, are tried again.
|
||||
//! ```
|
||||
|
||||
use crate::knobs::{FLAG, Knobs};
|
||||
use crate::lease::{Ask, HostMsg, Intent, Kind, Reason};
|
||||
use crate::opener::{HoldError, Lease, Opener, Starter};
|
||||
use crate::opener::{HoldError, Lease, Opener};
|
||||
use ledger_mcp::admin::{self, Request, Response};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
@@ -38,10 +46,13 @@ const LIST_WAIT: Duration = Duration::from_secs(30);
|
||||
const CLOSE_WAIT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Common flags: `--root DIR` and the test knobs.
|
||||
#[derive(Default)]
|
||||
struct Common {
|
||||
root: Option<PathBuf>,
|
||||
knobs: Knobs,
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Common {
|
||||
/// The data root named (`--root`), else the one this account's host
|
||||
/// finds.
|
||||
pub root: Option<PathBuf>,
|
||||
/// The test knobs.
|
||||
pub knobs: Knobs,
|
||||
}
|
||||
|
||||
impl Common {
|
||||
@@ -119,13 +130,7 @@ fn with_server_in(common: &Common, request: Request) -> Result<(Response, PathBu
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let outcome = rt.block_on(async {
|
||||
let mut opener = Opener::new(
|
||||
common.root.as_deref(),
|
||||
common.knobs,
|
||||
Starter::Direct,
|
||||
Kind::Cli,
|
||||
None,
|
||||
)?;
|
||||
let mut opener = Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None)?;
|
||||
let lease = open(&mut opener).await.map_err(|e| match e {
|
||||
HoldError::ClosedByOwner => {
|
||||
"the books were closed just now (Close the books): nothing was done".to_string()
|
||||
@@ -219,7 +224,6 @@ pub fn serve(
|
||||
let mut opener = Opener::new(
|
||||
common.root.as_deref(),
|
||||
common.knobs,
|
||||
Starter::Direct,
|
||||
Kind::Serve,
|
||||
None,
|
||||
)?;
|
||||
@@ -393,9 +397,13 @@ fn books_usage(prog: &str) {
|
||||
" {prog} adopt DATA-DIR --id ID [--toml FILE] [--config-json FILE] [--vault DIR --vault-key FILE | --no-vault] [--skip-unknown] [--yes]"
|
||||
);
|
||||
eprintln!(" {prog} restore BACKUP [--id ID] [--replace] [--yes]");
|
||||
eprintln!(" {prog} back-up ID [--new-disk]");
|
||||
eprintln!(" list the books, and whether each is open");
|
||||
eprintln!(" create start new, empty books (base currency USD unless given)");
|
||||
eprintln!(" adopt copy existing books in, verified; the original is left as it was");
|
||||
eprintln!(
|
||||
" back-up back the books up now to every backup their settings name (--new-disk: the backup folder's disk is new)"
|
||||
);
|
||||
eprintln!(
|
||||
" restore bring back a backup (a git repository's address, or BOOK.git in the backup folder), proven whole first: as new books, or replacing books (kept in legacy/)"
|
||||
);
|
||||
@@ -418,6 +426,7 @@ pub fn books(
|
||||
let mut skip_unknown = false;
|
||||
let mut yes = false;
|
||||
let mut replace = false;
|
||||
let mut new_disk = false;
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
if common.take(&arg, &mut args)? {
|
||||
@@ -434,6 +443,7 @@ pub fn books(
|
||||
"--no-vault" => no_vault = true,
|
||||
"--skip-unknown" => skip_unknown = true,
|
||||
"--replace" => replace = true,
|
||||
"--new-disk" => new_disk = true,
|
||||
"--yes" => yes = true,
|
||||
"--help" | "-h" => {
|
||||
books_usage(prog);
|
||||
@@ -478,6 +488,20 @@ pub fn books(
|
||||
);
|
||||
}
|
||||
["restore", source] => return restore(&common, source, id, replace, yes),
|
||||
["back-up", book] => {
|
||||
return match back_up_now(&common, book, new_disk) {
|
||||
Ok(lines) => {
|
||||
for line in lines {
|
||||
println!("{line}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(why) => {
|
||||
eprintln!("error: {why}");
|
||||
Err(exit(1))
|
||||
}
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
books_usage(prog);
|
||||
return Err(exit(2));
|
||||
@@ -521,6 +545,386 @@ pub fn books(
|
||||
}
|
||||
}
|
||||
|
||||
/// Back the books `book` up now (the server does it): what came of it, a
|
||||
/// line each; Err when it couldn't be done, or a target didn't take it.
|
||||
pub fn back_up_now(common: &Common, book: &str, new_disk: bool) -> Result<Vec<String>, String> {
|
||||
match with_server(
|
||||
common,
|
||||
Request::BackUpNow {
|
||||
id: book.to_string(),
|
||||
new_disk,
|
||||
},
|
||||
)? {
|
||||
Response::BackedUp { head_seq, targets } => {
|
||||
let lines = backed_up_lines(head_seq, &targets);
|
||||
if targets.is_empty() || targets.iter().any(|t| t.problem.is_some()) {
|
||||
Err(lines.join("\n"))
|
||||
} else {
|
||||
Ok(lines)
|
||||
}
|
||||
}
|
||||
Response::Refused(why) => Err(why),
|
||||
other => Err(format!("the books server answered oddly: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// "New books…": new, empty books `id` in `currency` (the server makes
|
||||
/// them).
|
||||
pub fn new_books(common: &Common, id: &str, currency: &str) -> Result<String, String> {
|
||||
match with_server(
|
||||
common,
|
||||
Request::Create {
|
||||
id: id.trim().to_string(),
|
||||
currency: currency.trim().to_string(),
|
||||
},
|
||||
)? {
|
||||
Response::Created { id, problem: None } => {
|
||||
Ok(format!("Made new books \"{id}\" ({}).", currency.trim()))
|
||||
}
|
||||
Response::Created {
|
||||
id,
|
||||
problem: Some(why),
|
||||
} => Err(format!(
|
||||
"new books \"{id}\" were made but didn't open: {why}. Closing the books and opening them again tries again."
|
||||
)),
|
||||
Response::Refused(why) => Err(why),
|
||||
other => Err(format!("the books server answered oddly: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// "Close the books": everything holding them lets go (the lease
|
||||
/// socket's close intent). What came of it.
|
||||
pub fn close_books(common: &Common) -> Result<String, String> {
|
||||
let found =
|
||||
ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
|
||||
let run = found.run_dir().map_err(|e| e.to_string())?;
|
||||
match crate::opener::ask_once_within(&run, Ask::Intent(Intent::Close), HOST_WAIT)? {
|
||||
None => Ok("The books are closed.".into()),
|
||||
Some(HostMsg::Done { detail }) => Ok(detail
|
||||
.map(|d| {
|
||||
let mut c = d.chars();
|
||||
c.next()
|
||||
.map(|first| format!("{}{}.", first.to_uppercase(), c.as_str()))
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.filter(|d| !d.is_empty())
|
||||
.unwrap_or_else(|| "The books are closing.".into())),
|
||||
Some(HostMsg::Refused(why)) => Err(why),
|
||||
Some(HostMsg::Uncertain(why)) => Err(why),
|
||||
Some(other) => Err(format!("the books' host answered oddly: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The longest an agent's own command may take to add the books.
|
||||
const CONNECT_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// How long the viewer's hold waits before trying again after books that
|
||||
/// wouldn't open.
|
||||
const VIEWER_RETRY: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Hold the books `book` open while the viewer runs (a lease of kind
|
||||
/// app), on a thread of its own: the window never waits for it. It lets
|
||||
/// go only when the owner closes the books (and when the viewer ends,
|
||||
/// with the process); books that stop, or won't open, are tried again.
|
||||
pub fn hold_for_viewer(common: &Common, book: Option<String>) -> std::thread::JoinHandle<()> {
|
||||
let common = common.clone();
|
||||
std::thread::spawn(move || {
|
||||
let Ok(rt) = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
rt.block_on(async {
|
||||
let Ok(mut opener) = Opener::new(common.root.as_deref(), common.knobs, Kind::App, book)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
// The same opener each time round (it knows it held the
|
||||
// books, so it never reopens books the owner closed); its
|
||||
// failures counted afresh.
|
||||
opener.forget_failures();
|
||||
loop {
|
||||
let mut lease = match open(&mut opener).await {
|
||||
Ok(lease) => lease,
|
||||
Err(HoldError::ClosedByOwner) => return,
|
||||
Err(HoldError::Failed(_)) => break,
|
||||
};
|
||||
match lease.next().await {
|
||||
Some(HostMsg::Ending {
|
||||
reason: Reason::ClosedByOwner,
|
||||
..
|
||||
}) => return,
|
||||
Some(HostMsg::Ending {
|
||||
reason: Reason::Restarting | Reason::Closing,
|
||||
..
|
||||
}) => {}
|
||||
_ => {
|
||||
if opener.failed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(VIEWER_RETRY).await;
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/// What a backup made now came to, a line each.
|
||||
pub fn backed_up_lines(head_seq: u64, targets: &[admin::BackedUpTarget]) -> Vec<String> {
|
||||
if targets.is_empty() {
|
||||
return vec![
|
||||
"No backups are set up: ask your agent to set the private repository and the backup folder.".into(),
|
||||
];
|
||||
}
|
||||
targets
|
||||
.iter()
|
||||
.map(|t| match (&t.problem, t.through_seq) {
|
||||
(None, Some(seq)) if seq == head_seq => {
|
||||
format!("Backed up to {} (through event {seq}).", t.at)
|
||||
}
|
||||
(None, Some(seq)) => format!("Backed up to {} through event {seq}.", t.at),
|
||||
(None, None) => format!("Not yet backed up to {}.", t.at),
|
||||
(Some(why), _) => format!("Not backed up to {}: {why}", t.at),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `wanderbooks connect claude|codex --book ID`: an agent of the owner's
|
||||
/// reaches the books.
|
||||
pub fn connect(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut common = Common::default();
|
||||
let mut book: Option<String> = None;
|
||||
let mut words: Vec<String> = Vec::new();
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
if common.take(&arg, &mut args)? {
|
||||
continue;
|
||||
}
|
||||
match arg.as_str() {
|
||||
"--book" => book = Some(args.next().ok_or("--book needs an id")?),
|
||||
"--help" | "-h" => {
|
||||
eprintln!("usage: {prog} claude|codex --book ID");
|
||||
eprintln!(
|
||||
" Lets Claude Code or Codex reach the books: keeps their token in this computer's keyring and adds the books to the agent's own list."
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
s if s.starts_with('-') => {
|
||||
eprintln!("unknown flag: {s}");
|
||||
eprintln!("usage: {prog} claude|codex --book ID");
|
||||
return Err(exit(2));
|
||||
}
|
||||
_ => words.push(arg),
|
||||
}
|
||||
}
|
||||
let (Some(agent), Some(book), 1) = (words.first(), book, words.len()) else {
|
||||
eprintln!("usage: {prog} claude|codex --book ID");
|
||||
return Err(exit(2));
|
||||
};
|
||||
match connect_agent(&common, agent, &book) {
|
||||
Ok(lines) => {
|
||||
for line in lines {
|
||||
println!("{line}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(why) => {
|
||||
eprintln!("error: {why}");
|
||||
Err(exit(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Let the agent `agent` ("claude" or "codex") reach the books `book`:
|
||||
/// their token kept in the keyring (made if none is), and `wanderbooks
|
||||
/// mcp --book <book>` added to the agent's own list as
|
||||
/// `wanderbooks-<book>`. What was done, a line each.
|
||||
pub fn connect_agent(common: &Common, agent: &str, book: &str) -> Result<Vec<String>, String> {
|
||||
let (cli, label) = match agent {
|
||||
"claude" => ("claude", "Claude Code"),
|
||||
"codex" => ("codex", "Codex"),
|
||||
other => return Err(format!("{other:?}: connect claude or codex")),
|
||||
};
|
||||
let book = ledger_store::root::BookId::parse(book)
|
||||
.map_err(|e| e.to_string())?
|
||||
.to_string();
|
||||
// The PATH first; then where the agents' installers put them (a
|
||||
// viewer started from the desktop has a short PATH).
|
||||
// ($HOME: a test's own home is never the account's.)
|
||||
let home = std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.filter(|h| h.is_absolute());
|
||||
let usual = [
|
||||
".local/bin",
|
||||
".npm-global/bin",
|
||||
".bun/bin",
|
||||
".cargo/bin",
|
||||
".volta/bin",
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|d| home.as_ref().map(|h| h.join(d)))
|
||||
.chain(
|
||||
["/usr/local/bin", "/opt/homebrew/bin"]
|
||||
.into_iter()
|
||||
.map(PathBuf::from),
|
||||
);
|
||||
let program = std::env::var_os("PATH")
|
||||
.map(|p| std::env::split_paths(&p).collect::<Vec<_>>())
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.chain(usual)
|
||||
.filter(|d| d.is_absolute())
|
||||
.map(|d| d.join(cli))
|
||||
.find(|p| {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
format!("{label} isn't installed here (no `{cli}` on the PATH, nor where its installer puts it)")
|
||||
})?;
|
||||
let mut lines = Vec::new();
|
||||
// The token: kept in the keyring, or made now.
|
||||
let found =
|
||||
ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
|
||||
let run = found.run_dir().map_err(|e| e.to_string())?;
|
||||
let account = crate::tokens::Account::new(found.path(), &book, crate::tokens::DEFAULT_NAME);
|
||||
if crate::tokens::keeper(&run, &common.knobs)
|
||||
.get(&account)?
|
||||
.is_none()
|
||||
{
|
||||
let scopes = ledger_store::auth::Scope::ALL
|
||||
.iter()
|
||||
.map(|s| s.as_str().to_string())
|
||||
.collect();
|
||||
make_token(common, &book, crate::tokens::DEFAULT_NAME, scopes, true)?;
|
||||
lines.push(format!(
|
||||
"Made the books {book}' agent token, kept in this computer's keyring."
|
||||
));
|
||||
}
|
||||
// The agent's own list: the books' entry made anew.
|
||||
let exe = crate::start::this_executable()?;
|
||||
let name = format!("wanderbooks-{book}");
|
||||
let mut serve: Vec<std::ffi::OsString> = vec![
|
||||
exe.into_os_string(),
|
||||
"mcp".into(),
|
||||
"--book".into(),
|
||||
book.clone().into(),
|
||||
];
|
||||
if common.root.is_some() {
|
||||
serve.push("--root".into());
|
||||
serve.push(found.path().as_os_str().to_os_string());
|
||||
}
|
||||
serve.extend(common.knobs.args().into_iter().map(Into::into));
|
||||
let scope: &[&str] = if agent == "claude" {
|
||||
&["--scope", "user"]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
// Each call within a minute: an agent's command that hangs is said,
|
||||
// never waited on for good.
|
||||
let run_cli = |args: Vec<std::ffi::OsString>| -> Result<std::process::Output, String> {
|
||||
let mut child = std::process::Command::new(&program)
|
||||
.args(args)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("{label} couldn't be run: {}", e.kind()))?;
|
||||
let deadline = std::time::Instant::now() + CONNECT_WAIT;
|
||||
loop {
|
||||
if child.try_wait().map_err(|e| e.to_string())?.is_some() {
|
||||
return child.wait_with_output().map_err(|e| e.to_string());
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!("{label} didn't answer in time: nothing was added"));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
};
|
||||
let mut remove: Vec<std::ffi::OsString> = vec!["mcp".into(), "remove".into()];
|
||||
remove.extend(scope.iter().map(Into::into));
|
||||
remove.push(name.clone().into());
|
||||
let _ = run_cli(remove);
|
||||
let mut add: Vec<std::ffi::OsString> = vec!["mcp".into(), "add".into()];
|
||||
add.extend(scope.iter().map(Into::into));
|
||||
add.push(name.clone().into());
|
||||
add.push("--".into());
|
||||
add.extend(serve);
|
||||
let out = run_cli(add)?;
|
||||
if !out.status.success() {
|
||||
let said: String = String::from_utf8_lossy(&out.stderr)
|
||||
.trim()
|
||||
.chars()
|
||||
.take(300)
|
||||
.collect();
|
||||
return Err(format!("{label} didn't add the books: {said}"));
|
||||
}
|
||||
lines.push(format!(
|
||||
"{label} reaches the books {book} as \"{name}\" (restart {label} to see it)."
|
||||
));
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// Make the token `name` for `book` (the server does) and keep it in this
|
||||
/// computer's keyring (INVARIANT 4: kept first, or taken back).
|
||||
fn make_token(
|
||||
common: &Common,
|
||||
book: &str,
|
||||
name: &str,
|
||||
scopes: Vec<String>,
|
||||
replace: bool,
|
||||
) -> Result<admin::TokenInfo, String> {
|
||||
let request = Request::TokenCreate {
|
||||
book: book.to_string(),
|
||||
name: name.to_string(),
|
||||
scopes,
|
||||
replace,
|
||||
};
|
||||
let (answer, run_dir) = with_server_in(common, request)?;
|
||||
let (info, text) = match answer {
|
||||
Response::TokenCreated { token, text } => (token, zeroize::Zeroizing::new(text.0)),
|
||||
Response::Refused(why) => return Err(why),
|
||||
other => return Err(format!("the books server answered oddly: {other:?}")),
|
||||
};
|
||||
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
|
||||
let account = crate::tokens::Account::new(&root, book, name);
|
||||
let kept = crate::tokens::keeper(&run_dir, &common.knobs).set(&account, &text);
|
||||
drop(text);
|
||||
if let Err(why) = kept {
|
||||
let undone = with_server(
|
||||
common,
|
||||
Request::TokenRevoke {
|
||||
book: book.to_string(),
|
||||
name: name.to_string(),
|
||||
// This one only: another may have replaced it.
|
||||
id: Some(info.id.clone()),
|
||||
},
|
||||
);
|
||||
let after = match undone {
|
||||
Ok(Response::TokenRevoked(_)) => "so it was revoked again",
|
||||
_ => "and revoking it again failed too: `wanderbooks token revoke` ends it",
|
||||
};
|
||||
let replaced = if replace {
|
||||
" The token it replaced had already ended."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
return Err(format!(
|
||||
"the token was made, but this computer's keyring couldn't keep it ({why}), {after}.{replaced}"
|
||||
));
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// The id a backup is restored as when none is given: its repository's
|
||||
/// name (`acme.git` → `acme`).
|
||||
fn restore_id(source: &str) -> Option<String> {
|
||||
@@ -564,6 +968,61 @@ fn restore(
|
||||
eprintln!("error: {why}");
|
||||
Err(exit(1))
|
||||
};
|
||||
let plan = match plan_restore(common, source, id, replace, true) {
|
||||
Ok(plan) => plan,
|
||||
Err(why) => return fail(why),
|
||||
};
|
||||
for line in &plan.lines {
|
||||
println!("{line}");
|
||||
}
|
||||
match confirmed("Restore it?", yes) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
println!("Not restored.");
|
||||
return Ok(());
|
||||
}
|
||||
Err(why) => return fail(format!("not restored: {why}")),
|
||||
}
|
||||
match carry_out_restore(common, &plan) {
|
||||
Ok(lines) => {
|
||||
for line in lines {
|
||||
println!("{line}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(why) => fail(why),
|
||||
}
|
||||
}
|
||||
|
||||
/// A restore previewed: the backup proven, the id it comes back as, and
|
||||
/// what it would do, a line each (INVARIANT 3: shown before it is done).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RestorePlan {
|
||||
/// The backup, as the server is told it (a folder by its full path).
|
||||
pub source: String,
|
||||
/// The books' id it comes back as.
|
||||
pub id: String,
|
||||
/// Whether it replaces the books of that id (kept in legacy/).
|
||||
pub replace: bool,
|
||||
/// The backup's head, proven: the restore is of this backup only.
|
||||
pub head: (u64, String),
|
||||
/// The id of the books it holds, if it says.
|
||||
pub origin: Option<String>,
|
||||
/// What it would do, a line each.
|
||||
pub lines: Vec<String>,
|
||||
}
|
||||
|
||||
/// Preview restoring the backup at `source` (`flags`: name the command
|
||||
/// line's flags in the advice): the id it comes back as is `id`, else the
|
||||
/// id of the books the backup holds, else the repository's name; refused
|
||||
/// over books that exist unless `replace`, and replacing none.
|
||||
pub fn plan_restore(
|
||||
common: &Common,
|
||||
source: &str,
|
||||
id: Option<String>,
|
||||
replace: bool,
|
||||
flags: bool,
|
||||
) -> Result<RestorePlan, String> {
|
||||
// A folder is named to the server by its full path (the server's
|
||||
// working folder isn't this one); anything else is an address.
|
||||
let source = match Path::new(source) {
|
||||
@@ -571,11 +1030,10 @@ fn restore(
|
||||
.unwrap_or_else(|_| p.to_path_buf())
|
||||
.display()
|
||||
.to_string(),
|
||||
_ => source.to_string(),
|
||||
_ => source.trim().to_string(),
|
||||
};
|
||||
// One preview (under the id given, or any: it only reports), then the
|
||||
// id: given, or else the id of the books the backup holds, or else the
|
||||
// repository's name.
|
||||
// id.
|
||||
let probe = id
|
||||
.clone()
|
||||
.or_else(|| restore_id(&source))
|
||||
@@ -589,10 +1047,17 @@ fn restore(
|
||||
confirmed: false,
|
||||
expect_head: None,
|
||||
},
|
||||
) {
|
||||
Ok(Response::RestorePreview(p)) => p,
|
||||
Ok(Response::Refused(why)) | Err(why) => return fail(format!("{source}: {why}")),
|
||||
Ok(other) => return fail(format!("the books server answered oddly: {other:?}")),
|
||||
)? {
|
||||
Response::RestorePreview(p) => p,
|
||||
Response::Refused(why) => return Err(format!("{source}: {why}")),
|
||||
other => return Err(format!("the books server answered oddly: {other:?}")),
|
||||
};
|
||||
let flag = |text: &str| {
|
||||
if flags {
|
||||
format!(" ({text})")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
let Some(id) = id
|
||||
.or_else(|| {
|
||||
@@ -603,87 +1068,98 @@ fn restore(
|
||||
})
|
||||
.or_else(|| restore_id(&source))
|
||||
else {
|
||||
eprintln!("error: name the restored books with --id ID");
|
||||
return Err(exit(2));
|
||||
return Err(format!("name the restored books{}", flag("--id ID")));
|
||||
};
|
||||
let exists = match with_server(common, Request::List) {
|
||||
Ok(Response::Books(books)) => books.iter().any(|b| b.id == id),
|
||||
Ok(Response::Refused(why)) | Err(why) => return fail(why),
|
||||
Ok(other) => return fail(format!("the books server answered oddly: {other:?}")),
|
||||
let exists = match with_server(common, Request::List)? {
|
||||
Response::Books(books) => books.iter().any(|b| b.id == id),
|
||||
Response::Refused(why) => return Err(why),
|
||||
other => return Err(format!("the books server answered oddly: {other:?}")),
|
||||
};
|
||||
if exists && !replace {
|
||||
return fail(format!(
|
||||
"there are already books called \"{id}\": restore them under another id (--id), or replace them (--replace; they are kept in legacy/)"
|
||||
return Err(format!(
|
||||
"there are already books called \"{id}\": restore them under another id{}, or replace them{}",
|
||||
flag("--id"),
|
||||
flag("--replace; they are kept in legacy/")
|
||||
));
|
||||
}
|
||||
if !exists && replace {
|
||||
return fail(format!(
|
||||
return Err(format!(
|
||||
"there are no books called \"{id}\" to replace: nothing was restored"
|
||||
));
|
||||
}
|
||||
let request = |expect_head: Option<(u64, String)>| Request::Restore {
|
||||
id: id.clone(),
|
||||
source: source.clone(),
|
||||
replace,
|
||||
confirmed: expect_head.is_some(),
|
||||
expect_head,
|
||||
};
|
||||
println!(
|
||||
let mut lines = vec![format!(
|
||||
"The backup at {source} is whole: through event {} ({}), {} stored files re-hashed, {} TIN vault records.",
|
||||
preview.head.0,
|
||||
short_hash(&preview.head.1),
|
||||
preview.blobs,
|
||||
preview.vault_files
|
||||
);
|
||||
)];
|
||||
if let Some(origin) = &preview.origin {
|
||||
println!("It is a backup of the books \"{origin}\".");
|
||||
lines.push(format!("It is a backup of the books \"{origin}\"."));
|
||||
}
|
||||
if replace {
|
||||
println!(
|
||||
lines.push(if replace {
|
||||
format!(
|
||||
"It replaces the books \"{id}\": they are closed and kept whole in the legacy folder, not deleted."
|
||||
);
|
||||
)
|
||||
} else {
|
||||
println!("It comes back as new books \"{id}\", beside any others.");
|
||||
}
|
||||
format!("It comes back as new books \"{id}\", beside any others.")
|
||||
});
|
||||
match preview.origin.as_deref() {
|
||||
Some(o) if o == id => {}
|
||||
Some(o) => println!(
|
||||
"Under a name other than its books' (\"{o}\"), they won't back up to the private repository those books back up to (that would stop theirs): if these are those books, restore them as \"{o}\" (--id {o}); else name a repository of their own."
|
||||
),
|
||||
None => println!(
|
||||
Some(o) => lines.push(format!(
|
||||
"Under a name other than its books' (\"{o}\"), they won't back up to the private repository those books back up to (that would stop theirs): if these are those books, restore them as \"{o}\"{}; else name a repository of their own.",
|
||||
flag(&format!("--id {o}"))
|
||||
)),
|
||||
None => lines.push(
|
||||
"It doesn't say whose books it holds: they won't back up to the private repository their settings name until one of their own is named."
|
||||
.into(),
|
||||
),
|
||||
}
|
||||
match confirmed("Restore it?", yes) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
println!("Not restored.");
|
||||
return Ok(());
|
||||
}
|
||||
Err(why) => return fail(format!("not restored: {why}")),
|
||||
}
|
||||
match with_server(common, request(Some(preview.head.clone()))) {
|
||||
Ok(Response::Restored(r)) => {
|
||||
println!(
|
||||
Ok(RestorePlan {
|
||||
source,
|
||||
id,
|
||||
replace,
|
||||
head: preview.head,
|
||||
origin: preview.origin,
|
||||
lines,
|
||||
})
|
||||
}
|
||||
|
||||
/// Restore as `plan` previewed it (only that backup, at that head): what
|
||||
/// was done, a line each.
|
||||
pub fn carry_out_restore(common: &Common, plan: &RestorePlan) -> Result<Vec<String>, String> {
|
||||
let request = Request::Restore {
|
||||
id: plan.id.clone(),
|
||||
source: plan.source.clone(),
|
||||
replace: plan.replace,
|
||||
confirmed: true,
|
||||
expect_head: Some(plan.head.clone()),
|
||||
};
|
||||
match with_server(common, request)? {
|
||||
Response::Restored(r) => {
|
||||
let mut lines = vec![format!(
|
||||
"Restored as \"{}\": through event {} ({}), {} stored files checked, {} TIN vault records.",
|
||||
r.id,
|
||||
r.head.0,
|
||||
short_hash(&r.head.1),
|
||||
r.blobs_verified,
|
||||
r.vault_files
|
||||
);
|
||||
)];
|
||||
if let Some(kept) = &r.replaced {
|
||||
println!("The books it replaced are kept in the legacy folder as {kept}.");
|
||||
}
|
||||
if let Some(why) = r.problem {
|
||||
return fail(format!(
|
||||
"the restored books didn't open: {why}. Closing the books and opening them again tries again."
|
||||
lines.push(format!(
|
||||
"The books it replaced are kept in the legacy folder as {kept}."
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
if let Some(why) = r.problem {
|
||||
return Err(format!(
|
||||
"{}\nThe restored books didn't open: {why}. Closing the books and opening them again tries again.",
|
||||
lines.join("\n")
|
||||
));
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
Ok(Response::Refused(why)) | Err(why) => fail(format!("{source}: {why}")),
|
||||
Ok(other) => fail(format!("the books server answered oddly: {other:?}")),
|
||||
Response::Refused(why) => Err(format!("{}: {why}", plan.source)),
|
||||
other => Err(format!("the books server answered oddly: {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1118,46 +1594,7 @@ pub fn token(
|
||||
match words.as_slice() {
|
||||
["create"] => {
|
||||
let book = need_book()?;
|
||||
let request = Request::TokenCreate {
|
||||
book: book.clone(),
|
||||
name: name.clone(),
|
||||
scopes,
|
||||
replace,
|
||||
};
|
||||
let (answer, run_dir) = with_server_in(&common, request).map_err(fail)?;
|
||||
let (info, text) = match answer {
|
||||
Response::TokenCreated { token, text } => (token, zeroize::Zeroizing::new(text.0)),
|
||||
Response::Refused(why) => return Err(fail(why)),
|
||||
other => return Err(fail(format!("the books server answered oddly: {other:?}"))),
|
||||
};
|
||||
// INVARIANT 4: kept first, or taken back.
|
||||
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
|
||||
let account = crate::tokens::Account::new(&root, &book, &name);
|
||||
let kept = crate::tokens::keeper(&run_dir, &common.knobs).set(&account, &text);
|
||||
drop(text);
|
||||
if let Err(why) = kept {
|
||||
let undone = with_server(
|
||||
&common,
|
||||
Request::TokenRevoke {
|
||||
book: book.clone(),
|
||||
name: name.clone(),
|
||||
// This one only: another may have replaced it.
|
||||
id: Some(info.id.clone()),
|
||||
},
|
||||
);
|
||||
let after = match undone {
|
||||
Ok(Response::TokenRevoked(_)) => "so it was revoked again",
|
||||
_ => "and revoking it again failed too: `wanderbooks token revoke` ends it",
|
||||
};
|
||||
let replaced = if replace {
|
||||
" The token it replaced had already ended."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
return Err(fail(format!(
|
||||
"the token was made, but this computer's keyring couldn't keep it ({why}), {after}.{replaced}"
|
||||
)));
|
||||
}
|
||||
let info = make_token(&common, &book, &name, scopes, replace).map_err(fail)?;
|
||||
println!(
|
||||
"Made the token {:?} for the books {} ({}), kept in this computer's keyring.",
|
||||
info.name,
|
||||
@@ -1222,14 +1659,8 @@ pub fn token(
|
||||
}
|
||||
["print"] => {
|
||||
let book = need_book()?;
|
||||
let opener = Opener::new(
|
||||
common.root.as_deref(),
|
||||
common.knobs,
|
||||
Starter::Direct,
|
||||
Kind::Cli,
|
||||
None,
|
||||
)
|
||||
.map_err(|e| fail(e.to_string()))?;
|
||||
let opener = Opener::new(common.root.as_deref(), common.knobs, Kind::Cli, None)
|
||||
.map_err(|e| fail(e.to_string()))?;
|
||||
let run_dir = opener.run_dir().to_path_buf();
|
||||
let root = run_dir.parent().unwrap_or(&run_dir).to_path_buf();
|
||||
let account = crate::tokens::Account::new(&root, &book, &name);
|
||||
@@ -1348,7 +1779,6 @@ fn on_the_socket(
|
||||
let mut opener = Opener::new(
|
||||
common.root.as_deref(),
|
||||
common.knobs,
|
||||
Starter::Direct,
|
||||
Kind::Cli,
|
||||
None,
|
||||
)?;
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
//! `wanderbooks helper`: the small unconfined process a confined mode
|
||||
//! keeps beside it (plan 3.9, "Starting the host cleanly").
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: a mode that confines itself (the viewer, the bridge) can still
|
||||
//! start the host cleanly and hand off the few things a sandbox
|
||||
//! forbids, through a channel that can ask for nothing else.
|
||||
//! SOURCE: plan/served.md 3.9 (the helper: spawned over an inherited
|
||||
//! socket pair before any thread, a fixed message set; it never
|
||||
//! accepts a path or a command line).
|
||||
//! INVARIANTS:
|
||||
//! 1. It talks only over the socket it was handed (descriptor 3), one
|
||||
//! JSON line each way, and exits when that closes.
|
||||
//! 2. It accepts exactly start_host, open_with (an attachment id),
|
||||
//! stage_backup and start_updater, none carrying a path or command;
|
||||
//! anything else is refused.
|
||||
//! 3. start_host starts the host cleanly (start.rs).
|
||||
//! 4. Spawned before the caller starts any thread.
|
||||
//! 5. A message is exactly its version and one ask, on a line of at
|
||||
//! most MAX_LINE bytes; anything else ends the helper.
|
||||
//! ```
|
||||
|
||||
use crate::knobs::{FLAG, Knobs};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::{BufRead, Write};
|
||||
use std::os::fd::{AsRawFd, FromRawFd};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// This protocol's version.
|
||||
pub const PROTO: u32 = 1;
|
||||
/// The descriptor the helper is handed its socket on.
|
||||
const FD: i32 = 3;
|
||||
/// The longest line either side sends.
|
||||
const MAX_LINE: u64 = 64 * 1024;
|
||||
|
||||
/// A message to the helper (INVARIANT 2).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
/// The sender's protocol version.
|
||||
pub proto: u32,
|
||||
/// What it asks.
|
||||
#[serde(flatten)]
|
||||
pub ask: HelperAsk,
|
||||
}
|
||||
|
||||
/// What the helper can be asked: exactly these (INVARIANT 2).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum HelperAsk {
|
||||
/// Start the books' host.
|
||||
StartHost,
|
||||
/// Open a document with the system's app for it.
|
||||
OpenWith {
|
||||
/// The attachment's id.
|
||||
attachment: u64,
|
||||
},
|
||||
/// Ask the owner for a backup file and stage it for a restore.
|
||||
StageBackup,
|
||||
/// Start the updater.
|
||||
StartUpdater,
|
||||
}
|
||||
|
||||
/// The helper's answer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Answer {
|
||||
/// Done.
|
||||
Done,
|
||||
/// Refused, with why.
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
/// The caller's end: a running helper.
|
||||
pub struct Client {
|
||||
stream: std::sync::Mutex<UnixStream>,
|
||||
child: std::sync::Mutex<std::process::Child>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Spawn a helper for the root (`root`, else this account's), with
|
||||
/// these knobs. Call it before starting any thread (INVARIANT 4).
|
||||
pub fn spawn(root: Option<&Path>, knobs: &Knobs) -> Result<Self, String> {
|
||||
let (ours, theirs) = UnixStream::pair().map_err(|e| e.to_string())?;
|
||||
let exe = crate::start::this_executable()?;
|
||||
let mut c = std::process::Command::new(exe);
|
||||
c.arg("helper");
|
||||
if let Some(root) = root {
|
||||
let found = ledger_store::root::Root::find(Some(root)).map_err(|e| e.to_string())?;
|
||||
c.arg("--root").arg(found.path());
|
||||
}
|
||||
c.args(knobs.args())
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null());
|
||||
let fd = theirs.as_raw_fd();
|
||||
// SAFETY: dup2 and fcntl are async-signal-safe; they give the child
|
||||
// its end of the pair as descriptor 3, without close-on-exec (which
|
||||
// a descriptor already numbered 3 would still have).
|
||||
unsafe {
|
||||
c.pre_exec(move || {
|
||||
let ok =
|
||||
(fd == FD || libc::dup2(fd, FD) >= 0) && libc::fcntl(FD, libc::F_SETFD, 0) >= 0;
|
||||
if !ok {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let child = c
|
||||
.spawn()
|
||||
.map_err(|e| format!("can't start the helper: {e}"))?;
|
||||
drop(theirs);
|
||||
Ok(Self {
|
||||
stream: std::sync::Mutex::new(ours),
|
||||
child: std::sync::Mutex::new(child),
|
||||
})
|
||||
}
|
||||
|
||||
/// Ask the helper one thing (blocking).
|
||||
pub fn ask(&self, ask: HelperAsk) -> Result<Answer, String> {
|
||||
let stream = self.stream.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut line =
|
||||
serde_json::to_string(&Message { proto: PROTO, ask }).map_err(|e| e.to_string())?;
|
||||
line.push('\n');
|
||||
(&*stream)
|
||||
.write_all(line.as_bytes())
|
||||
.map_err(|e| format!("the helper is gone: {}", e.kind()))?;
|
||||
let mut answer = String::new();
|
||||
std::io::BufReader::new(std::io::Read::take(&*stream, MAX_LINE))
|
||||
.read_line(&mut answer)
|
||||
.map_err(|e| format!("the helper didn't answer: {}", e.kind()))?;
|
||||
serde_json::from_str(answer.trim_end()).map_err(|_| "the helper is gone".to_string())
|
||||
}
|
||||
|
||||
/// Start the books' host (INVARIANT 3).
|
||||
pub fn start_host(&self) -> Result<(), String> {
|
||||
match self.ask(HelperAsk::StartHost)? {
|
||||
Answer::Done => Ok(()),
|
||||
Answer::Refused(why) => Err(why),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Client {
|
||||
fn drop(&mut self) {
|
||||
// Closing our end ends the helper (INVARIANT 1); reap it.
|
||||
let stream = self.stream.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = stream.shutdown(std::net::Shutdown::Both);
|
||||
drop(stream);
|
||||
let _ = self.child.lock().unwrap_or_else(|e| e.into_inner()).wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// What the helper does with one message.
|
||||
fn answer(ask: HelperAsk, root: Option<&Path>, knobs: &Knobs) -> Answer {
|
||||
match ask {
|
||||
HelperAsk::StartHost => match crate::start::start_host(root, knobs) {
|
||||
Ok(()) => Answer::Done,
|
||||
Err(why) => Answer::Refused(why),
|
||||
},
|
||||
HelperAsk::OpenWith { .. } => {
|
||||
Answer::Refused("opening documents comes with the viewer's menu".into())
|
||||
}
|
||||
HelperAsk::StageBackup => Answer::Refused(
|
||||
"restore from a terminal for now: `wanderbooks books restore BACKUP`".into(),
|
||||
),
|
||||
HelperAsk::StartUpdater => Answer::Refused("updates come with the installers".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `wanderbooks helper`: serve the socket on descriptor 3 until it closes
|
||||
/// (INVARIANT 1).
|
||||
pub fn run(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut root: Option<PathBuf> = None;
|
||||
let mut knobs = Knobs::default();
|
||||
let mut args = args.into_iter();
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--root" => root = Some(PathBuf::from(args.next().ok_or("--root needs a folder")?)),
|
||||
FLAG => knobs = Knobs::parse(&args.next().ok_or("--test-knobs needs a value")?)?,
|
||||
"--help" | "-h" => {
|
||||
eprintln!("usage: {prog} [--root DIR]");
|
||||
eprintln!(" Started by a confined part of wanderbooks, over descriptor 3.");
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
return Err(Box::new(crate::Exit(2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// SAFETY: descriptor 3 is ours by INVARIANT 1; if it isn't a socket,
|
||||
// the peer check below refuses it.
|
||||
let stream = unsafe { UnixStream::from_raw_fd(FD) };
|
||||
// Taken: nothing this helper starts inherits it.
|
||||
// SAFETY: fcntl on our own descriptor.
|
||||
unsafe { libc::fcntl(FD, libc::F_SETFD, libc::FD_CLOEXEC) };
|
||||
if !ledger_mcp::local::peer_is_us(&stream) {
|
||||
eprintln!("{prog}: descriptor 3 isn't a socket to this account's own process");
|
||||
return Err(Box::new(crate::Exit(2)));
|
||||
}
|
||||
serve(std::io::BufReader::new(&stream), &stream, |ask| {
|
||||
answer(ask, root.as_deref(), &knobs)
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Answer each message read from `reader` on `writer`, a line each, with
|
||||
/// `reply` for the asks of this version, until the input ends or a line
|
||||
/// is too long or cut off (INVARIANTS 1, 5).
|
||||
fn serve<R: BufRead, W: Write>(mut reader: R, mut writer: W, reply: impl Fn(HelperAsk) -> Answer) {
|
||||
loop {
|
||||
// INVARIANT 5: never more than a line's worth read.
|
||||
let mut line = String::new();
|
||||
match std::io::Read::take(&mut reader, MAX_LINE + 1).read_line(&mut line) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) if !line.ends_with('\n') => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
let parsed = crate::lease::strict_pair(line.trim_end())
|
||||
.and_then(|v| serde_json::from_value::<Message>(v).map_err(|e| e.to_string()));
|
||||
let answer = match parsed {
|
||||
Ok(m) if m.proto == PROTO => reply(m.ask),
|
||||
Ok(_) => Answer::Refused("the helper is another version: restart".into()),
|
||||
Err(e) => Answer::Refused(format!("not understood: {e}")),
|
||||
};
|
||||
let mut text = serde_json::to_string(&answer).unwrap_or_default();
|
||||
text.push('\n');
|
||||
if writer.write_all(text.as_bytes()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn exactly_four_asks_and_none_carries_a_path() {
|
||||
for ok in [
|
||||
json!({"proto": 1, "start_host": null}),
|
||||
json!({"proto": 1, "open_with": {"attachment": 7}}),
|
||||
json!({"proto": 1, "stage_backup": null}),
|
||||
json!({"proto": 1, "start_updater": null}),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Message>(ok.clone()).is_ok(),
|
||||
"{ok}"
|
||||
);
|
||||
}
|
||||
for bad in [
|
||||
json!({"proto": 1, "open_with": {"attachment": 7, "path": "/etc/passwd"}}),
|
||||
json!({"proto": 1, "open_with": {"path": "/x"}}),
|
||||
json!({"proto": 1, "run": {"command": "sh"}}),
|
||||
json!({"proto": 1, "start_host": {"root": "/x"}}),
|
||||
json!({"proto": 1}),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Message>(bad.clone()).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
// Two asks in one message: refused, not half-read.
|
||||
let two = r#"{"proto":1,"start_host":null,"start_updater":null}"#;
|
||||
assert!(crate::lease::strict_pair(two).is_err());
|
||||
}
|
||||
|
||||
/// Serve `input`; the answers, a line each.
|
||||
fn served(input: &[u8]) -> Vec<String> {
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
serve(input, &mut out, |ask| match ask {
|
||||
HelperAsk::StartHost => Answer::Done,
|
||||
_ => Answer::Refused("no".into()),
|
||||
});
|
||||
String::from_utf8(out)
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_line_is_answered_and_a_bad_one_ends_it() {
|
||||
let ok = r#"{"proto":1,"start_host":null}"#;
|
||||
let other = r#"{"proto":2,"start_host":null}"#;
|
||||
let got = served(format!("{ok}\n{other}\nnonsense\n{ok}\n").as_bytes());
|
||||
assert_eq!(got[0], r#""done""#);
|
||||
assert!(got[1].contains("another version"), "{}", got[1]);
|
||||
assert!(got[2].contains("not understood"), "{}", got[2]);
|
||||
assert_eq!(got[3], r#""done""#);
|
||||
assert_eq!(got.len(), 4);
|
||||
// A line at the limit is read; one byte more ends the helper,
|
||||
// and so does one cut off without its newline.
|
||||
let at = format!("{ok}{}\n", " ".repeat(MAX_LINE as usize - ok.len()));
|
||||
assert_eq!(served(at.as_bytes()), [r#""done""#]);
|
||||
let over = format!(
|
||||
"{ok}{}\n{ok}\n",
|
||||
" ".repeat(MAX_LINE as usize - ok.len() + 1)
|
||||
);
|
||||
assert!(served(over.as_bytes()).is_empty());
|
||||
assert!(served(ok.as_bytes()).is_empty());
|
||||
// An answer that can't be written ends it.
|
||||
struct Broken;
|
||||
impl Write for Broken {
|
||||
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
|
||||
Err(std::io::ErrorKind::BrokenPipe.into())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let asked = std::cell::Cell::new(0);
|
||||
serve(format!("{ok}\n{ok}\n").as_bytes(), Broken, |_| {
|
||||
asked.set(asked.get() + 1);
|
||||
Answer::Done
|
||||
});
|
||||
assert_eq!(asked.get(), 1, "nothing more after the first answer failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_isnt_built_yet_is_refused() {
|
||||
let k = Knobs::default();
|
||||
for ask in [
|
||||
HelperAsk::OpenWith { attachment: 1 },
|
||||
HelperAsk::StageBackup,
|
||||
HelperAsk::StartUpdater,
|
||||
] {
|
||||
assert!(matches!(answer(ask, None, &k), Answer::Refused(_)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -539,16 +539,6 @@ impl Host {
|
||||
Phase::Closing(reason) => HostMsg::Ending { reason, detail: None },
|
||||
});
|
||||
}
|
||||
Event::Intent(Intent::NewBooks { id, currency }, answer)
|
||||
if self.ready && self.phase == Phase::Open =>
|
||||
{
|
||||
// The server does it; the loop goes on meanwhile.
|
||||
let socket = self.run.join(ledger_mcp::local::ADMIN_SOCKET);
|
||||
tokio::spawn(async move {
|
||||
let msg = new_books(socket, id, currency).await;
|
||||
let _ = answer.send(msg);
|
||||
});
|
||||
}
|
||||
Event::Intent(Intent::Close, answer) if self.phase != Phase::Open => {
|
||||
// INVARIANT 11; closing anyway: already so.
|
||||
let _ = answer.send(match self.phase {
|
||||
@@ -701,27 +691,6 @@ impl Host {
|
||||
},
|
||||
Some(Reason::ClosedByOwner),
|
||||
),
|
||||
Intent::NewBooks { .. } if self.phase == Phase::Open => (
|
||||
HostMsg::Refused("the books are still opening: try again in a moment".into()),
|
||||
None,
|
||||
),
|
||||
Intent::NewBooks { .. } => (
|
||||
HostMsg::Refused("the books are closing: nothing was done".into()),
|
||||
None,
|
||||
),
|
||||
Intent::BackUpNow { .. } => (
|
||||
HostMsg::Refused(
|
||||
"the books back themselves up; to back up now, ask your agent (books.back_up_now)"
|
||||
.into(),
|
||||
),
|
||||
None,
|
||||
),
|
||||
Intent::Restore { .. } => (
|
||||
HostMsg::Refused(
|
||||
"restore from a terminal for now: `wanderbooks books restore BACKUP`".into(),
|
||||
),
|
||||
None,
|
||||
),
|
||||
Intent::ConsentFormatUpgrade { .. } => (
|
||||
HostMsg::Refused("no books are waiting for a format upgrade".into()),
|
||||
None,
|
||||
@@ -768,30 +737,6 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// "New books…": the server makes them, through its admin socket.
|
||||
async fn new_books(socket: PathBuf, id: String, currency: String) -> HostMsg {
|
||||
use ledger_mcp::admin::{Request, Response};
|
||||
let answer = tokio::task::spawn_blocking(move || {
|
||||
ledger_mcp::admin::call(&socket, Request::Create { id, currency })
|
||||
})
|
||||
.await;
|
||||
match answer {
|
||||
Ok(Ok(Response::Created { id, problem: None })) => HostMsg::Done {
|
||||
detail: Some(format!("new books {id} made")),
|
||||
},
|
||||
Ok(Ok(Response::Created {
|
||||
id,
|
||||
problem: Some(why),
|
||||
})) => HostMsg::Refused(format!("new books {id} were made but didn't open: {why}")),
|
||||
Ok(Ok(Response::Refused(why))) => HostMsg::Refused(why),
|
||||
Ok(Ok(_)) => HostMsg::Refused("the books server answered oddly".into()),
|
||||
Ok(Err(ledger_mcp::admin::CallError::NotSent(why))) => HostMsg::Refused(why),
|
||||
// Sent, and no answer: never reported as not done.
|
||||
Ok(Err(ledger_mcp::admin::CallError::Unknown(why))) => HostMsg::Uncertain(why),
|
||||
Err(_) => HostMsg::Refused("the host hit an internal error".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take run/update.lock (exclusive), if it is free now.
|
||||
fn take_update_lock(run: &Path) -> Result<std::fs::File, String> {
|
||||
let file = std::fs::OpenOptions::new()
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
//! GOAL: holders hold the books open, and the owner's few requests reach
|
||||
//! the server, through one socket that accepts nothing else.
|
||||
//! SOURCE: plan/served.md 3.9 ("Who holds them", "Three local channels":
|
||||
//! leases record their kind and PID; exactly five owner intents;
|
||||
//! leases record their kind and PID; the owner intents (as built,
|
||||
//! D-32: two; the viewer, unconfined, backs up, restores and makes
|
||||
//! books through the server's admin socket as the command line
|
||||
//! does);
|
||||
//! consent carries the owner passphrase (a replacing restore no
|
||||
//! longer does: D-31, the books it replaces are kept in legacy/);
|
||||
//! "Closing": the host says why on each lease; protocol versions N
|
||||
@@ -13,9 +16,8 @@
|
||||
//! 1. A connection's first line is one Ask; a Hold keeps the
|
||||
//! connection (the lease lasts until it closes), anything else is
|
||||
//! answered once and the connection closed.
|
||||
//! 2. Exactly five intents exist: close, back_up_now, new_books,
|
||||
//! restore (a backup's address or folder, as `books restore` takes
|
||||
//! it) and consent_format_upgrade; any other request is refused.
|
||||
//! 2. Exactly two intents exist: close and consent_format_upgrade; any
|
||||
//! other request is refused.
|
||||
//! 3. consent_format_upgrade carries the owner passphrase; without one
|
||||
//! it is refused before anything is done.
|
||||
//! 4. Before the host ends a lease it sends the reason; a lease that
|
||||
@@ -102,36 +104,12 @@ pub enum Ask {
|
||||
Intent(Intent),
|
||||
}
|
||||
|
||||
/// The owner's intents: exactly these five (INVARIANT 2).
|
||||
/// The owner's intents: exactly these two (INVARIANT 2).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Intent {
|
||||
/// Close the books now: every lease ends.
|
||||
Close,
|
||||
/// Back a book up now.
|
||||
BackUpNow {
|
||||
/// Which.
|
||||
book: String,
|
||||
},
|
||||
/// Start new books.
|
||||
NewBooks {
|
||||
/// Their id.
|
||||
id: String,
|
||||
/// Their base currency.
|
||||
currency: String,
|
||||
},
|
||||
/// Restore a backup, as its own book or replacing one (kept in
|
||||
/// legacy/).
|
||||
Restore {
|
||||
/// The backup: a repository's address, or its folder (`<book>.git`
|
||||
/// in the backup folder).
|
||||
source: String,
|
||||
/// The books' id.
|
||||
id: String,
|
||||
/// Replace the books of that id.
|
||||
#[serde(default)]
|
||||
replace: bool,
|
||||
},
|
||||
/// Let the server upgrade a book's journal format.
|
||||
ConsentFormatUpgrade {
|
||||
/// Which.
|
||||
@@ -277,12 +255,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_five_intents_are_understood() {
|
||||
fn exactly_two_intents_are_understood() {
|
||||
for intent in [
|
||||
json!("close"),
|
||||
json!({"back_up_now": {"book": "acme"}}),
|
||||
json!({"new_books": {"id": "acme", "currency": "USD"}}),
|
||||
json!({"restore": {"source": "/backups/acme.git", "id": "acme"}}),
|
||||
json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}),
|
||||
] {
|
||||
let h = parse(json!({"proto": 1, "intent": intent})).unwrap();
|
||||
@@ -291,8 +266,9 @@ mod tests {
|
||||
for bad in [
|
||||
json!({"proto": 1, "intent": "delete"}),
|
||||
json!({"proto": 1, "intent": {"restore": {"path": "/x"}}}),
|
||||
json!({"proto": 1, "intent": {"restore": {"source": "/x", "id": "a", "path": "/x"}}}),
|
||||
json!({"proto": 1, "intent": {"restore": {"staged": "s1"}}}),
|
||||
json!({"proto": 1, "intent": {"restore": {"source": "/x", "id": "a"}}}),
|
||||
json!({"proto": 1, "intent": {"back_up_now": {"book": "acme"}}}),
|
||||
json!({"proto": 1, "intent": {"new_books": {"id": "acme", "currency": "USD"}}}),
|
||||
json!({"proto": 1, "intent": {"run": {"command": "sh"}}}),
|
||||
json!({"proto": 1, "exec": "sh"}),
|
||||
json!({"proto": 1}),
|
||||
@@ -325,22 +301,7 @@ mod tests {
|
||||
assert!(consent(None).check_passphrase().is_err());
|
||||
assert!(consent(Some("")).check_passphrase().is_err());
|
||||
assert!(consent(Some("pw")).check_passphrase().is_ok());
|
||||
for other in [
|
||||
// D-31: replacing keeps the old books in legacy/; no passphrase.
|
||||
Intent::Restore {
|
||||
source: "/backups/acme.git".into(),
|
||||
id: "acme".into(),
|
||||
replace: true,
|
||||
},
|
||||
Intent::Close,
|
||||
Intent::BackUpNow { book: "a".into() },
|
||||
Intent::NewBooks {
|
||||
id: "a".into(),
|
||||
currency: "USD".into(),
|
||||
},
|
||||
] {
|
||||
assert!(other.check_passphrase().is_ok());
|
||||
}
|
||||
assert!(Intent::Close.check_passphrase().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,18 +5,17 @@
|
||||
//! the **host**, which starts the books server and holds it open while
|
||||
//! any lease on its socket lasts, and closes it 10 s after the last one
|
||||
//! ends. Each part is a mode of the one `wanderbooks` executable:
|
||||
//! [`host::run`] (`wanderbooks host`), [`helper::run`] (`wanderbooks
|
||||
//! helper`), [`bridge::run`] (`wanderbooks mcp`), and the commands in
|
||||
//! [`commands`] (`serve`, `close`, `books`, `token`). Agents' tokens are
|
||||
//! kept in the OS keyring ([`tokens`]).
|
||||
//! [`host::run`] (`wanderbooks host`), [`bridge::run`] (`wanderbooks
|
||||
//! mcp`), and the owner's commands in [`commands`] (`serve`, `close`,
|
||||
//! `books`, `token`, `connect`, `doctor`, `passphrase`, and what the
|
||||
//! viewer's Books menu does). Agents' tokens are kept in the OS keyring
|
||||
//! ([`tokens`]).
|
||||
|
||||
#[cfg(unix)]
|
||||
pub mod bridge;
|
||||
#[cfg(unix)]
|
||||
pub mod commands;
|
||||
#[cfg(unix)]
|
||||
pub mod helper;
|
||||
#[cfg(unix)]
|
||||
pub mod host;
|
||||
pub mod knobs;
|
||||
pub mod lease;
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
//! INVARIANTS:
|
||||
//! 1. Before each try the holder waits while a restart holds
|
||||
//! run/update.lock.
|
||||
//! 2. No host answering: it starts one (its Starter) and keeps trying;
|
||||
//! 2. No host answering: it starts one (cleanly, start.rs) and keeps
|
||||
//! trying;
|
||||
//! a host that is closing or restarting turns it away and it tries
|
||||
//! again until a host takes the lease.
|
||||
//! 3. A start that brings no lease within START_WAIT (or whose host
|
||||
@@ -87,20 +88,11 @@ impl From<&str> for HoldError {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a holder starts a host when none answers.
|
||||
pub enum Starter {
|
||||
/// Start it itself (a holder that isn't confined).
|
||||
Direct,
|
||||
/// Ask its helper to (a confined holder).
|
||||
Helper(std::sync::Arc<crate::helper::Client>),
|
||||
}
|
||||
|
||||
/// Takes leases for one holder.
|
||||
pub struct Opener {
|
||||
root: Option<PathBuf>,
|
||||
run: PathBuf,
|
||||
knobs: Knobs,
|
||||
starter: Starter,
|
||||
kind: Kind,
|
||||
book: Option<String>,
|
||||
failures: u32,
|
||||
@@ -140,7 +132,6 @@ impl Opener {
|
||||
pub fn new(
|
||||
root: Option<&Path>,
|
||||
knobs: Knobs,
|
||||
starter: Starter,
|
||||
kind: Kind,
|
||||
book: Option<String>,
|
||||
) -> Result<Self, String> {
|
||||
@@ -150,7 +141,6 @@ impl Opener {
|
||||
root: root.map(|_| found.path().to_path_buf()),
|
||||
run,
|
||||
knobs,
|
||||
starter,
|
||||
kind,
|
||||
book,
|
||||
failures: 0,
|
||||
@@ -192,6 +182,12 @@ impl Opener {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start counting failures over (a holder that waited a while after
|
||||
/// giving up tries again); what it has held is kept (INVARIANT 5).
|
||||
pub fn forget_failures(&mut self) {
|
||||
self.failures = 0;
|
||||
}
|
||||
|
||||
/// Take a lease, starting a host if none answers (INVARIANTS 1-4).
|
||||
pub async fn hold(&mut self) -> Result<Lease, HoldError> {
|
||||
let mut started: Option<Instant> = None;
|
||||
@@ -230,8 +226,8 @@ impl Opener {
|
||||
// for it rather than start another.
|
||||
let held = host_lock_held(&self.run);
|
||||
match started {
|
||||
// A start that fails (a spawn error, the helper
|
||||
// gone) is a failure like any other (INVARIANT 3).
|
||||
// A start that fails (a spawn error) is a
|
||||
// failure like any other (INVARIANT 3).
|
||||
None if !held => match self.start().await {
|
||||
Ok(()) => started = Some(Instant::now()),
|
||||
Err(_) => self.failed().await?,
|
||||
@@ -256,25 +252,13 @@ impl Opener {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a host, through this holder's starter.
|
||||
/// Start a host, cleanly (start.rs).
|
||||
async fn start(&self) -> Result<(), String> {
|
||||
match &self.starter {
|
||||
Starter::Direct => {
|
||||
let root = self.root.clone();
|
||||
let knobs = self.knobs;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::start::start_host(root.as_deref(), &knobs)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
Starter::Helper(helper) => {
|
||||
let helper = helper.clone();
|
||||
tokio::task::spawn_blocking(move || helper.start_host())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
}
|
||||
let root = self.root.clone();
|
||||
let knobs = self.knobs;
|
||||
tokio::task::spawn_blocking(move || crate::start::start_host(root.as_deref(), &knobs))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
/// Say hello on a connection and read the host's first word.
|
||||
@@ -468,7 +452,6 @@ mod tests {
|
||||
backoff: Duration::from_millis(1),
|
||||
..Knobs::default()
|
||||
},
|
||||
starter: Starter::Direct,
|
||||
kind: Kind::Cli,
|
||||
book: None,
|
||||
failures: 0,
|
||||
|
||||
@@ -116,6 +116,15 @@ pub enum Request {
|
||||
#[serde(default)]
|
||||
expect_head: Option<(u64, String)>,
|
||||
},
|
||||
/// Back books up now (plan 3.7): to every target their settings name.
|
||||
BackUpNow {
|
||||
/// The books.
|
||||
id: String,
|
||||
/// The backup folder's disk is new: begin these books' backup
|
||||
/// there again.
|
||||
#[serde(default)]
|
||||
new_disk: bool,
|
||||
},
|
||||
/// Make a token for an agent (plan 3.3).
|
||||
TokenCreate {
|
||||
/// The books it opens.
|
||||
@@ -294,6 +303,21 @@ pub struct Restored {
|
||||
pub problem: Option<String>,
|
||||
}
|
||||
|
||||
/// How one backup target took a backup now.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BackedUpTarget {
|
||||
/// "repository" or "folder".
|
||||
pub target: String,
|
||||
/// Where (no path in the data root).
|
||||
pub at: String,
|
||||
/// The journal seq it holds now, if any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub through_seq: Option<u64>,
|
||||
/// Why it holds no backup of the latest, if it doesn't.
|
||||
#[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)]
|
||||
@@ -316,6 +340,14 @@ pub enum Response {
|
||||
RestorePreview(RestorePreview),
|
||||
/// A backup restored, and open.
|
||||
Restored(Restored),
|
||||
/// Books backed up now: their head, and each target's outcome (none
|
||||
/// named: none set up).
|
||||
BackedUp {
|
||||
/// The books' head seq.
|
||||
head_seq: u64,
|
||||
/// Each target.
|
||||
targets: Vec<BackedUpTarget>,
|
||||
},
|
||||
/// A token made: its text, shown this once (INVARIANT 6).
|
||||
TokenCreated {
|
||||
/// The token.
|
||||
@@ -371,6 +403,9 @@ pub fn call_within(
|
||||
Request::Create { .. } | Request::Adopt { .. } | Request::Restore { .. } => {
|
||||
Some("`wanderbooks books list` shows the books there are")
|
||||
}
|
||||
Request::BackUpNow { .. } => {
|
||||
Some("`wanderbooks books back-up` again backs up whatever it didn't")
|
||||
}
|
||||
Request::TokenCreate { .. } | Request::TokenRevoke { .. } => {
|
||||
Some("`wanderbooks token list` shows the tokens there are")
|
||||
}
|
||||
|
||||
@@ -490,6 +490,41 @@ impl Books {
|
||||
confirmed,
|
||||
expect_head,
|
||||
} => self.restore(id, source, replace, confirmed, expect_head),
|
||||
Request::BackUpNow { id, new_disk } => {
|
||||
let id = match BookId::parse(&id) {
|
||||
Ok(id) => id,
|
||||
Err(e) => return refused(e),
|
||||
};
|
||||
let backups = match self.held().get(id.as_str()) {
|
||||
Some(Book::Open { backups, .. }) => backups.clone(),
|
||||
Some(Book::Failed(why)) => {
|
||||
return Response::Refused(format!("those books didn't open: {why}"));
|
||||
}
|
||||
None => {
|
||||
return Response::Refused(format!(
|
||||
"there are no books called {:?}",
|
||||
id.as_str()
|
||||
));
|
||||
}
|
||||
};
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
match rt.block_on(backups.run_with("asked", new_disk)) {
|
||||
Ok(status) => Response::BackedUp {
|
||||
head_seq: status.head_seq,
|
||||
targets: status
|
||||
.targets
|
||||
.into_iter()
|
||||
.map(|t| admin::BackedUpTarget {
|
||||
target: t.target,
|
||||
at: t.at,
|
||||
through_seq: t.through_seq,
|
||||
problem: t.last_error,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
Err(why) => Response::Refused(self.hide(why)),
|
||||
}
|
||||
}
|
||||
Request::Adopt {
|
||||
id,
|
||||
source,
|
||||
|
||||
@@ -237,6 +237,10 @@ pub struct StartOut {
|
||||
pub handbook_sections: Vec<SectionOut>,
|
||||
/// What to do next, in order.
|
||||
pub next_steps: Vec<NextStepOut>,
|
||||
/// How the books are kept from agents: "same_user (advisory)", hidden
|
||||
/// from them in the human's own account, not locked against them
|
||||
/// (plan D-32): reach them only through these tools.
|
||||
pub isolation: String,
|
||||
}
|
||||
|
||||
/// One setting change.
|
||||
@@ -894,6 +898,7 @@ impl BooksServer {
|
||||
})
|
||||
.collect(),
|
||||
next_steps,
|
||||
isolation: "same_user (advisory)".to_string(),
|
||||
};
|
||||
// Fit (INVARIANT 4): whole lists if they fit; else summaries go,
|
||||
// then the lists shorten from the end, each saying how many more.
|
||||
|
||||
@@ -517,6 +517,8 @@ fn start_fits_in_8_kb() {
|
||||
let cut = start["truncated"].to_string();
|
||||
assert!(cut.contains("books.conventions"), "{cut}");
|
||||
assert!(start["conventions"].as_array().unwrap().len() >= 10);
|
||||
// isolation_label_is_advisory (plan P2, D-32).
|
||||
assert_eq!(start["isolation"], "same_user (advisory)");
|
||||
}
|
||||
|
||||
/// Review of P1 (2026-10-17): the text imported is the file the server
|
||||
|
||||
Reference in new issue
Block a user