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 %} +
unannounced
+ {% elif m.error %} +
unreadable
+ {% else %} +
{{ m.handle }}{% if m.why %} · {{ m.why }}{% endif %}
+ {% endif %} +{%- endmacro %} diff --git a/booth/templates/base.html b/booth/templates/base.html index 55a52c4..d0f9b3c 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -255,6 +255,22 @@ .card .name:hover{text-decoration:none;color:var(--aus-bright-cyan)} .card .sub{color:var(--fg-3);font-size:.72rem;font-family:var(--font-mono);letter-spacing:.03em;margin-top:.3rem} + /* THE ANNOUNCEMENT — who posted this booth and why (U5). Same size and + rhythm as .sub above it, because it is the same class of information: a + second line of card metadata, not a heading. The handle carries the only + colour, so a scan down the index reads as a column of posters. */ + .prov{margin-top:.28rem;font-size:.72rem;font-family:var(--font-mono); + letter-spacing:.03em;color:var(--fg-3);line-height:1.45; + overflow-wrap:anywhere} + .prov-who{color:var(--fg-2)} + .prov-why{color:var(--fg-3)} + /* Quiet on purpose. 26 booths arrived before this convention existed and + rsync keeps making more, so the marker has to be visible-if-you-look and + never a badge shouting 26 times. `unreadable` gets the warning tint + because, unlike `unannounced`, it is something somebody has to fix. */ + .prov-none{color:var(--fg-muted);font-style:italic} + .prov-broken{color:var(--aus-bright-yellow,#e8c547);font-style:italic;cursor:help} + .wipe{position:absolute;top:.5rem;right:.5rem;margin:0} /* ★ keep, mirroring .wipe on the other shoulder of the card. Same hover-to-reveal language as .release in the kept lane. */ @@ -408,6 +424,9 @@ .boothhead h1{margin:0;font-family:var(--font-display);font-weight:600;font-size:1.5rem; letter-spacing:-.01em;word-break:break-word;flex:1 1 auto;color:var(--fg-0)} .boothhead .sub{color:var(--fg-3);font-size:.74rem;font-family:var(--font-mono);letter-spacing:.06em} + /* Its own row under the title, not another chip in the flex line — a `why` + can run to WHY_MAX and would otherwise shove the zip link around. */ + .boothhead .prov{flex:0 0 100%;margin-top:-.35rem} .wipe-lg{position:static} /* red-outline danger button — legible on the dark canvas, fills on hover */ .wipe-lg button{width:auto;height:auto;padding:.42rem .85rem;border-radius:var(--radius-md); diff --git a/booth/templates/booth.html b/booth/templates/booth.html index bf96406..e97f3d0 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "_provenance.html" import provenance %} {# The blur toggle, defined ONCE. There are three item branches in this file (doc / media / other) and the first cut of this feature patched only one of them, so docs rendered with no control at all. A macro makes "patched two of @@ -58,6 +59,7 @@

{{ name }}

{% if uploaded %}⬆ pickup {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{% if marks_open %}{{ marks_open }} open · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %} {% if items %}⬇ zip{% endif %} + {{ provenance(manifest) }} {# A durable multi-writer board gets no one-click wipe — same rule as the kept lane on the index. Remove rows with the per-row ×, or release the board from the index and wipe it from there. #} diff --git a/booth/templates/index.html b/booth/templates/index.html index ae43c4a..56482e2 100644 --- a/booth/templates/index.html +++ b/booth/templates/index.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% from "_provenance.html" import provenance %} {% block content %}