feat(r2): C4 the Desk — the index triaged by what needs the operator

- list_booths gains open_since (parsed, never compared as text), flags,
  landed_at (content only; a new, differently named clock, INV-5),
  viewed_at, and a four-image preview that keeps blur.
- The index renders needs you / new since you looked / everything else,
  always in that order. Needs you includes unreadable marks, so a damaged
  judgment file cannot hide. Everything else keeps list_booths' order
  rather than stating a second rule. An empty section renders nothing.
- The side column holds live benches (a damaged registry says so),
  bookmarks from BOOTH_LINKS_BOARD with booth URLs left out (capped at 8),
  and the pickup form.
- test_booth's kept-lane test is rewritten as the contract declared: kept
  is a fact on each row, not a lane.

Two of the new tests were VACUOUS on their first draft, and mutation-
checking caught both. The clocks test used a future t0, so a hand-set
marker outranked every real write. The look-then-judge test followed the
flag's 303, and the resulting GET recorded a fresh look. Both are fixed
and now go red under their mutation.
This commit is contained in:
vh
2026-09-23 08:45:54 -07:00
parent b9750d221a
commit ce27b06f32
6 changed files with 545 additions and 162 deletions
+111 -7
View File
@@ -41,11 +41,13 @@ import os
import re
import secrets
import shutil
import stat
import tempfile
import time
import zipfile
from contextlib import asynccontextmanager
from dataclasses import replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Sequence
from urllib.parse import quote, unquote
@@ -254,6 +256,56 @@ def _newest_mtime(path: Path) -> float:
return newest
def _content_mtime(path: Path) -> float:
"""`landed_at` (R2 C4): the newest mtime among the booth's CONTENT — regular
files with no dot-component in their path. Deliberately NOT `_newest_mtime`
(INV-5 of r2): a mark, a view, a blur or a keep is activity, never new
content, so none of them may make a booth read as newly landed.
Files only, never directories: creating `.viewed` bumps the booth
directory's own mtime, and counting that would make the first look at a
booth look like a delivery. An empty booth landed at 0.0. Unknowable reads
as NOW, the posture `_newest_mtime` takes and for a milder reason here: a
booth we cannot read is shown as new rather than hidden as old.
"""
newest = 0.0
try:
for p in path.rglob("*"):
rel = p.relative_to(path)
if any(part.startswith(".") for part in rel.parts):
continue
try:
st = p.stat()
except FileNotFoundError:
continue
if stat.S_ISREG(st.st_mode) and st.st_mtime > newest:
newest = st.st_mtime
except OSError:
return time.time()
return newest
def _viewed_at(path: Path) -> float | None:
"""The mtime of the booth's `.viewed` marker (U4), or None if it has never
been looked at. `lstat`, like `is_kept`: a planted symlink is read as the
marker it claims to be, never followed."""
try:
return os.lstat(path / VIEW_MARKER).st_mtime
except OSError:
return None
def _stamp(created: str) -> datetime | None:
"""A mark's `created` as an aware datetime, or None when it will not parse.
Strings are never compared: two ISO stamps with different offsets sort
wrong as text. A naive stamp is read as UTC."""
try:
dt = datetime.fromisoformat(created)
except (TypeError, ValueError):
return None
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def booth_age_seconds(path: Path, now: float | None = None) -> float:
now = time.time() if now is None else now
return now - _newest_mtime(path)
@@ -384,6 +436,9 @@ def wants_json(accept: str | None) -> bool:
return False
# The Desk shows this many bookmarks and links to the board for the rest.
BOOKMARKS_SHOWN = 8
HOLD_UNREADABLE = "unreadable"
HOLD_OPEN = "open"
@@ -529,6 +584,10 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
# rather than re-opening .blurred here.
thumb_blurred = it.blurred
mtime = _newest_mtime(child)
# R2 C4: the oldest question still owed an answer, PARSED. Unparseable
# stamps are left out, so a booth whose every open pick is unparseable
# has no `open_since` and sorts after every booth that has one.
stamps = [st for st in (_stamp(m.created) for m in open_marks(marks)) if st]
booths.append(
{
"name": child.name,
@@ -555,6 +614,17 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
"hold": hold,
"expires_in": max(0.0, ttl_seconds - (now - mtime)),
"mtime": mtime,
# ---- R2 C4, the Desk. All from the pass above; no second read.
"open_since": min(stamps) if stamps else None,
"flags": sum(1 for m in marks if m.shape == "flag"),
# Two clocks, named apart (INV-5): `mtime` is activity,
# `landed_at` is content. "New since you looked" reads only the
# second, so a flag or a view never makes a booth look new.
"landed_at": _content_mtime(child),
"viewed_at": _viewed_at(child),
# The first four images in item order, as the originals shown
# small. Blurred ones stay blurred, the cover's rule.
"preview": [(it.url, it.blurred) for it in items if it.kind == "image"][:4],
}
)
# Newest first, NAME as the tie-break. Sorting on mtime alone left equal-mtime
@@ -779,6 +849,7 @@ def create_app(
sweep_interval_s: int = 900,
max_upload_mb: float = 1024.0,
max_files: int = 50,
links_board: str = "links",
) -> FastAPI:
data_dir = Path(data_dir).expanduser().resolve()
data_dir.mkdir(parents=True, exist_ok=True)
@@ -889,19 +960,49 @@ def create_app(
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
# Two lanes, split here rather than in the template: kept boards are a
# different KIND of thing from the ephemeral churn — durable, deliberate,
# operator-facing — and burying them in a feed that turns over daily is
# exactly how they would get lost, which is the problem they exist to
# solve. Kept renders first.
"""THE DESK (R2 C4) — the index triaged by what needs the operator.
Three sections, ALWAYS in this order, each booth in exactly one:
needs — an open pick, or marks that cannot be read (somebody has to
fix those, so they must not hide further down). Oldest open
question first; a booth with no parseable stamp after every
booth that has one; name breaks ties.
new — content landed since the booth was last looked at, or never
looked at. Newest content first; name breaks ties.
rest — everything else, in `list_booths`' own order (last activity
first, name as the tie-break). No second rule is stated.
The kept/ephemeral lanes are gone: 23 of 24 live booths were kept, so
the lanes sorted nothing. Kept status still shows on every row.
"""
everything = list_booths(data_dir, ttl_seconds)
needs = [b for b in everything
if b["marks_open"] > 0 or b["hold"] == HOLD_UNREADABLE]
needs.sort(key=lambda b: ((0, b["open_since"].timestamp())
if b["open_since"] else (1, 0.0), b["name"]))
in_needs = {b["name"] for b in needs}
new = [b for b in everything if b["name"] not in in_needs
and (b["viewed_at"] is None or b["landed_at"] > b["viewed_at"])]
new.sort(key=lambda b: (-b["landed_at"], b["name"]))
in_new = {b["name"] for b in new}
rest = [b for b in everything
if b["name"] not in in_needs and b["name"] not in in_new]
benches, benches_error = read_benches(data_dir)
board = data_dir / links_board
bookmarks = [row for row in _board_rows(board)
if booth_target(row["url"]) is None] if board.is_dir() else []
return templates.TemplateResponse(
request,
"index.html",
{
**base_ctx,
"kept": [b for b in everything if b["kept"]],
"booths": [b for b in everything if not b["kept"]],
"needs": needs,
"new": new,
"rest": rest,
"benches": [b for b in benches if b.state != "retired"],
"benches_error": benches_error,
"bookmarks": bookmarks[:BOOKMARKS_SHOWN],
"bookmarks_total": len(bookmarks),
"board_url": f"/b/{quote(links_board, safe='')}/",
},
)
@@ -1875,6 +1976,9 @@ def _from_env() -> FastAPI:
sweep_interval_s=interval,
max_upload_mb=max_mb,
max_files=max_n,
# The board the CLI's `booth link` writes (scripts/booth reads the same
# variable), so the Desk's bookmarks come from where they are written.
links_board=os.environ.get("BOOTH_LINKS_BOARD", "links"),
)