diff --git a/booth/app.py b/booth/app.py index 6396980..ffe3806 100644 --- a/booth/app.py +++ b/booth/app.py @@ -141,6 +141,12 @@ from booth.inline import ( # noqa: E402 has_placeholders, place as place_asks, ) +from booth.manifest import ( # noqa: E402 + MANIFEST_FILE, + SERVICE_HANDLE, + read_manifest, + write_manifest, +) from booth.links import ( # noqa: E402 LINK_LOCK, LINKS_FILE, @@ -273,6 +279,11 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> # — which is why marks live in one file per booth rather than a sidecar # per mark. This loop runs on every index page load. marks = marks_for(child) + # The booth's own announcement — who posted it and why. One more small + # read per booth, beside the marks read already here, and `read_manifest` + # cannot raise for the same reason `marks_for` must not: this loop runs + # over EVERY booth on every index page load. + manifest = read_manifest(child) kinds = {"image": 0, "video": 0, "audio": 0, "other": 0} thumb_url = None thumb_blurred = False @@ -290,6 +301,7 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> { "name": child.name, "name_url": quote(child.name, safe=""), + "manifest": manifest, "count": len(items), "kinds": kinds, "thumb_url": thumb_url, @@ -748,6 +760,10 @@ def create_app( }, "booth_marks": marks_for_target(marks, None), "uploaded": (booth / UPLOAD_MARKER).exists(), + # The same provenance line the index card carries. Deliberate: + # a booth URL handed to the operator lands HERE, never on the + # index, and job 5 is "operator, look at this". + "manifest": read_manifest(booth), "expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)), }, ) @@ -1086,9 +1102,17 @@ def create_app( dest = data_dir / booth_id dest.mkdir(parents=True) (dest / UPLOAD_MARKER).write_text("") # stamp as an upload (dotfile, not listed) + # A booth the SERVICE made says so, rather than being exempted from the + # unannounced marker. One rule instead of an exemption list, and the + # handle is true: nobody's agent posted this, the browser did. + write_manifest(dest, SERVICE_HANDLE, title=booth_id, + why="browser upload, for pickup") total = 0 - used: set = {UPLOAD_MARKER} + # Both markers are belt-and-braces: `safe_upload_name` strips leading + # dots, so an uploaded file can never be named either of them. Listed + # anyway so the set says what the directory already contains. + used: set = {UPLOAD_MARKER, MANIFEST_FILE} try: for i, f in enumerate(files): name = _dedupe_name(safe_upload_name(f.filename, f"file-{i + 1}"), used) diff --git a/booth/manifest.py b/booth/manifest.py new file mode 100644 index 0000000..3b83ef3 --- /dev/null +++ b/booth/manifest.py @@ -0,0 +1,164 @@ +"""A booth's own announcement — who posted it, and why. + +U5. The index card used to show a name, an item count and a countdown, and +nothing the poster chose. An agent with something to show therefore had no way +to make the booth say "look at this" and posted a URL to the link board +instead — which is why 145 of that board's 210 rows (69%) ended up pointing at +booths that had already been swept. The board was absorbing a job it was never +shaped for. This is the shape. + + .booth.json -> {"handle": ..., "title": ..., "why": ..., "created": ...} + +⚠ STDLIB ONLY, and it imports nothing from `booth.*` either. + +`scripts/booth` — the CLI every fleet session uses — imports this module +directly under the system `python3` with no venv, through a `python3 -c` +heredoc no AST extractor can see. A single third-party import here breaks +`booth new` and `booth add` on every host, and the failure surfaces in an +agent's session rather than in ours. The ban extends to sibling `booth` modules: +importing `marks` to reuse its atomic write would drag marks' own import list +into this one's, so the four-line pattern is copied instead. `test_stdlib_only` +in tests/test_manifest.py is the only thing standing here. + +Contract: docs/contracts/u5_booth_manifest.contract.md. +""" +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +MANIFEST_FILE = ".booth.json" + +# A `why` renders inside a card's sub-line, so it is one line by construction +# rather than by convention — enforced at the WRITE so nothing downstream has to +# remember. The caps are display budgets, not storage limits. +HANDLE_MAX = 64 +TITLE_MAX = 120 +WHY_MAX = 200 + +# The handle a booth created by the service itself carries. A pickup booth and +# the standing link board are made by the Booth, not by an agent, and saying so +# is true rather than manufactured — which is the whole reason there is no +# exemption list. One rule: a booth with no manifest is unannounced. +SERVICE_HANDLE = "booth" + + +@dataclass(frozen=True) +class Manifest: + """One booth's announcement. + + `handle` is an althing agent handle, or `SERVICE_HANDLE` for a booth the + Booth made. `error` is a read-time verdict and is never stored. + """ + + handle: str + title: str + why: str + created: str + error: str | None = None + + +def _one_line(value, limit: int) -> str: + if not isinstance(value, str): + return "" + return " ".join(value.split())[:limit] + + +def _now() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def read_manifest(booth: Path) -> Manifest | None: + """This booth's announcement, or None if it never made one. + + LENIENT, AND IT NEVER RAISES (INV-2). `list_booths` calls this once per + booth on every index page load, so a read that can raise is a service-wide + outage wearing a single-booth bug's clothes. That is not hypothetical: a + poisoned `.marks.json` did exactly that to `/` and `/healthz` across all 25 + live booths, and the fix shipped in v0.2.2. Same posture, applied before the + same mistake rather than after it. + + Absent -> None. Present but unreadable -> a Manifest carrying `error`, so a + card can say `unreadable` instead of quietly showing the same thing as a + booth that never announced (INV-5). Folding the two together would hide the + one case somebody has to go and fix. + + Only `handle` is required. A hand-written manifest is a supported input — + the file is plain JSON in a folder the operator owns, and half the point of + the Booth is that a booth is just a directory. + """ + booth = Path(booth) + path = booth / MANIFEST_FILE + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + return None + except (OSError, UnicodeDecodeError) as exc: + return _broken(booth, f"cannot be read: {exc}") + if not text.strip(): + return _broken(booth, "is empty") + try: + raw = json.loads(text) + except ValueError as exc: + return _broken(booth, f"is not valid JSON: {exc}") + if not isinstance(raw, dict): + return _broken(booth, "is not a JSON object") + + handle = _one_line(raw.get("handle"), HANDLE_MAX) + if not handle: + return _broken(booth, "names no handle") + return Manifest( + handle=handle, + title=_one_line(raw.get("title"), TITLE_MAX) or booth.name, + why=_one_line(raw.get("why"), WHY_MAX), + created=_one_line(raw.get("created"), 64), + ) + + +def _broken(booth: Path, reason: str) -> Manifest: + return Manifest(handle="", title=booth.name, why="", created="", + error=f"{MANIFEST_FILE} {reason}") + + +def write_manifest(booth: Path, handle: str, *, title: str = "", + why: str = "") -> Manifest: + """Announce a booth, atomically (CLAUDE.md invariant 5). + + Temp file + `os.replace`, because the CLI writes this in one process while + the browser reads it in another — a reader must never see a half-written + document. The temp file is itself a dotfile (`.booth.json.tmp`), so no + listing, gallery or zip can see it mid-write either. + + RE-ANNOUNCING PRESERVES `created` (INV-3). It is when the booth APPEARED, + and saying something more about it later is not a second appearance — + `booth add` on an existing booth is the common case, where the poster drops + the second batch and sharpens the why. A `created` that cannot be read back + is replaced rather than guessed at: a stamp that is silently wrong is worse + than one that is silently new. + """ + booth = Path(booth) + booth.mkdir(parents=True, exist_ok=True) + prior = read_manifest(booth) + created = prior.created if prior and not prior.error and prior.created else _now() + + record = Manifest( + handle=_one_line(handle, HANDLE_MAX) or SERVICE_HANDLE, + title=_one_line(title, TITLE_MAX) or booth.name, + why=_one_line(why, WHY_MAX), + created=created, + ) + path = booth / MANIFEST_FILE + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text( + json.dumps( + {"handle": record.handle, "title": record.title, + "why": record.why, "created": record.created}, + ensure_ascii=False, indent=2, + ) + "\n", + encoding="utf-8", + ) + os.replace(tmp, path) + return record diff --git a/booth/templates/_provenance.html b/booth/templates/_provenance.html new file mode 100644 index 0000000..c702093 --- /dev/null +++ b/booth/templates/_provenance.html @@ -0,0 +1,17 @@ +{# THE ANNOUNCEMENT — who posted this booth and why. Defined ONCE and called + from both index lanes and the booth page header: the kept lane is a separate + block, and patching only the ephemeral one would leave the durable, + most-looked-at boards with exactly the defect this closes. + + Four states, and `unannounced` is distinct from `unreadable` on purpose — + folding "cannot be read" into "never said" hides the one case somebody has to + go and fix. The classes are the test hooks; the words are for the operator. #} +{% macro provenance(m) -%} + {% if m is none %} +