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

+45
View File
@@ -661,6 +661,12 @@ impl Root {
.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.
pub fn book_dir(&self, id: &BookId) -> PathBuf {
self.path.join(BOOKS_DIR).join(id.as_str())
@@ -2394,3 +2400,42 @@ mod unit {
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)
}