max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+309 insertions,
-7 deletions
| @@ -1333,6 +1333,17 @@ | |||
| 1333 | 1333 | /// | |
| 1334 | 1334 | /// [`refresh`]: PkgView::refresh | |
| 1335 | 1335 | stale: Option<Result<Staleness>>, | |
| 1336 | + | ||
| 1337 | + | /// When the last successful check happened, read from disk at construction | |
| 1338 | + | /// and updated in place after a check. Read once rather than per frame: it | |
| 1339 | + | /// is a file read, and the answer cannot change while this view is up | |
| 1340 | + | /// except by this view changing it. | |
| 1341 | + | /// | |
| 1342 | + | /// This is the local half of the update position (docs/STACK.md, | |
| 1343 | + | /// "Updates"). It reports the age of the last check and never whether an | |
| 1344 | + | /// update exists, because learning that is the outbound request Alloy | |
| 1345 | + | /// declines to make on its own. | |
| 1346 | + | last_checked: Option<std::time::SystemTime>, | |
| 1336 | 1347 | } | |
| 1337 | 1348 | ||
| 1338 | 1349 | impl PkgView { | |
| @@ -1353,6 +1364,7 @@ | |||
| 1353 | 1364 | rpm: None, | |
| 1354 | 1365 | has_dnf: stale::available(), | |
| 1355 | 1366 | stale: None, | |
| 1367 | + | last_checked: stale::last_checked(), | |
| 1356 | 1368 | }; | |
| 1357 | 1369 | view.refresh(log); | |
| 1358 | 1370 | view | |
| @@ -1773,17 +1785,72 @@ | |||
| 1773 | 1785 | .collect(); | |
| 1774 | 1786 | frame.render_widget(AlloyList::new(theme, rows), list_area); | |
| 1775 | 1787 | ||
| 1776 | - | let pinned = status.deployments.iter().filter(|d| d.pinned).count(); | |
| 1777 | - | let mut summary = format!("{} deployments", status.deployments.len()); | |
| 1778 | - | if pinned > 0 { | |
| 1779 | - | let _ = write!(summary, ", {pinned} pinned"); | |
| 1780 | - | } | |
| 1788 | + | let behind = match &self.stale { | |
| 1789 | + | Some(Ok(staleness)) => Some(staleness.behind()), | |
| 1790 | + | // An unchecked machine and a failed check are the same statement | |
| 1791 | + | // here: this line does not know. The staleness block below says | |
| 1792 | + | // which one it is. | |
| 1793 | + | Some(Err(_)) | None => None, | |
| 1794 | + | }; | |
| 1795 | + | let summary = Self::system_summary( | |
| 1796 | + | behind, | |
| 1797 | + | status.deployments.iter().filter(|d| d.staged).count(), | |
| 1798 | + | status.deployments.len(), | |
| 1799 | + | status.deployments.iter().filter(|d| d.pinned).count(), | |
| 1800 | + | ); | |
| 1781 | 1801 | frame.render_widget(Line::from(text::muted(theme, summary)), summary_area); | |
| 1782 | 1802 | } | |
| 1783 | 1803 | ||
| 1784 | 1804 | self.render_staleness(frame, stale_area, theme); | |
| 1785 | 1805 | } | |
| 1786 | 1806 | ||
| 1807 | + | /// The one-line headline over the deployment list. | |
| 1808 | + | /// | |
| 1809 | + | /// "3 updates available, 2 staged for reboot" is the shape Max asked for | |
| 1810 | + | /// (2026-08-05). The two halves come from different places and mean | |
| 1811 | + | /// different things, which is why they are counted separately rather than | |
| 1812 | + | /// added: `behind` is packages a rebuild would move forward, and `staged` is | |
| 1813 | + | /// deployments already built and waiting on a reboot. A machine can have | |
| 1814 | + | /// both, either, or neither. | |
| 1815 | + | /// | |
| 1816 | + | /// `behind` is `None` when nothing has been checked, and then the line says | |
| 1817 | + | /// nothing about updates at all. Reporting "0 updates available" for an | |
| 1818 | + | /// unchecked machine would be a claim about the world that Alloy has not | |
| 1819 | + | /// made and will not make on its own. | |
| 1820 | + | fn system_summary( | |
| 1821 | + | behind: Option<usize>, | |
| 1822 | + | staged: usize, | |
| 1823 | + | deployments: usize, | |
| 1824 | + | pinned: usize, | |
| 1825 | + | ) -> String { | |
| 1826 | + | let mut parts: Vec<String> = Vec::new(); | |
| 1827 | + | ||
| 1828 | + | match behind { | |
| 1829 | + | Some(0) => parts.push("up to date".to_string()), | |
| 1830 | + | Some(1) => parts.push("1 update available".to_string()), | |
| 1831 | + | Some(n) => parts.push(format!("{n} updates available")), | |
| 1832 | + | None => {} | |
| 1833 | + | } | |
| 1834 | + | ||
| 1835 | + | if staged == 1 { | |
| 1836 | + | parts.push("1 staged for reboot".to_string()); | |
| 1837 | + | } else if staged > 1 { | |
| 1838 | + | parts.push(format!("{staged} staged for reboot")); | |
| 1839 | + | } | |
| 1840 | + | ||
| 1841 | + | parts.push(if deployments == 1 { | |
| 1842 | + | "1 deployment".to_string() | |
| 1843 | + | } else { | |
| 1844 | + | format!("{deployments} deployments") | |
| 1845 | + | }); | |
| 1846 | + | ||
| 1847 | + | if pinned > 0 { | |
| 1848 | + | parts.push(format!("{pinned} pinned")); | |
| 1849 | + | } | |
| 1850 | + | ||
| 1851 | + | parts.join(", ") | |
| 1852 | + | } | |
| 1853 | + | ||
| 1787 | 1854 | /// What a rebuild would change, under the deployments it would add to. | |
| 1788 | 1855 | /// | |
| 1789 | 1856 | /// Four states, and the unchecked one is the default rather than a failure: | |
| @@ -1805,6 +1872,14 @@ | |||
| 1805 | 1872 | ))); | |
| 1806 | 1873 | } | |
| 1807 | 1874 | None => { | |
| 1875 | + | // The nudge, and the only thing on this screen that speaks | |
| 1876 | + | // before the user presses anything. It is a subtraction against | |
| 1877 | + | // a stored timestamp, so it contacts nothing; see | |
| 1878 | + | // [`stale::describe_last_check`]. | |
| 1879 | + | lines.push(Line::from(text::muted( | |
| 1880 | + | theme, | |
| 1881 | + | stale::describe_last_check(self.last_checked, std::time::SystemTime::now()), | |
| 1882 | + | ))); | |
| 1808 | 1883 | lines.push(Line::from(text::muted( | |
| 1809 | 1884 | theme, | |
| 1810 | 1885 | "press u to check what is behind (refreshes repo metadata; takes a moment)", | |
| @@ -1997,7 +2072,15 @@ | |||
| 1997 | 2072 | // would undo `alloy@85ee0e6`'s promise that an installed machine never | |
| 1998 | 2073 | // reaches out on its own. See [`stale`](crate::stale). | |
| 1999 | 2074 | if self.tab() == Tab::System && key.code == KeyCode::Char('u') && self.has_dnf { | |
| 2000 | - | self.stale = Some(stale::check(log)); | |
| 2075 | + | let outcome = stale::check(log); | |
| 2076 | + | // Only a check that succeeded is written down. A failed one learned | |
| 2077 | + | // nothing about how far behind the machine is, and recording it | |
| 2078 | + | // would let a month of broken network read as a month of currency. | |
| 2079 | + | if outcome.is_ok() { | |
| 2080 | + | stale::record_check(); | |
| 2081 | + | self.last_checked = stale::last_checked(); | |
| 2082 | + | } | |
| 2083 | + | self.stale = Some(outcome); | |
| 2001 | 2084 | return Flow::Continue; | |
| 2002 | 2085 | } | |
| 2003 | 2086 | ||
| @@ -2047,6 +2130,56 @@ | |||
| 2047 | 2130 | mod tests { | |
| 2048 | 2131 | use super::*; | |
| 2049 | 2132 | ||
| 2133 | + | /// The shape Max asked for, and the reason the two counts stay separate: | |
| 2134 | + | /// packages behind and deployments staged are different facts and a machine | |
| 2135 | + | /// can carry both at once. | |
| 2136 | + | #[test] | |
| 2137 | + | fn summary_reports_updates_and_staged_separately() { | |
| 2138 | + | assert_eq!( | |
| 2139 | + | PkgView::system_summary(Some(3), 2, 3, 0), | |
| 2140 | + | "3 updates available, 2 staged for reboot, 3 deployments" | |
| 2141 | + | ); | |
| 2142 | + | } | |
| 2143 | + | ||
| 2144 | + | /// An unchecked machine says nothing about updates. "0 updates available" | |
| 2145 | + | /// would be a claim about the world that nothing has looked at, which is the | |
| 2146 | + | /// exact claim the update position refuses to make on its own. | |
| 2147 | + | #[test] | |
| 2148 | + | fn unchecked_machine_makes_no_claim_about_updates() { | |
| 2149 | + | let summary = PkgView::system_summary(None, 1, 2, 0); | |
| 2150 | + | assert_eq!(summary, "1 staged for reboot, 2 deployments"); | |
| 2151 | + | assert!(!summary.contains("update")); | |
| 2152 | + | assert!(!summary.contains("up to date")); | |
| 2153 | + | } | |
| 2154 | + | ||
| 2155 | + | /// Checked and level is a different statement from unchecked, and has to | |
| 2156 | + | /// read as one. | |
| 2157 | + | #[test] | |
| 2158 | + | fn checked_and_level_says_so() { | |
| 2159 | + | assert_eq!( | |
| 2160 | + | PkgView::system_summary(Some(0), 0, 1, 0), | |
| 2161 | + | "up to date, 1 deployment" | |
| 2162 | + | ); | |
| 2163 | + | } | |
| 2164 | + | ||
| 2165 | + | /// Singulars, because "1 updates available" is the kind of thing that makes | |
| 2166 | + | /// a careful screen look careless. | |
| 2167 | + | #[test] | |
| 2168 | + | fn counts_of_one_read_as_singular() { | |
| 2169 | + | assert_eq!( | |
| 2170 | + | PkgView::system_summary(Some(1), 1, 1, 0), | |
| 2171 | + | "1 update available, 1 staged for reboot, 1 deployment" | |
| 2172 | + | ); | |
| 2173 | + | } | |
| 2174 | + | ||
| 2175 | + | #[test] | |
| 2176 | + | fn pinned_is_carried_when_present() { | |
| 2177 | + | assert_eq!( | |
| 2178 | + | PkgView::system_summary(Some(0), 0, 4, 2), | |
| 2179 | + | "up to date, 4 deployments, 2 pinned" | |
| 2180 | + | ); | |
| 2181 | + | } | |
| 2182 | + | ||
| 2050 | 2183 | // Captured from this machine's real `podman ps --format json --all`, cut to | |
| 2051 | 2184 | // the fields the parser reads. The awkward parts are real: a container in | |
| 2052 | 2185 | // `created` rather than running or exited, and `Names` as an array. | |
| @@ -2952,6 +3085,10 @@ | |||
| 2952 | 3085 | // claimed dnf would offer a key these tests cannot press. | |
| 2953 | 3086 | has_dnf: false, | |
| 2954 | 3087 | stale: None, | |
| 3088 | + | // Not read from disk: a fixture that picked up this machine's real | |
| 3089 | + | // state file would render a different line depending on when the | |
| 3090 | + | // suite was last run on a developer's box. | |
| 3091 | + | last_checked: None, | |
| 2955 | 3092 | }; | |
| 2956 | 3093 | view.cursor.resize(view.boxes.len()); | |
| 2957 | 3094 | (view, CommandLog::new()) |
| @@ -93,7 +93,8 @@ | |||
| 93 | 93 | ||
| 94 | 94 | use std::collections::BTreeMap; | |
| 95 | 95 | use std::fmt::Write as _; | |
| 96 | - | use std::path::Path; | |
| 96 | + | use std::path::{Path, PathBuf}; | |
| 97 | + | use std::time::{SystemTime, UNIX_EPOCH}; | |
| 97 | 98 | ||
| 98 | 99 | use anyhow::{Context, Result}; | |
| 99 | 100 | ||
| @@ -212,6 +213,15 @@ | |||
| 212 | 213 | self.watched.is_empty() && self.bulk == 0 | |
| 213 | 214 | } | |
| 214 | 215 | ||
| 216 | + | /// How many packages are behind, watchlist and bulk together. | |
| 217 | + | /// | |
| 218 | + | /// The one number the headline needs. The report below keeps the two halves | |
| 219 | + | /// apart because a list and a count read differently; a headline has room | |
| 220 | + | /// for neither and wants the total. | |
| 221 | + | pub(crate) fn behind(&self) -> usize { | |
| 222 | + | self.watched.len() + self.bulk | |
| 223 | + | } | |
| 224 | + | ||
| 215 | 225 | /// The commands that bring the machine forward, in order. | |
| 216 | 226 | /// | |
| 217 | 227 | /// Printed rather than run; see the module docs. `build-image.sh` rather | |
| @@ -496,6 +506,87 @@ | |||
| 496 | 506 | (watched, bulk) | |
| 497 | 507 | } | |
| 498 | 508 | ||
| 509 | + | // ---- when the user last looked ---- | |
| 510 | + | // | |
| 511 | + | // The one nudge the update position allows (docs/STACK.md, "Updates"). Alloy | |
| 512 | + | // never checks on its own, so a machine can sit behind indefinitely with nothing | |
| 513 | + | // to say so. What it can do without contacting anything is subtract: it knows | |
| 514 | + | // when you last asked, because it wrote the time down. | |
| 515 | + | // | |
| 516 | + | // The line this feeds reports the age of your last check and nothing about the | |
| 517 | + | // world. It must never imply an update exists — that is the outbound request the | |
| 518 | + | // position declines, and the whole reason this is a stored timestamp rather than | |
| 519 | + | // a background poll. | |
| 520 | + | ||
| 521 | + | /// Where the last-check timestamp lives. | |
| 522 | + | /// | |
| 523 | + | /// `$XDG_STATE_HOME/alloy/` when set, `~/.local/state/alloy/` otherwise. State | |
| 524 | + | /// rather than config or cache: it is not user-editable and it is not | |
| 525 | + | /// regenerable, since deleting it loses the only record of when you looked. | |
| 526 | + | fn state_path() -> Option<PathBuf> { | |
| 527 | + | let dir = match std::env::var("XDG_STATE_HOME") { | |
| 528 | + | Ok(state) if !state.is_empty() => PathBuf::from(state), | |
| 529 | + | _ => PathBuf::from(std::env::var("HOME").ok()?).join(".local/state"), | |
| 530 | + | }; | |
| 531 | + | Some(dir.join("alloy/last-update-check")) | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | /// Write down that a check just happened. | |
| 535 | + | /// | |
| 536 | + | /// Called only after a check that succeeded. A failed check learned nothing | |
| 537 | + | /// about how far behind the machine is, so recording it would let a week of | |
| 538 | + | /// broken network read as a week of being up to date. | |
| 539 | + | /// | |
| 540 | + | /// Failure to write is deliberately silent. The timestamp is a courtesy; losing | |
| 541 | + | /// it costs the nudge and nothing else, and a console that interrupted a | |
| 542 | + | /// successful update check to complain about its own state directory would be | |
| 543 | + | /// reporting the wrong thing. | |
| 544 | + | pub(crate) fn record_check() { | |
| 545 | + | let Some(path) = state_path() else { | |
| 546 | + | return; | |
| 547 | + | }; | |
| 548 | + | let Ok(elapsed) = SystemTime::now().duration_since(UNIX_EPOCH) else { | |
| 549 | + | return; | |
| 550 | + | }; | |
| 551 | + | if let Some(parent) = path.parent() { | |
| 552 | + | let _ = std::fs::create_dir_all(parent); | |
| 553 | + | } | |
| 554 | + | let _ = std::fs::write(&path, elapsed.as_secs().to_string()); | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | /// When the last successful check happened, if one ever did. | |
| 558 | + | pub(crate) fn last_checked() -> Option<SystemTime> { | |
| 559 | + | let raw = std::fs::read_to_string(state_path()?).ok()?; | |
| 560 | + | let secs: u64 = raw.trim().parse().ok()?; | |
| 561 | + | Some(UNIX_EPOCH + std::time::Duration::from_secs(secs)) | |
| 562 | + | } | |
| 563 | + | ||
| 564 | + | /// The nudge, as words. | |
| 565 | + | /// | |
| 566 | + | /// `None` means no check has ever been recorded, which is a different statement | |
| 567 | + | /// from "checked a long time ago" and reads differently on screen. | |
| 568 | + | pub(crate) fn describe_last_check(last: Option<SystemTime>, now: SystemTime) -> String { | |
| 569 | + | let Some(last) = last else { | |
| 570 | + | return "never checked what is behind".to_string(); | |
| 571 | + | }; | |
| 572 | + | // A timestamp ahead of the clock means the clock moved backwards, not that | |
| 573 | + | // the future was checked. Report it as unknown rather than rendering a | |
| 574 | + | // negative age or clamping it to "today", which would look like a fresh | |
| 575 | + | // check that never happened. | |
| 576 | + | let Ok(age) = now.duration_since(last) else { | |
| 577 | + | return "last checked at an unreadable time (clock moved?)".to_string(); | |
| 578 | + | }; | |
| 579 | + | let days = age.as_secs() / 86_400; | |
| 580 | + | match days { | |
| 581 | + | 0 => "last checked today".to_string(), | |
| 582 | + | 1 => "last checked yesterday".to_string(), | |
| 583 | + | // Past a year the day count stops carrying information and starts | |
| 584 | + | // reading as precision nobody asked for. | |
| 585 | + | 366.. => "last checked over a year ago".to_string(), | |
| 586 | + | _ => format!("last checked {days} days ago"), | |
| 587 | + | } | |
| 588 | + | } | |
| 589 | + | ||
| 499 | 590 | /// Whether this machine can be checked at all. | |
| 500 | 591 | /// | |
| 501 | 592 | /// dnf is present on any Fedora-derived system including a dev box, so unlike | |
| @@ -536,6 +627,80 @@ | |||
| 536 | 627 | ||
| 537 | 628 | #[cfg(test)] | |
| 538 | 629 | mod tests { | |
| 630 | + | /// The nudge's whole job is to distinguish these three, and "never" is not | |
| 631 | + | /// "a long time ago": a machine nobody has ever checked reads differently | |
| 632 | + | /// from one checked last year. | |
| 633 | + | #[test] | |
| 634 | + | fn last_check_ages_read_as_words() { | |
| 635 | + | let now = UNIX_EPOCH + std::time::Duration::from_secs(400 * 86_400); | |
| 636 | + | let ago = | |
| 637 | + | |days: u64| Some(UNIX_EPOCH + std::time::Duration::from_secs((400 - days) * 86_400)); | |
| 638 | + | ||
| 639 | + | assert_eq!( | |
| 640 | + | describe_last_check(None, now), | |
| 641 | + | "never checked what is behind" | |
| 642 | + | ); | |
| 643 | + | assert_eq!(describe_last_check(ago(0), now), "last checked today"); | |
| 644 | + | assert_eq!(describe_last_check(ago(1), now), "last checked yesterday"); | |
| 645 | + | assert_eq!( | |
| 646 | + | describe_last_check(ago(34), now), | |
| 647 | + | "last checked 34 days ago" | |
| 648 | + | ); | |
| 649 | + | assert_eq!( | |
| 650 | + | describe_last_check(ago(370), now), | |
| 651 | + | "last checked over a year ago" | |
| 652 | + | ); | |
| 653 | + | } | |
| 654 | + | ||
| 655 | + | /// A clock that moved backwards must not render as a fresh check. Clamping | |
| 656 | + | /// this to "today" would report a check that never happened. | |
| 657 | + | #[test] | |
| 658 | + | fn a_timestamp_from_the_future_is_not_today() { | |
| 659 | + | let now = UNIX_EPOCH + std::time::Duration::from_secs(1_000); | |
| 660 | + | let later = Some(UNIX_EPOCH + std::time::Duration::from_secs(9_000)); | |
| 661 | + | assert_eq!( | |
| 662 | + | describe_last_check(later, now), | |
| 663 | + | "last checked at an unreadable time (clock moved?)" | |
| 664 | + | ); | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | /// Nothing here may read as a security scan, and the nudge is the newest | |
| 668 | + | /// place that could slip. | |
| 669 | + | #[test] | |
| 670 | + | fn the_nudge_claims_nothing_about_the_world() { | |
| 671 | + | let now = UNIX_EPOCH + std::time::Duration::from_secs(90 * 86_400); | |
| 672 | + | let text = describe_last_check( | |
| 673 | + | Some(UNIX_EPOCH + std::time::Duration::from_secs(20 * 86_400)), | |
| 674 | + | now, | |
| 675 | + | ); | |
| 676 | + | for forbidden in [ | |
| 677 | + | "vulnerab", | |
| 678 | + | "security", | |
| 679 | + | "available", | |
| 680 | + | "behind by", | |
| 681 | + | "out of date", | |
| 682 | + | ] { | |
| 683 | + | assert!(!text.contains(forbidden), "{text:?} implies {forbidden}"); | |
| 684 | + | } | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | /// The total the headline uses, against the split the report uses. | |
| 688 | + | #[test] | |
| 689 | + | fn behind_totals_both_halves() { | |
| 690 | + | let staleness = Staleness { | |
| 691 | + | watched: vec![Behind { | |
| 692 | + | name: "firefox".to_string(), | |
| 693 | + | installed: Some("1".to_string()), | |
| 694 | + | available: "2".to_string(), | |
| 695 | + | repo: "terra".to_string(), | |
| 696 | + | }], | |
| 697 | + | bulk: 4, | |
| 698 | + | repos: vec!["fedora".to_string()], | |
| 699 | + | }; | |
| 700 | + | assert_eq!(staleness.behind(), 5); | |
| 701 | + | assert!(!staleness.current()); | |
| 702 | + | } | |
| 703 | + | ||
| 539 | 704 | use super::*; | |
| 540 | 705 | ||
| 541 | 706 | // Shape captured from a real /etc/yum.repos.d/terra.repo, cut to the keys |