feat: accept a staged update in the app

The host has staged a downloaded, signature-checked update since step 4,
but the only way to apply it was `wanderbooks update` at a terminal, and
nothing in the viewer or on an agent's first call said one waited. The
owner's accept is the name plan 8.0.3 allocated for it: the lease socket
gains `Intent::ApplyUpdate`, and the host installs the staged copy from a
thread and lets its existing watch for a replaced program (host.rs
INVARIANT 7) restart onto it once no call runs, so the books stay open and
every holder's session carries on. With no host running there is no call
to cut off, so the viewer applies it itself.

The viewer reads `<root>/updates/staged/meta.json` every few seconds and
shows a standing notice with "Update now"; Books gains "Check for updates"
(which looks at the feed at once, whatever the daily look last did) and
"Update to X". `books.start` gains the allocated `update_waiting` field
and a next step telling the agent to tell the human; no tool can accept.

`apply_staged` waits up to five seconds for the update lock: running the
new tests showed the host's own look can hold it for a moment after it
writes `meta.json`, and the owner's accept should wait that out rather
than answer "nothing waits".

Tests: `books_start_says_an_update_waits` and
`the_owner_accepts_a_staged_update_in_the_app` in
`crates/ledger-cli/tests/update.rs` (a bridge writes all through the
restart, each account in the chart exactly once);
`the_menu_checks_for_updates_and_a_waiting_update_shows_a_notice_that_accepts`
and `books_outside_a_data_root_have_no_update_notice` in
`crates/ledger-gui/tests/update_notice.rs`.
This commit is contained in:
swaits committed 2026-11-05 08:29:11 -07:00
1 parent 812d6f203e
commit 53a233d687
10 files changed
+693 -11

No files matched your search

+15
View File
@@ -172,6 +172,21 @@ impl ledger_gui::owner::Owner for ViewerOwner {
fn upgrade_format(&self, book: &str, passphrase: &str) -> Result<Vec<String>, String> { fn upgrade_format(&self, book: &str, passphrase: &str) -> Result<Vec<String>, String> {
ledger_host::commands::upgrade_format(&self.0, book, passphrase) ledger_host::commands::upgrade_format(&self.0, book, passphrase)
} }
fn update_waiting(&self) -> Option<ledger_gui::owner::UpdateWaiting> {
ledger_host::commands::update_waiting(&self.0).map(|u| ledger_gui::owner::UpdateWaiting {
version: u.version,
staged_at: u.staged_at,
})
}
fn check_for_updates(&self) -> Result<Vec<String>, String> {
ledger_host::commands::check_for_updates(&self.0).map(|l| vec![l])
}
fn apply_update(&self) -> Result<Vec<String>, String> {
ledger_host::commands::apply_update(&self.0).map(|l| vec![l])
}
} }
/// `wanderbooks gui`: the viewer, in this process. For books of the data /// `wanderbooks gui`: the viewer, in this process. For books of the data
+134
View File
@@ -1040,3 +1040,137 @@ fn a_staged_update_that_was_tampered_with_is_dropped_not_applied() {
"the tampered copy was kept" "the tampered copy was kept"
); );
} }
/// What the owner's app does when the owner accepts: one `apply_update`
/// on the host's lease socket (the viewer's `Owner::apply_update`, scripted).
fn owner_accepts(home: &Home) -> Result<Option<ledger_host::lease::HostMsg>, String> {
ledger_host::opener::ask_once_within(
&home.root().join("run"),
ledger_host::lease::Ask::Intent(ledger_host::lease::Intent::ApplyUpdate),
Duration::from_secs(90),
)
}
#[test]
fn books_start_says_an_update_waits() {
let home = Home::new("start-says");
home.create("acme");
let release = Release::new();
let next = newer(&home, "start-says");
release.publish("9.0.0", &next, |_| {});
let mut agent = Agent::start(&home, "acme");
let quiet = agent.call("books.start", json!({})).unwrap();
assert!(quiet.get("update_waiting").is_none(), "{quiet}");
name_feed(&home, &release);
// The host's look is once a day (a test knob makes it soon); a mark
// older than that lets the next poll look now.
let _ = std::fs::remove_file(home.updates().join("checked"));
assert!(
wait_for(30, || staged_version(&home).as_deref() == Some("9.0.0")),
"nothing was staged"
);
let start = agent.call("books.start", json!({})).unwrap();
assert_eq!(start["update_waiting"]["version"], "9.0.0", "{start}");
assert!(
start["update_waiting"]["staged_at"]
.as_str()
.unwrap()
.ends_with('Z')
);
assert!(
start["update_waiting"]["how"]
.as_str()
.unwrap()
.contains("only the owner"),
"{start}"
);
// An agent is told to tell the human, and is given no way to accept.
let steps = start["next_steps"].as_array().unwrap();
assert!(
steps
.iter()
.any(|s| s["why"].as_str().unwrap_or_default().contains("9.0.0")),
"{steps:?}"
);
let text = start.to_string();
assert!(
!text.contains(&home.dir.path().display().to_string()),
"a path in books.start"
);
}
#[test]
fn the_owner_accepts_a_staged_update_in_the_app() {
let home = Home::new("accept");
home.create("acme");
let mut agent = Agent::start(&home, "acme");
agent.add_account("4000").unwrap();
let host_before = wait_host(&home);
// Nothing staged: refused, and nothing changes.
match owner_accepts(&home) {
Ok(Some(ledger_host::lease::HostMsg::Refused(_))) => {}
other => panic!("accepting nothing: {other:?}"),
}
let release = Release::new();
let next = newer(&home, "accept");
release.publish("9.0.0", &next, |_| {});
name_feed(&home, &release);
let _ = std::fs::remove_file(home.updates().join("checked"));
assert!(
wait_for(30, || staged_version(&home).as_deref() == Some("9.0.0")),
"nothing was staged"
);
let old = home.program();
assert_eq!(sha256(&home.program()), sha256(&old));
// The owner accepts, in the app: the answer says it is installed.
match owner_accepts(&home) {
Ok(Some(ledger_host::lease::HostMsg::Done { detail })) => {
assert!(detail.unwrap_or_default().contains("9.0.0"));
}
other => panic!("accepting: {other:?}"),
}
assert_eq!(sha256(&home.program()), sha256(&next));
// Writes all through the restart onto it: none lost, none made twice,
// the session carries on with no reconnect.
let mut written = vec!["4000".to_string()];
let deadline = Instant::now() + Duration::from_secs(90);
let mut k = 0u32;
loop {
assert!(Instant::now() < deadline, "the host never restarted");
let code = format!("{}", 5000 + k);
k += 1;
let r = agent.add_account(&code);
assert!(r.is_ok(), "a write across the accept: {r:?}");
written.push(code);
if home.pids("host").len() == 1 && home.pids("host")[0] != host_before {
break;
}
}
let host = wait_host(&home);
assert!(wait_for(20, || !home.pids("server").is_empty()));
let exe = |pid: u32| std::fs::read_link(format!("/proc/{pid}/exe")).unwrap();
assert!(!exe(host).to_string_lossy().contains("(deleted)"));
assert_eq!(
sha256(&std::fs::read(home.updates().join("previous/wanderbooks")).unwrap()),
sha256(&old),
"the old program is kept for rollback"
);
agent.add_account("4999").unwrap();
written.push("4999".into());
let chart = agent.call("books.chart", json!({})).unwrap();
let mut in_books: Vec<String> = chart["accounts"]
.as_array()
.unwrap()
.iter()
.filter_map(|a| a["code"].as_str().map(str::to_string))
.filter(|c| c.starts_with('4') || c.starts_with('5'))
.collect();
in_books.sort();
written.sort();
assert_eq!(in_books, written);
}
+114 -5
View File
@@ -132,6 +132,16 @@ pub struct RestorePlan {
pub lines: Vec<String>, pub lines: Vec<String>,
} }
/// An update the host has downloaded and checked, waiting for the owner to
/// accept it (P11a step 4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateWaiting {
/// Its version.
pub version: String,
/// When it was staged (UTC, RFC 3339).
pub staged_at: String,
}
/// The owner's actions, done by the program the viewer runs in (ledger-cli /// 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 /// over the books server and the agents' own commands). Each blocks: the
/// menu calls it off the UI thread. /// menu calls it off the UI thread.
@@ -172,6 +182,21 @@ pub trait Owner: Send + Sync {
} }
/// Hold the books `book` open while the viewer runs (the viewer's lease). /// Hold the books `book` open while the viewer runs (the viewer's lease).
fn hold(&self, _book: &str) {} fn hold(&self, _book: &str) {}
/// The update waiting for the owner's accept, if one is (read from the
/// data root; cheap, called every few seconds).
fn update_waiting(&self) -> Option<UpdateWaiting> {
None
}
/// "Check for updates": look at the release feed now and stage a newer
/// version if there is one. What came of it, in plain words.
fn check_for_updates(&self) -> Result<Vec<String>, String> {
Err("not here".into())
}
/// Accept the waiting update: the books' host installs it and restarts
/// onto it when no call is running, the books staying open.
fn apply_update(&self) -> Result<Vec<String>, String> {
Err("not here".into())
}
/// How to run `agent` in the books home of `book` (made if need be): the /// How to run `agent` in the books home of `book` (made if need be): the
/// dock starts it on a terminal of its own (D-27). /// dock starts it on a terminal of its own (D-27).
fn agent_start(&self, _agent: Agent, _book: &str) -> Result<AgentStart, String> { fn agent_start(&self, _agent: Agent, _book: &str) -> Result<AgentStart, String> {
@@ -291,8 +316,14 @@ pub struct OwnerMenu {
/// The books whose format upgrade was asked for since the viewer /// The books whose format upgrade was asked for since the viewer
/// opened them (INVARIANT 5: once each open). /// opened them (INVARIANT 5: once each open).
asked_upgrade: Option<String>, asked_upgrade: Option<String>,
/// The update waiting, as last read, and when (re-read every few
/// seconds and after any job).
update: Option<(std::time::Instant, Option<UpdateWaiting>)>,
} }
/// How long a reading of the waiting update is kept.
const UPDATE_LOOK: std::time::Duration = std::time::Duration::from_secs(5);
impl OwnerMenu { impl OwnerMenu {
/// The id of the books shown, if they are books of a data root: found /// The id of the books shown, if they are books of a data root: found
/// once for each load. /// once for each load.
@@ -304,15 +335,73 @@ impl OwnerMenu {
self.book.as_ref().and_then(|(_, id)| id.clone()) self.book.as_ref().and_then(|(_, id)| id.clone())
} }
/// The standing notice of a format upgrade the books wait for, above /// The update waiting, if any (read now and then, not every frame).
/// the page, and the go-ahead asked for once each open (INVARIANT 5). fn update_waiting(
pub fn notice(&mut self, ui: &mut Ui, books: &Arc<Books>) { &mut self,
if owner().is_none() { ctx: &egui::Context,
return; owner: &Arc<dyn Owner>,
) -> Option<UpdateWaiting> {
if self
.update
.as_ref()
.is_none_or(|(at, _)| at.elapsed() > UPDATE_LOOK)
{
self.update = Some((std::time::Instant::now(), owner.update_waiting()));
} }
ctx.request_repaint_after(UPDATE_LOOK);
self.update.as_ref().and_then(|(_, u)| u.clone())
}
/// The standing notices above the page: an update waiting for the
/// owner's accept, and a format upgrade the books wait for, whose
/// go-ahead is asked for once each open (INVARIANT 5).
pub fn notice(&mut self, ui: &mut Ui, books: &Arc<Books>) {
let Some(owner) = owner() else {
return;
};
let Some(book) = self.book_of(books) else { let Some(book) = self.book_of(books) else {
return; return;
}; };
if let Some(update) = self.update_waiting(ui.ctx(), &owner) {
let p = crate::theme::pal(ui.ctx());
let mut start: Option<Box<dyn FnOnce() -> Outcome + Send>> = None;
egui::Frame::new()
.fill(p.warning_soft)
.inner_margin(egui::Margin::symmetric(20, 10))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal_wrapped(|ui| {
ui.label(
RichText::new(format!(
"{} Version {} of wanderbooks is ready.",
icon::ARROW_FAT_UP,
update.version
))
.font(crate::theme::semibold(13.0))
.color(p.warning),
);
ui.label(
RichText::new(
"It is downloaded and checked. The books stay open while it starts, and no work in progress is cut off.",
)
.font(crate::theme::regular(13.0))
.color(p.text),
);
let free = self.job.is_none();
if ui
.add_enabled(free, egui::Button::new("Update now"))
.on_hover_text("Start using the new version")
.clicked()
{
let o = owner.clone();
start = Some(Box::new(move || o.apply_update()));
}
});
});
if let Some(work) = start {
self.start(ui.ctx(), "Update".into(), work);
}
}
let Some(upgrade) = books.format_upgrade.clone() else { let Some(upgrade) = books.format_upgrade.clone() else {
return; return;
}; };
@@ -378,6 +467,7 @@ impl OwnerMenu {
{ {
self.done = Some((what.clone(), done)); self.done = Some((what.clone(), done));
self.job = None; self.job = None;
self.update = None;
} }
let text = if compact { let text = if compact {
RichText::new(icon::BOOKS).size(16.0).color(p.muted) RichText::new(icon::BOOKS).size(16.0).color(p.muted)
@@ -391,6 +481,7 @@ impl OwnerMenu {
button = button.min_size(vec2(32.0, ui.spacing().interact_size.y + 4.0)); button = button.min_size(vec2(32.0, ui.spacing().interact_size.y + 4.0));
} }
let busy = self.job.is_some(); let busy = self.job.is_some();
let waiting = self.update_waiting(ui.ctx(), &owner);
let mut start: Option<(String, Box<dyn FnOnce() -> Outcome + Send>)> = None; let mut start: Option<(String, Box<dyn FnOnce() -> Outcome + Send>)> = None;
let response = ui let response = ui
.add_enabled_ui(!busy, |ui| { .add_enabled_ui(!busy, |ui| {
@@ -449,6 +540,24 @@ impl OwnerMenu {
} }
} }
ui.separator(); ui.separator();
if ui
.button(format!("{} Check for updates", icon::ARROWS_CLOCKWISE))
.on_hover_text("Look for a newer version of wanderbooks now")
.clicked()
{
let o = owner.clone();
start = Some(("Check for updates".into(), Box::new(move || o.check_for_updates())));
}
if let Some(u) = &waiting
&& ui
.button(format!("{} Update to {}", icon::ARROW_FAT_UP, u.version))
.on_hover_text("Start using the version that is downloaded and checked: the books stay open")
.clicked()
{
let o = owner.clone();
start = Some(("Update".into(), Box::new(move || o.apply_update())));
}
ui.separator();
if ui if ui
.button(format!("{} Close the books", icon::POWER)) .button(format!("{} Close the books", icon::POWER))
.on_hover_text("Close the books now: agents' sessions and every other holder let go") .on_hover_text("Close the books now: agents' sessions and every other holder let go")
+199
View File
@@ -0,0 +1,199 @@
//! The viewer's update notice and its "Check for updates" menu item
//! (owner.rs, P11a step 4): a staged update the owner reports shows a
//! standing notice with an "Update now" button; the menu can look for one;
//! accepting hands the work to the owner, off the UI thread.
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::{Owner, RestorePlan, UpdateWaiting};
use ledger_store::Store;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
/// An owner with an update that waits once it has been "found".
#[derive(Default)]
struct Fake {
asked: Mutex<Vec<String>>,
staged: Mutex<bool>,
}
impl Fake {
fn said(&self, what: &str) {
self.asked
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(what.into());
}
}
impl Owner for Fake {
fn close_books(&self) -> Result<String, String> {
Err("not in these tests".into())
}
fn new_books(&self, _: &str, _: &str) -> Result<String, String> {
Err("not in these tests".into())
}
fn back_up_now(&self, _: &str) -> Result<Vec<String>, String> {
Err("not in these tests".into())
}
fn plan_restore(&self, _: &str, _: Option<String>, _: bool) -> Result<RestorePlan, String> {
Err("not in these tests".into())
}
fn restore(&self, _: &RestorePlan) -> Result<Vec<String>, String> {
Err("not in these tests".into())
}
fn connect(&self, _: ledger_gui::owner::Agent, _: &str) -> Result<Vec<String>, String> {
Err("not in these tests".into())
}
fn upgrade_format(&self, _: &str, _: &str) -> Result<Vec<String>, String> {
Err("not in these tests".into())
}
fn update_waiting(&self) -> Option<UpdateWaiting> {
(*self.staged.lock().unwrap()).then(|| UpdateWaiting {
version: "9.1.0".into(),
staged_at: "2026-10-30T08:00:00Z".into(),
})
}
fn check_for_updates(&self) -> Result<Vec<String>, String> {
self.said("check");
*self.staged.lock().unwrap() = true;
Ok(vec!["Version 9.1.0 is downloaded and checked.".into()])
}
fn apply_update(&self) -> Result<Vec<String>, String> {
self.said("apply");
*self.staged.lock().unwrap() = false;
Ok(vec!["Version 9.1.0 is installed.".into()])
}
}
fn alone() -> std::sync::MutexGuard<'static, ()> {
static ONE: Mutex<()> = Mutex::new(());
ONE.lock().unwrap_or_else(|e| e.into_inner())
}
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
/// outside one (as the demo's are).
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)
}
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
}
fn wait_for(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());
}
#[test]
fn the_menu_checks_for_updates_and_a_waiting_update_shows_a_notice_that_accepts() {
let _alone = alone();
let f = fake();
let (_dir, _book, b) = books(true);
let mut h = harness(b);
// Nothing waits: no notice.
assert!(h.query_by_label_contains("is ready.").is_none());
// "Check for updates" asks the owner to look; what it finds shows.
h.get_by_label_contains(" Books").click();
h.run_steps(3);
h.get_by_label_contains("Check for updates").click();
wait_for(&mut h, &f, 1);
assert_eq!(f.asked.lock().unwrap()[0], "check");
// The job says so, and the notice says so too: either is the same
// version, so ask for at least one rather than exactly one.
assert!(
h.query_all_by_label_contains("downloaded and checked")
.next()
.is_some()
);
// The standing notice names the version; accepting hands it to the
// owner, and the notice goes once it is installed.
for _ in 0..60 {
h.run_steps(2);
if h.query_by_label_contains("Version 9.1.0 of wanderbooks is ready")
.is_some()
{
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(
h.query_by_label_contains("Version 9.1.0 of wanderbooks is ready")
.is_some(),
"no notice"
);
h.get_by_label("Update now").click();
wait_for(&mut h, &f, 2);
assert_eq!(f.asked.lock().unwrap()[1], "apply");
for _ in 0..60 {
h.run_steps(2);
if h.query_by_label_contains("is ready.").is_none() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(h.query_by_label_contains("is ready.").is_none());
}
#[test]
fn books_outside_a_data_root_have_no_update_notice() {
let _alone = alone();
let f = fake();
*f.staged.lock().unwrap() = true;
let (_dir, _book, b) = books(false);
let h = harness(b);
assert!(h.query_by_label_contains("is ready.").is_none());
*f.staged.lock().unwrap() = false;
}
+84
View File
@@ -1031,6 +1031,90 @@ pub fn close_books(common: &Common) -> Result<String, String> {
} }
} }
/// An update the host has downloaded and checked, waiting for the owner
/// (the viewer's notice; P11a step 4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateWaiting {
/// Its version.
pub version: String,
/// When it was staged (UTC, RFC 3339).
pub staged_at: String,
}
/// The update waiting for the owner's accept, if there is one.
pub fn update_waiting(common: &Common) -> Option<UpdateWaiting> {
let found = ledger_store::root::Root::find(common.root.as_deref()).ok()?;
let u = found.staged_update()?;
// One this program already is (or is older than) is not waiting: the
// next look drops it.
let newer = crate::update::parse_version(&u.version)
.zip(crate::update::parse_version(
crate::update::program_version(),
))
.is_some_and(|(staged, mine)| staged > mine);
newer.then_some(UpdateWaiting {
version: u.version,
staged_at: u.staged_at,
})
}
/// "Check for updates": look at the feed now, whatever the daily look
/// last did, and stage a newer release if there is one (it never swaps
/// the program: accepting is [`apply_update`]). What came of it.
pub fn check_for_updates(common: &Common) -> Result<String, String> {
let found =
ledger_store::root::Root::find(common.root.as_deref()).map_err(|e| e.to_string())?;
let mine = crate::update::program_version();
if let crate::update::Install::PackageManaged(manager) = crate::update::this_install()? {
return Err(format!(
"this install is updated by {manager}, not here: use the package manager"
));
}
match crate::update::stage_if_due(&found, mine, Duration::ZERO) {
Ok(Some(version)) => Ok(format!(
"Version {version} is downloaded and checked. Accept it to start using it."
)),
Ok(None) => Ok(match update_waiting(common) {
Some(u) => format!(
"Version {} is downloaded and checked. Accept it to start using it.",
u.version
),
None => format!("You have the newest version ({mine})."),
}),
Err(why) => Err(why.to_string()),
}
}
/// Accept the staged update: the books' host installs it and restarts
/// onto it once no call is running, the books staying open meanwhile
/// (the lease socket's apply_update). With no host running, it is
/// installed here, since no call can be cut off. What came of it.
pub fn apply_update(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())?;
let said = |detail: Option<String>| detail.unwrap_or_else(|| "The update is accepted.".into());
match crate::opener::ask_once_within(&run, Ask::Intent(Intent::ApplyUpdate), UPDATE_WAIT)? {
Some(HostMsg::Done { detail }) => Ok(said(detail)),
Some(HostMsg::Refused(why)) => Err(why),
Some(HostMsg::Uncertain(why)) => Err(why),
Some(other) => Err(format!("the books' host answered oddly: {other:?}")),
None => match crate::update::apply_staged(&found, crate::update::program_version()) {
Ok(Some(version)) => Ok(format!(
"Version {version} is installed; it starts the next time the books open."
)),
Ok(None) => Err(
"no newer version waits, or this install is updated by its package manager".into(),
),
Err(why) => Err(why.to_string()),
},
}
}
/// The longest the host may take to install an accepted update (a copy
/// and a health check of the new program).
const UPDATE_WAIT: Duration = Duration::from_secs(90);
/// The longest an agent's own command may take to add the books. /// The longest an agent's own command may take to add the books.
const CONNECT_WAIT: Duration = Duration::from_secs(60); const CONNECT_WAIT: Duration = Duration::from_secs(60);
+52 -1
View File
@@ -648,6 +648,9 @@ impl Host {
Event::Intent(Intent::ConsentFormatUpgrade { book, passphrase }, answer) => { Event::Intent(Intent::ConsentFormatUpgrade { book, passphrase }, answer) => {
self.consent(book, passphrase.unwrap_or_default(), answer); self.consent(book, passphrase.unwrap_or_default(), answer);
} }
Event::Intent(Intent::ApplyUpdate, answer) => {
self.apply_update(answer);
}
Event::Intent(Intent::Close, answer) => { Event::Intent(Intent::Close, answer) => {
let _ = answer.send(HostMsg::Done { let _ = answer.send(HostMsg::Done {
detail: Some("the books are closing".into()), detail: Some("the books are closing".into()),
@@ -715,7 +718,7 @@ impl Host {
let (root, every, looking) = (self.root.clone(), self.knobs.update_check, looking.clone()); let (root, every, looking) = (self.root.clone(), self.knobs.update_check, looking.clone());
std::thread::spawn(move || { std::thread::spawn(move || {
match crate::update::stage_if_due(&root, crate::update::program_version(), every) { match crate::update::stage_if_due(&root, crate::update::program_version(), every) {
Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts the next time the books open, or at `wanderbooks update`"), Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts when you accept it in the app, the next time the books open, or at `wanderbooks update`"),
Ok(None) => {} Ok(None) => {}
Err(why) => eprintln!("wanderbooks host: the look for a newer version failed: {why}"), Err(why) => eprintln!("wanderbooks host: the look for a newer version failed: {why}"),
} }
@@ -860,6 +863,54 @@ impl Host {
} }
} }
/// The owner's accept of the staged update (P11a step 4): install it
/// now, as `wanderbooks update` does, and let the host's watch for a
/// replaced program (INVARIANT 7) restart onto it once no call is
/// running: the server finishes its calls before it closes, and the
/// leases carry on across it. The answer is sent when the file is
/// in place (or why not), from a thread, so the loop never waits.
fn apply_update(&self, answer: oneshot::Sender<HostMsg>) {
if !matches!(self.phase, Phase::Open) {
let _ = answer.send(HostMsg::Refused(
"the books are closing or restarting: ask again in a moment".into(),
));
return;
}
#[cfg(unix)]
{
let root = self.root.clone();
std::thread::spawn(move || {
let said = match crate::update::apply_staged(
&root,
crate::update::program_version(),
) {
Ok(Some(version)) => {
eprintln!(
"wanderbooks host: version {version} accepted: restarting onto it when no call is running"
);
HostMsg::Done {
detail: Some(format!(
"version {version} is installed; the books restart onto it as soon as no call is running, and stay open meanwhile"
)),
}
}
Ok(None) => HostMsg::Refused(
"no newer version waits, or this install is updated by its package manager"
.into(),
),
Err(why) => HostMsg::Refused(why.to_string()),
};
let _ = answer.send(said);
});
}
#[cfg(not(unix))]
{
let _ = answer.send(HostMsg::Refused(
"updating from the app isn't built for this system yet".into(),
));
}
}
/// Close the server's input: it closes the books (INVARIANT 3). /// Close the server's input: it closes the books (INVARIANT 3).
fn close_server(&mut self) { fn close_server(&mut self) {
self.server_stdin = None; self.server_stdin = None;
+9 -3
View File
@@ -16,7 +16,8 @@
//! 1. A connection's first line is one Ask; a Hold keeps the //! 1. A connection's first line is one Ask; a Hold keeps the
//! connection (the lease lasts until it closes), anything else is //! connection (the lease lasts until it closes), anything else is
//! answered once and the connection closed. //! answered once and the connection closed.
//! 2. Exactly two intents exist: close and consent_format_upgrade; any //! 2. Exactly three intents exist: close, consent_format_upgrade and
//! apply_update; any
//! other request is refused. //! other request is refused.
//! 3. consent_format_upgrade carries the owner passphrase; without one //! 3. consent_format_upgrade carries the owner passphrase; without one
//! it is refused before anything is done. With one, the host hands it //! it is refused before anything is done. With one, the host hands it
@@ -106,7 +107,7 @@ pub enum Ask {
Intent(Intent), Intent(Intent),
} }
/// The owner's intents: exactly these two (INVARIANT 2). /// The owner's intents: exactly these three (INVARIANT 2).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)] #[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Intent { pub enum Intent {
@@ -120,6 +121,10 @@ pub enum Intent {
#[serde(default)] #[serde(default)]
passphrase: Option<ledger_mcp::admin::Passphrase>, passphrase: Option<ledger_mcp::admin::Passphrase>,
}, },
/// Accept the update the host has staged (P11a step 4): the host
/// installs it, and restarts onto it once no call is running, the
/// books staying open to every holder across it.
ApplyUpdate,
} }
impl Intent { impl Intent {
@@ -258,9 +263,10 @@ mod tests {
} }
#[test] #[test]
fn exactly_two_intents_are_understood() { fn exactly_three_intents_are_understood() {
for intent in [ for intent in [
json!("close"), json!("close"),
json!("apply_update"),
json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}), json!({"consent_format_upgrade": {"book": "acme", "passphrase": "x"}}),
] { ] {
let h = parse(json!({"proto": 1, "intent": intent})).unwrap(); let h = parse(json!({"proto": 1, "intent": intent})).unwrap();
+6 -2
View File
@@ -157,7 +157,8 @@ pub fn install_kind(exe: &Path, appimage: Option<&Path>) -> Install {
Install::SelfUpdating(exe.to_path_buf()) Install::SelfUpdating(exe.to_path_buf())
} }
fn this_install() -> Result<Install, String> { /// How the running program was installed.
pub fn this_install() -> Result<Install, String> {
let exe = crate::start::this_executable()?; let exe = crate::start::this_executable()?;
let image = std::env::var_os("APPIMAGE").map(PathBuf::from); let image = std::env::var_os("APPIMAGE").map(PathBuf::from);
Ok(install_kind(&exe, image.as_deref())) Ok(install_kind(&exe, image.as_deref()))
@@ -494,7 +495,10 @@ pub fn apply_staged(
let Install::SelfUpdating(target) = this_install()? else { let Install::SelfUpdating(target) = this_install()? else {
return Ok(None); return Ok(None);
}; };
let Ok(_lock) = take_lock(&updates) else { // The host's own look at the feed can still hold the lock for a moment
// after it writes `meta.json`; the owner's accept should wait that out,
// not answer "nothing waits" (P11a step 4).
let Ok(_lock) = take_lock_within(&updates, Duration::from_secs(5)) else {
return Ok(None); return Ok(None);
}; };
let meta: serde_json::Value = std::fs::read(dir.join(META)) let meta: serde_json::Value = std::fs::read(dir.join(META))
+35
View File
@@ -242,6 +242,17 @@ pub struct FormatUpgradeOut {
pub how: String, pub how: String,
} }
/// An update the host has downloaded and checked, waiting for the owner.
#[derive(Debug, Serialize, JsonSchema)]
pub struct UpdateWaitingOut {
/// The version waiting.
pub version: String,
/// When the host staged it (UTC, RFC 3339).
pub staged_at: String,
/// Who can accept it, and how.
pub how: String,
}
/// Something to do next. /// Something to do next.
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub struct NextStepOut { pub struct NextStepOut {
@@ -391,6 +402,10 @@ pub struct StartOut {
/// the owner gives the go-ahead. No tool can: tell the human. /// the owner gives the go-ahead. No tool can: tell the human.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub format_upgrade: Option<FormatUpgradeOut>, pub format_upgrade: Option<FormatUpgradeOut>,
/// A newer version the host has downloaded and checked (P11a step 4):
/// the owner accepts it in the app. No tool can: tell the human.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub update_waiting: Option<UpdateWaitingOut>,
/// Who is asking. /// Who is asking.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub you: Option<YouOut>, pub you: Option<YouOut>,
@@ -963,6 +978,25 @@ impl BooksServer {
"Tell the human first: these books wait for a format upgrade, which only the owner can agree to (in the app, which asks when it opens the books, or `wanderbooks books upgrade` at a terminal, with the owner passphrase; a verified backup is made first). Until then they work on their old format and refuse what needs the new one (format_upgrade_pending). No tool can upgrade them.", "Tell the human first: these books wait for a format upgrade, which only the owner can agree to (in the app, which asks when it opens the books, or `wanderbooks books upgrade` at a terminal, with the owner passphrase; a verified backup is made first). Until then they work on their old format and refuse what needs the new one (format_upgrade_pending). No tool can upgrade them.",
)); ));
} }
// A newer version waits for the owner (P11a step 4): said once,
// after a format upgrade, which comes first.
let update_waiting = ledger_store::root::root_of_book_dir(store.data_dir())
.and_then(ledger_store::root::staged_update_in)
.map(|u| UpdateWaitingOut {
version: u.version,
staged_at: u.staged_at,
how: "only the owner accepts it: in the app (Books, then Update), or `wanderbooks update` at a terminal. No tool can.".to_string(),
});
if let Some(u) = &update_waiting {
next_steps.push(step(
"books.handbook",
None,
&format!(
"Tell the human: version {} of wanderbooks is downloaded and checked, and waits for them to accept it in the app (Books, then Update). Nothing changes until they do; no tool can.",
u.version
),
));
}
if onboarding || focus == "onboarding" { if onboarding || focus == "onboarding" {
next_steps.push(NextStepOut { next_steps.push(NextStepOut {
tool: None, tool: None,
@@ -1240,6 +1274,7 @@ impl BooksServer {
title: handbook::title(md).to_string(), title: handbook::title(md).to_string(),
}) })
.collect(), .collect(),
update_waiting,
format_upgrade: upgrade.map(|u| FormatUpgradeOut { format_upgrade: upgrade.map(|u| FormatUpgradeOut {
guard: ledger_store::error::FORMAT_UPGRADE_PENDING.to_string(), guard: ledger_store::error::FORMAT_UPGRADE_PENDING.to_string(),
format: u.from.format, format: u.from.format,
+45
View File
@@ -661,6 +661,12 @@ impl Root {
.map_err(|e| self.hide(e)) .map_err(|e| self.hide(e))
} }
/// The update the host has downloaded, checked and staged under
/// `updates/staged/`, if one waits (P11a step 4).
pub fn staged_update(&self) -> Option<StagedUpdate> {
staged_update_in(&self.path)
}
/// The folder a book lives in. /// The folder a book lives in.
pub fn book_dir(&self, id: &BookId) -> PathBuf { pub fn book_dir(&self, id: &BookId) -> PathBuf {
self.path.join(BOOKS_DIR).join(id.as_str()) self.path.join(BOOKS_DIR).join(id.as_str())
@@ -2394,3 +2400,42 @@ mod unit {
assert_eq!(keys, book.join("keys")); assert_eq!(keys, book.join("keys"));
} }
} }
/// An update waiting in `<root>/updates/staged/`: downloaded, checked and
/// kept by the host, applied when the owner accepts it or the books next
/// start.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StagedUpdate {
/// Its version.
pub version: String,
/// When it was staged, UTC (RFC 3339).
pub staged_at: String,
}
/// The update staged in the data root `root`, if one waits. What it says
/// is shown to people and agents, so a version that is not plainly one
/// (digits, letters, dots, dashes, at most 40) is no update.
pub fn staged_update_in(root: &Path) -> Option<StagedUpdate> {
let meta = root.join("updates").join("staged").join("meta.json");
let value: serde_json::Value = serde_json::from_slice(&std::fs::read(&meta).ok()?).ok()?;
let version = value["version"].as_str()?;
let plain = !version.is_empty()
&& version.len() <= 40
&& version
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'));
if !plain {
return None;
}
let at: chrono::DateTime<chrono::Utc> = std::fs::metadata(&meta).ok()?.modified().ok()?.into();
Some(StagedUpdate {
version: version.to_string(),
staged_at: at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
})
}
/// The data root a book's folder (`<root>/books/<id>`) is in, if it is one.
pub fn root_of_book_dir(book_dir: &Path) -> Option<&Path> {
let root = book_dir.parent()?.parent()?;
root.join(MARKER_FILE).is_file().then_some(root)
}