Show what has actually been installed

Automatic updates leave no other trace. The Flatpak that sat here as "1 update
available" installed itself at 00:14 this morning and nothing on the machine
would have said so.

Both sources are asked in their own machine-readable form and merged on time, so
the answer reads as one history rather than two lists to interleave by eye.

Two parsing traps worth recording next to the code. flatpak's --json prints
timestamps as "Aug 20 08:07:46" with no year in them, so the year is inferred
and a date that would land in the future is read as last year's. And dnf5's
start_time is epoch UTC while its own history table prints that same value as
though it were local -- checked against rpm, and the local rendering here is the
correct one.

The contract asserts entries are newest first, that none is dated in the future,
and that both sources parse; it was verified to fail by breaking the year
inference so every flatpak entry landed tomorrow.

Loaded on demand rather than with the page, because it reads both full
transaction logs.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-20 11:30:31 -04:00
parent de45f205ad
commit 4cbab01ae2
4 changed files with 216 additions and 0 deletions
@@ -20,6 +20,7 @@ updater does, instead of pretending the number is live.
from __future__ import annotations
import datetime
import json
import os
import re
@@ -291,8 +292,90 @@ def _refusal(result: subprocess.CompletedProcess, fallback: str) -> str:
return last[:200]
def flatpak_time(text: str) -> int:
"""flatpak prints "Aug 20 08:07:46" -- a time with no year in it.
Assumed to be this year, and rolled back one if that would put it in the
future, which is the only reading that makes sense for a history. Anything
unparseable sorts last rather than pretending to be the epoch, which would
put it at the top of a newest-first list.
"""
if not text.strip():
return 0
now = datetime.datetime.now()
for year in (now.year, now.year - 1):
try:
when = datetime.datetime.strptime(f"{year} {text.strip()}", "%Y %b %d %H:%M:%S")
except ValueError:
return 0
if when <= now + datetime.timedelta(days=1):
return int(when.timestamp())
return 0
def history(limit: int = 25) -> list[dict]:
"""What has actually been installed, newest first.
Both sources are asked in their own machine-readable form rather than by
parsing their tables: dnf5 prints JSON, and flatpak takes --json. The two
are merged on time so the answer reads as one history rather than as two
lists a person has to interleave themselves.
Automatic updates are the reason this is worth showing. Something that
installs itself overnight leaves no other trace a person would notice.
"""
entries: list[dict] = []
dnf = run(["dnf5", "history", "list", "--json"], timeout=30.0)
if dnf.returncode == 0:
try:
for row in json.loads(dnf.stdout or "[]"):
command = str(row.get("command_line") or "").strip()
entries.append({
"source": "dnf",
"at": int(row.get("start_time") or 0),
# The full argv is noise; what was done is the useful part.
"summary": command.split("/")[-1] if command else "transaction",
"count": int(row.get("altered_count") or 0),
"ok": str(row.get("status") or "") == "Ok",
})
except (json.JSONDecodeError, TypeError, ValueError):
pass
flatpak = run(["flatpak", "history", "--json"], timeout=30.0)
if flatpak.returncode == 0:
try:
rows = json.loads(flatpak.stdout or "[]")
except json.JSONDecodeError:
rows = []
for row in rows if isinstance(rows, list) else []:
application = str(row.get("application") or "").strip()
change = str(row.get("change") or "").strip()
if not application:
continue
# "deploy update", "deploy install", "uninstall" -- the verb is a
# word inside the change rather than the whole of it.
verb = next((word for word in ("update", "install", "uninstall")
if word in change), "")
if not verb:
continue
entries.append({
"source": "flatpak",
"at": flatpak_time(str(row.get("time") or "")),
"summary": verb + " " + application,
"count": 1,
"ok": True,
})
entries.sort(key=lambda entry: entry["at"], reverse=True)
return entries[:limit]
def main(arguments: list[str]) -> int:
try:
if arguments == ["history"]:
print(json.dumps({"entries": history(), "error": ""}, separators=(",", ":")))
return 0
if arguments == ["snapshot"]:
print(json.dumps(snapshot(), separators=(",", ":")))
return 0