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

+114 -5
View File
@@ -132,6 +132,16 @@ pub struct RestorePlan {
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
/// over the books server and the agents' own commands). Each blocks: the
/// 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).
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
/// dock starts it on a terminal of its own (D-27).
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
/// opened them (INVARIANT 5: once each open).
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 {
/// The id of the books shown, if they are books of a data root: found
/// once for each load.
@@ -304,15 +335,73 @@ impl OwnerMenu {
self.book.as_ref().and_then(|(_, id)| id.clone())
}
/// The standing notice of a format upgrade the books wait for, above
/// the page, and the go-ahead asked for once each open (INVARIANT 5).
pub fn notice(&mut self, ui: &mut Ui, books: &Arc<Books>) {
if owner().is_none() {
return;
/// The update waiting, if any (read now and then, not every frame).
fn update_waiting(
&mut self,
ctx: &egui::Context,
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 {
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 {
return;
};
@@ -378,6 +467,7 @@ impl OwnerMenu {
{
self.done = Some((what.clone(), done));
self.job = None;
self.update = None;
}
let text = if compact {
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));
}
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 response = ui
.add_enabled_ui(!busy, |ui| {
@@ -449,6 +540,24 @@ impl OwnerMenu {
}
}
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
.button(format!("{} Close the books", icon::POWER))
.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;
}