Skip to main content

max / alloy

Say how long since the last update check, and what is waiting The local half of the update position committed in 65e6bc5. Alloy never checks on its own, so a machine can sit behind indefinitely with nothing to say so. What it can do without contacting anything is subtract: it now writes down when a check succeeded and reports the age of that timestamp. The line reports how long since you looked and nothing about the world. "never checked" is kept distinct from "checked a long time ago", a clock that moved backwards renders as unreadable rather than as today, and a test asserts the string cannot be read as a vulnerability scan, which is the property the whole module is written to hold. Only a successful check is recorded. A failed one learned nothing about how far behind the machine is, so writing it down would let a month of broken network read as a month of currency. The system tab gains the headline: "3 updates available, 2 staged for reboot". The two counts stay separate because they are different facts, packages a rebuild would move forward against deployments already built and waiting on a reboot, and a machine can carry both. An unchecked machine says nothing about updates at all rather than "0 available", which would be a claim about the world nothing has looked at. State lives at $XDG_STATE_HOME/alloy/last-update-check, falling back to ~/.local/state. A failed write is silent: the timestamp is a courtesy, and a console that interrupted a successful check to complain about its own state directory would be reporting the wrong thing. 695 tests pass, clippy clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 16:29 UTC
Signed with PGP, not checked
Commit: 0dbf1f2dfbf3d1a5864c0aebbaa22adced7f0ad4
Parent: 670968e
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