feat(manifest): U5 — booths that say who posted them and why
The index card showed 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.
Each booth carries `.booth.json` — {handle, title, why, created} — written
by the CLI from $ALTHING_HANDLE, and the provenance line renders on both
index lanes and on the booth page header.
WHAT IS WHERE
- booth/manifest.py, stdlib-only and importing nothing from booth.* either:
scripts/booth imports it under the system python3 with no venv, and a
cross-import between two stdlib-only modules is a second way for that
invariant to break. It joins the shared test_stdlib_only list and keeps
a stricter copy of its own.
- The read is lenient and cannot raise. list_booths touches every booth on
every index load, so a manifest that cannot be parsed costs that booth's
provenance and nothing else. That is the v0.2.2 lesson applied before the
same mistake rather than after it.
- Absent and damaged render differently — `unannounced` and `unreadable`.
Folding "cannot be read" into "never said" would hide the one case
somebody has to go and fix.
- Re-announcing preserves `created`. A second `booth add` sharpening the
why is not a second appearance of the booth.
- The write is atomic (invariant 5); the temp file is itself a dotfile, so
no listing can see it mid-write.
THREE OPERATOR CALLS, 2026-09-22
Flags on the existing new/add verbs rather than a separate `announce` verb
(a second step is the step that gets forgotten, which is the rot's own
mechanism). Unannounced booths get a quiet marker rather than nothing — the
convention is only adoptable if the gap is visible. U5 adds provenance only
and does NOT add a second index ordering keyed on announcement time; that
is a different surface needing its own stated rule, parked for v1.1.
NO EXEMPTION LIST
A pickup booth and the standing link board are created by the service, so
they announce themselves with handle `booth`, which is true rather than
manufactured. One rule — a booth with no manifest is unannounced — instead
of a growing set of special cases.
ALSO
tests/test_booth.py's keep/release assertion was slicing the page on the
bare word `boothhead`, which has lived in the stylesheet far longer than
the assertion has; it was reading CSS and passing on luck, and went red the
first time a new rule landed above the old one. Same assertion, aimed at
the markup. A U5 test had the mirror-image bug: pytest derives tmp_path
from the test name and the index renders data_dir, so a test named
`test_an_unannounced_booth_says_so` put the needle in the haystack itself
and passed against a template that did not yet exist.
310 tests (304 before this unit's CLI half). Live service restarted, 26/26
booth pages verified 200, end-to-end smoke through the real CLI.
NOT TAGGED. The cold contract-review panel is still in flight and the
code-review and bug-hunt gates have not run. Tagging with a gate
outstanding is what made v0.2.0 premature.
This commit is contained in:
+25
-1
@@ -141,6 +141,12 @@ from booth.inline import ( # noqa: E402
|
|||||||
has_placeholders,
|
has_placeholders,
|
||||||
place as place_asks,
|
place as place_asks,
|
||||||
)
|
)
|
||||||
|
from booth.manifest import ( # noqa: E402
|
||||||
|
MANIFEST_FILE,
|
||||||
|
SERVICE_HANDLE,
|
||||||
|
read_manifest,
|
||||||
|
write_manifest,
|
||||||
|
)
|
||||||
from booth.links import ( # noqa: E402
|
from booth.links import ( # noqa: E402
|
||||||
LINK_LOCK,
|
LINK_LOCK,
|
||||||
LINKS_FILE,
|
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
|
# — which is why marks live in one file per booth rather than a sidecar
|
||||||
# per mark. This loop runs on every index page load.
|
# per mark. This loop runs on every index page load.
|
||||||
marks = marks_for(child)
|
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}
|
kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
|
||||||
thumb_url = None
|
thumb_url = None
|
||||||
thumb_blurred = False
|
thumb_blurred = False
|
||||||
@@ -290,6 +301,7 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
|
|||||||
{
|
{
|
||||||
"name": child.name,
|
"name": child.name,
|
||||||
"name_url": quote(child.name, safe=""),
|
"name_url": quote(child.name, safe=""),
|
||||||
|
"manifest": manifest,
|
||||||
"count": len(items),
|
"count": len(items),
|
||||||
"kinds": kinds,
|
"kinds": kinds,
|
||||||
"thumb_url": thumb_url,
|
"thumb_url": thumb_url,
|
||||||
@@ -748,6 +760,10 @@ def create_app(
|
|||||||
},
|
},
|
||||||
"booth_marks": marks_for_target(marks, None),
|
"booth_marks": marks_for_target(marks, None),
|
||||||
"uploaded": (booth / UPLOAD_MARKER).exists(),
|
"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)),
|
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1086,9 +1102,17 @@ def create_app(
|
|||||||
dest = data_dir / booth_id
|
dest = data_dir / booth_id
|
||||||
dest.mkdir(parents=True)
|
dest.mkdir(parents=True)
|
||||||
(dest / UPLOAD_MARKER).write_text("") # stamp as an upload (dotfile, not listed)
|
(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
|
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:
|
try:
|
||||||
for i, f in enumerate(files):
|
for i, f in enumerate(files):
|
||||||
name = _dedupe_name(safe_upload_name(f.filename, f"file-{i + 1}"), used)
|
name = _dedupe_name(safe_upload_name(f.filename, f"file-{i + 1}"), used)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 %}
|
||||||
|
<div class="prov prov-none">unannounced</div>
|
||||||
|
{% elif m.error %}
|
||||||
|
<div class="prov prov-broken" title="{{ m.error }}">unreadable</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="prov"><span class="prov-who">{{ m.handle }}</span>{% if m.why %} · <span class="prov-why">{{ m.why }}</span>{% endif %}</div>
|
||||||
|
{% endif %}
|
||||||
|
{%- endmacro %}
|
||||||
@@ -255,6 +255,22 @@
|
|||||||
.card .name:hover{text-decoration:none;color:var(--aus-bright-cyan)}
|
.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}
|
.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}
|
.wipe{position:absolute;top:.5rem;right:.5rem;margin:0}
|
||||||
/* ★ keep, mirroring .wipe on the other shoulder of the card. Same
|
/* ★ keep, mirroring .wipe on the other shoulder of the card. Same
|
||||||
hover-to-reveal language as .release in the kept lane. */
|
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;
|
.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)}
|
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}
|
.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}
|
.wipe-lg{position:static}
|
||||||
/* red-outline danger button — legible on the dark canvas, fills on hover */
|
/* 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);
|
.wipe-lg button{width:auto;height:auto;padding:.42rem .85rem;border-radius:var(--radius-md);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "_provenance.html" import provenance %}
|
||||||
{# The blur toggle, defined ONCE. There are three item branches in this file
|
{# 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
|
(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
|
them, so docs rendered with no control at all. A macro makes "patched two of
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
<h1>{{ name }}</h1>
|
<h1>{{ name }}</h1>
|
||||||
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% 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 %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
|
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% 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 %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
|
||||||
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
|
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
|
||||||
|
{{ provenance(manifest) }}
|
||||||
{# A durable multi-writer board gets no one-click wipe — same rule as the
|
{# 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
|
kept lane on the index. Remove rows with the per-row ×, or release the
|
||||||
board from the index and wipe it from there. #}
|
board from the index and wipe it from there. #}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% from "_provenance.html" import provenance %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
|
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
|
||||||
<label class="drop" for="booth-files">
|
<label class="drop" for="booth-files">
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
<div class="meta">
|
<div class="meta">
|
||||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · kept · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · kept · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||||
|
{{ provenance(b.manifest) }}
|
||||||
</div>
|
</div>
|
||||||
{# There IS a × here now (operator, 2026-09-21). The old rule was
|
{# There IS a × here now (operator, 2026-09-21). The old rule was
|
||||||
release-then-find-it-in-the-other-lane, on the theory that two
|
release-then-find-it-in-the-other-lane, on the theory that two
|
||||||
@@ -110,6 +112,7 @@
|
|||||||
<div class="meta">
|
<div class="meta">
|
||||||
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
|
||||||
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
|
||||||
|
{{ provenance(b.manifest) }}
|
||||||
</div>
|
</div>
|
||||||
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb
|
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb
|
||||||
both predate this button; until 2026-09-19 the UI could only RELEASE a
|
both predate this button; until 2026-09-19 the UI could only RELEASE a
|
||||||
|
|||||||
+12
-1
@@ -26,7 +26,18 @@ _As of 2026-09-22:_
|
|||||||
the deploy. U1 `ce598b3`; U2 `c7f9437` released as `v0.2.0`, then `5e41108` as
|
the deploy. U1 `ce598b3`; U2 `c7f9437` released as `v0.2.0`, then `5e41108` as
|
||||||
`v0.2.1` (four contract-panel findings), then `v0.2.2` carrying the
|
`v0.2.1` (four contract-panel findings), then `v0.2.2` carrying the
|
||||||
**bug-hunt panel's** nine (below).
|
**bug-hunt panel's** nine (below).
|
||||||
- **U5 is the next unit** (operator, 2026-09-21): **self-announcing booths.**
|
- **U5 is IMPLEMENTED and unreleased** as of 2026-09-22. `booth/manifest.py`
|
||||||
|
(stdlib-only, INV-1), `.booth.json` per booth, the provenance line on both
|
||||||
|
index lanes and the booth page header, `--why` / `--title` on `booth new` and
|
||||||
|
`booth add`, and the link board + pickup booths announcing themselves as the
|
||||||
|
service's own. 310 tests, live service restarted, 26/26 booth pages verified
|
||||||
|
200 and all 26 rendering `unannounced`. **Deliberately NOT tagged yet**: the
|
||||||
|
cold `/heid-contract-review` panel is still in flight and the code-review and
|
||||||
|
bug-hunt gates have not run. That ordering is the 2026-09-21 lesson applied —
|
||||||
|
a release whose gate is outstanding is premature even when the tier is right.
|
||||||
|
Contract: `docs/contracts/u5_booth_manifest.contract.md` (carries its own
|
||||||
|
seam-review section).
|
||||||
|
- **U5's original framing** (operator, 2026-09-21): **self-announcing booths.**
|
||||||
`.booth.json` carrying `{handle, title, why, created}`, written by the CLI from
|
`.booth.json` carrying `{handle, title, why, created}`, written by the CLI from
|
||||||
`$ALTHING_HANDLE`; the index card gains provenance and a one-line purpose, and
|
`$ALTHING_HANDLE`; the index card gains provenance and a one-line purpose, and
|
||||||
the index becomes the "what landed" feed the link board was being used as. It
|
the index becomes the "what landed" feed the link board was being used as. It
|
||||||
|
|||||||
+75
-3
@@ -3,8 +3,10 @@
|
|||||||
# folder under $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL
|
# folder under $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL
|
||||||
# back.
|
# back.
|
||||||
#
|
#
|
||||||
# booth new <name> make an empty booth, print its URL
|
# booth new <name> [--why W] [--title T]
|
||||||
# booth add <name> <file>... copy files into a booth (creates it), print URL
|
# make an empty booth, print its URL
|
||||||
|
# booth add <name> <file>... [--why W] [--title T]
|
||||||
|
# copy files into a booth (creates it), print URL
|
||||||
# booth url <name> print a booth's URL
|
# booth url <name> print a booth's URL
|
||||||
# booth ls list booths (kept ones marked ★)
|
# booth ls list booths (kept ones marked ★)
|
||||||
# booth rm <name> wipe a booth now (TTL would eventually anyway)
|
# booth rm <name> wipe a booth now (TTL would eventually anyway)
|
||||||
@@ -75,6 +77,18 @@
|
|||||||
# URLs that then drown in terminal scrollback. They go on a standing kept board
|
# URLs that then drown in terminal scrollback. They go on a standing kept board
|
||||||
# instead, with provenance, so they outlive the session that produced them.
|
# instead, with provenance, so they outlive the session that produced them.
|
||||||
#
|
#
|
||||||
|
# ANNOUNCE YOUR BOOTH. `--why` is one line saying what the operator is looking
|
||||||
|
# at and why he should care; it lands on the index card and on the booth page
|
||||||
|
# beside your handle, taken from $ALTHING_HANDLE. It is optional and nothing
|
||||||
|
# breaks without it — but a booth that cannot say what it is has no way to ask
|
||||||
|
# for attention except by posting its URL somewhere, which is exactly how the
|
||||||
|
# link board came to be 69% dead rows. The booth is the place to say it.
|
||||||
|
#
|
||||||
|
# booth add r18-ab out/*.png --why "pick the denoiser, left column is v3"
|
||||||
|
#
|
||||||
|
# Re-announcing (a second `new` or `add` on the same booth) updates the why and
|
||||||
|
# KEEPS the original creation stamp: the booth appeared once.
|
||||||
|
#
|
||||||
# On a host that is NOT nh3-dev, rsync into the data dir instead, e.g.:
|
# On a host that is NOT nh3-dev, rsync into the data dir instead, e.g.:
|
||||||
# rsync -a ./out/ nh3-dev:booth-data/my-run/
|
# rsync -a ./out/ nh3-dev:booth-data/my-run/
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -85,23 +99,76 @@ KEEP=".forever" # must match KEEP_MARKER in b
|
|||||||
BLUR=".blurred" # one booth-relative item path per line; see `blur` below
|
BLUR=".blurred" # one booth-relative item path per line; see `blur` below
|
||||||
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
|
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
|
||||||
|
|
||||||
|
# `--why` / `--title` for `new` and `add`. Pulled out of "$@" wherever they
|
||||||
|
# appear, so `booth add b *.png --why "..."` and `booth add b --why "..." *.png`
|
||||||
|
# both work — a glob is usually last and a flag usually after it, but nothing
|
||||||
|
# enforces that and a session should not have to care.
|
||||||
|
WHY=""; TITLE=""; ARGS=()
|
||||||
|
strip_announce_flags() {
|
||||||
|
ARGS=()
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--why) [ $# -ge 2 ] || usage; WHY="$2"; shift 2 ;;
|
||||||
|
--title) [ $# -ge 2 ] || usage; TITLE="$2"; shift 2 ;;
|
||||||
|
--why=*) WHY="${1#--why=}"; shift ;;
|
||||||
|
--title=*) TITLE="${1#--title=}"; shift ;;
|
||||||
|
*) ARGS+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Announce a booth. Goes through booth/manifest.py rather than printf-ing JSON
|
||||||
|
# from the shell, because a why containing a quote, a backslash or a newline is
|
||||||
|
# not an edge case — it is a sentence somebody wrote.
|
||||||
|
announce() {
|
||||||
|
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" \
|
||||||
|
BOOTH_ANN_DIR="$1" BOOTH_ANN_HANDLE="$2" \
|
||||||
|
BOOTH_ANN_TITLE="${3:-}" BOOTH_ANN_WHY="${4:-}" python3 -c '
|
||||||
|
import os, pathlib, sys
|
||||||
|
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||||||
|
try:
|
||||||
|
from booth.manifest import write_manifest
|
||||||
|
write_manifest(pathlib.Path(os.environ["BOOTH_ANN_DIR"]),
|
||||||
|
os.environ["BOOTH_ANN_HANDLE"],
|
||||||
|
title=os.environ["BOOTH_ANN_TITLE"],
|
||||||
|
why=os.environ["BOOTH_ANN_WHY"])
|
||||||
|
except Exception as exc:
|
||||||
|
# A booth that could not announce itself is still a booth. Say so on stderr
|
||||||
|
# and carry on: failing `booth add` over its metadata would lose the files
|
||||||
|
# the session just copied, which is a far worse trade.
|
||||||
|
print(f"booth: could not write the announcement: {exc}", file=sys.stderr)
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Who is posting. The same chain `link` uses for its rows, so provenance means
|
||||||
|
# the same thing on the board and on the card.
|
||||||
|
whoami_handle() {
|
||||||
|
echo "${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
|
||||||
|
}
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|asks <name> (deprecated alias for marks)|answer <name> <id> [--wait [SECS]]|marks-import <name>}" >&2
|
echo "usage: booth {new <name> [--why W] [--title T]|add <name> <file>... [--why W] [--title T]|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|asks <name> (deprecated alias for marks)|answer <name> <id> [--wait [SECS]]|marks-import <name>}" >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd="${1:-}"; shift || true
|
cmd="${1:-}"; shift || true
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
new)
|
new)
|
||||||
|
strip_announce_flags "$@"
|
||||||
|
set -- ${ARGS+"${ARGS[@]}"}
|
||||||
[ $# -ge 1 ] || usage
|
[ $# -ge 1 ] || usage
|
||||||
mkdir -p -- "$DATA/$1"
|
mkdir -p -- "$DATA/$1"
|
||||||
|
announce "$DATA/$1" "$(whoami_handle)" "$TITLE" "$WHY"
|
||||||
echo "$URL/b/$1/"
|
echo "$URL/b/$1/"
|
||||||
;;
|
;;
|
||||||
add)
|
add)
|
||||||
|
strip_announce_flags "$@"
|
||||||
|
set -- ${ARGS+"${ARGS[@]}"}
|
||||||
[ $# -ge 2 ] || usage
|
[ $# -ge 2 ] || usage
|
||||||
name="$1"; shift
|
name="$1"; shift
|
||||||
mkdir -p -- "$DATA/$name"
|
mkdir -p -- "$DATA/$name"
|
||||||
cp -- "$@" "$DATA/$name/"
|
cp -- "$@" "$DATA/$name/"
|
||||||
|
announce "$DATA/$name" "$(whoami_handle)" "$TITLE" "$WHY"
|
||||||
echo "$URL/b/$name/"
|
echo "$URL/b/$name/"
|
||||||
;;
|
;;
|
||||||
url)
|
url)
|
||||||
@@ -177,6 +244,11 @@ case "$cmd" in
|
|||||||
board="$DATA/$LINKS_BOARD"
|
board="$DATA/$LINKS_BOARD"
|
||||||
mkdir -p -- "$board"
|
mkdir -p -- "$board"
|
||||||
: > "$board/$KEEP" # the board is durable by definition
|
: > "$board/$KEEP" # the board is durable by definition
|
||||||
|
# The board announces itself as the SERVICE's, not as any one agent's:
|
||||||
|
# seventeen handles post to it, so no handle owns it. Idempotent — a second
|
||||||
|
# link keeps the original creation stamp.
|
||||||
|
announce "$board" "booth" "$LINKS_BOARD" \
|
||||||
|
"the standing link board — every agent session posts here"
|
||||||
# Provenance, because a bare URL is unreadable three days later: who posted
|
# Provenance, because a bare URL is unreadable three days later: who posted
|
||||||
# it, from where, and when.
|
# it, from where, and when.
|
||||||
who="${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
|
who="${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
|
||||||
|
|||||||
+7
-1
@@ -1537,7 +1537,13 @@ def test_booth_page_offers_keep_when_ephemeral_and_release_when_kept(client):
|
|||||||
_png(d / "x.png")
|
_png(d / "x.png")
|
||||||
|
|
||||||
body = c.get("/b/bo/").text
|
body = c.get("/b/bo/").text
|
||||||
assert "☆ keep" in body and "release" not in body.split("boothhead")[1][:900]
|
# Sliced on the ELEMENT, not the bare word: `boothhead` has appeared in the
|
||||||
|
# stylesheet this page carries since long before this assertion, so
|
||||||
|
# `split("boothhead")[1]` was reading CSS and passing on luck. It went red
|
||||||
|
# the first time a new rule landed above the old one (U5's .prov), which is
|
||||||
|
# the only reason anybody noticed. Same assertion, aimed at the markup.
|
||||||
|
head = body.split('class="boothhead"')[1][:900]
|
||||||
|
assert "☆ keep" in body and "release" not in head
|
||||||
|
|
||||||
c.post("/b/bo/keep", data={"next": "/b/bo/"}, follow_redirects=False)
|
c.post("/b/bo/keep", data={"next": "/b/bo/"}, follow_redirects=False)
|
||||||
body = c.get("/b/bo/").text
|
body = c.get("/b/bo/").text
|
||||||
|
|||||||
@@ -119,3 +119,91 @@ def test_answer_on_a_note_id_says_no_such_pick(booth):
|
|||||||
r = run(data, "answer", "b", "note-1")
|
r = run(data, "answer", "b", "note-1")
|
||||||
assert r.returncode == NO_SUCH_PICK
|
assert r.returncode == NO_SUCH_PICK
|
||||||
assert "no such pick" in r.stderr
|
assert "no such pick" in r.stderr
|
||||||
|
|
||||||
|
|
||||||
|
# ---- U5: self-announcing booths ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest(booth_dir):
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
|
||||||
|
from booth.manifest import read_manifest
|
||||||
|
return read_manifest(booth_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_announces_the_booth(tmp_path):
|
||||||
|
"""`$ALTHING_HANDLE` is the whole provenance story: the session already has
|
||||||
|
it, so the booth can say who made it without anybody typing a name."""
|
||||||
|
env = {**os.environ, "ALTHING_HANDLE": "shutter-dev"}
|
||||||
|
r = subprocess.run([str(SCRIPT), "new", "r18-ab", "--why", "pick the winner"],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
env={**env, "BOOTH_DATA_DIR": str(tmp_path),
|
||||||
|
"BOOTH_URL": "http://booth.invalid"})
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
m = _manifest(tmp_path / "r18-ab")
|
||||||
|
assert m.handle == "shutter-dev"
|
||||||
|
assert m.why == "pick the winner"
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_without_a_why_is_still_legal(tmp_path):
|
||||||
|
"""The flags are optional and existing call sites keep working. A booth
|
||||||
|
that says only who made it is still a booth that said something."""
|
||||||
|
r = subprocess.run([str(SCRIPT), "new", "scratch"], capture_output=True,
|
||||||
|
text=True, timeout=30,
|
||||||
|
env={**os.environ, "ALTHING_HANDLE": "booth-dev",
|
||||||
|
"BOOTH_DATA_DIR": str(tmp_path),
|
||||||
|
"BOOTH_URL": "http://booth.invalid"})
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
m = _manifest(tmp_path / "scratch")
|
||||||
|
assert m.handle == "booth-dev" and m.why == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_announces_and_still_copies_the_files(tmp_path):
|
||||||
|
"""`add` is the verb most sessions actually use — it creates the booth AND
|
||||||
|
fills it — so the why has to ride on it or it rides nowhere."""
|
||||||
|
src = tmp_path / "src"
|
||||||
|
src.mkdir()
|
||||||
|
(src / "a.txt").write_text("content")
|
||||||
|
r = subprocess.run([str(SCRIPT), "add", "r18-ab", str(src / "a.txt"),
|
||||||
|
"--why", "second pass", "--title", "R18 A/B"],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
env={**os.environ, "ALTHING_HANDLE": "booth-dev",
|
||||||
|
"BOOTH_DATA_DIR": str(tmp_path),
|
||||||
|
"BOOTH_URL": "http://booth.invalid"})
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
assert (tmp_path / "r18-ab" / "a.txt").read_text() == "content"
|
||||||
|
m = _manifest(tmp_path / "r18-ab")
|
||||||
|
assert m.why == "second pass" and m.title == "R18 A/B"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_re_announcing_keeps_the_original_created(tmp_path):
|
||||||
|
"""The common shape: `new` opens the booth, `add` drops the second batch and
|
||||||
|
sharpens the why. The booth appeared once."""
|
||||||
|
env = {**os.environ, "ALTHING_HANDLE": "booth-dev",
|
||||||
|
"BOOTH_DATA_DIR": str(tmp_path), "BOOTH_URL": "http://booth.invalid"}
|
||||||
|
src = tmp_path / "a.txt"
|
||||||
|
src.write_text("x")
|
||||||
|
subprocess.run([str(SCRIPT), "new", "b", "--why", "first"], check=True,
|
||||||
|
capture_output=True, timeout=30, env=env)
|
||||||
|
first = _manifest(tmp_path / "b").created
|
||||||
|
subprocess.run([str(SCRIPT), "add", "b", str(src), "--why", "sharper"],
|
||||||
|
check=True, capture_output=True, timeout=30, env=env)
|
||||||
|
|
||||||
|
after = _manifest(tmp_path / "b")
|
||||||
|
assert after.created == first
|
||||||
|
assert after.why == "sharper"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_link_board_announces_itself_as_the_booths_own(tmp_path):
|
||||||
|
"""No exemption list. The standing board is made by the service and posted
|
||||||
|
to by seventeen handles, so no single agent owns it — `booth` is the
|
||||||
|
truthful answer, and it keeps the rule to one line."""
|
||||||
|
r = subprocess.run([str(SCRIPT), "link", "http://example.invalid", "a thing"],
|
||||||
|
capture_output=True, text=True, timeout=30,
|
||||||
|
env={**os.environ, "ALTHING_HANDLE": "booth-dev",
|
||||||
|
"BOOTH_DATA_DIR": str(tmp_path),
|
||||||
|
"BOOTH_URL": "http://booth.invalid"})
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
m = _manifest(tmp_path / "links")
|
||||||
|
assert m is not None and m.handle == "booth"
|
||||||
|
assert m.why
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
"""U5 — self-announcing booths.
|
||||||
|
|
||||||
|
A booth carries `.booth.json` saying who posted it and why, and the index card
|
||||||
|
and the booth page render it. Closes job 5 (`Announce`) — the job nobody named,
|
||||||
|
whose absence is the measured cause of 145 dead link rows.
|
||||||
|
|
||||||
|
See docs/contracts/u5_booth_manifest.contract.md.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from booth.manifest import MANIFEST_FILE, Manifest, read_manifest, write_manifest
|
||||||
|
|
||||||
|
|
||||||
|
# ---- slice 1: the record and its storage ------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_announcement_round_trips(tmp_path):
|
||||||
|
b = tmp_path / "r18-ab"
|
||||||
|
b.mkdir()
|
||||||
|
written = write_manifest(b, "booth-dev", why="pick the winning denoiser")
|
||||||
|
|
||||||
|
assert (b / MANIFEST_FILE).is_file()
|
||||||
|
got = read_manifest(b)
|
||||||
|
assert got == written
|
||||||
|
assert got.handle == "booth-dev"
|
||||||
|
assert got.why == "pick the winning denoiser"
|
||||||
|
assert got.error is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_title_falls_back_to_the_directory_name(tmp_path):
|
||||||
|
"""A booth always has a display name. `title` is the one the poster chose
|
||||||
|
when there is one, and the folder name is a perfectly good one when there
|
||||||
|
is not — an empty heading on a card is worse than a plain one."""
|
||||||
|
b = tmp_path / "r18-ab"
|
||||||
|
b.mkdir()
|
||||||
|
assert write_manifest(b, "booth-dev").title == "r18-ab"
|
||||||
|
assert write_manifest(b, "booth-dev", title="R18 A/B").title == "R18 A/B"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_booth_that_never_announced_reads_as_none(tmp_path):
|
||||||
|
"""The normal case for every booth that predates this unit, and for every
|
||||||
|
booth that arrives by rsync — the documented path for any host that is not
|
||||||
|
nh3-dev, which never runs the CLI at all."""
|
||||||
|
b = tmp_path / "quiet"
|
||||||
|
b.mkdir()
|
||||||
|
assert read_manifest(b) is None
|
||||||
|
assert read_manifest(tmp_path / "does-not-exist") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_line_by_construction_not_by_convention(tmp_path):
|
||||||
|
"""`why` renders inside a card's sub-line, so a newline in it would break
|
||||||
|
the card rather than the field. Truncation and newline-stripping happen at
|
||||||
|
the WRITE, so nothing downstream has to remember."""
|
||||||
|
b = tmp_path / "b"
|
||||||
|
b.mkdir()
|
||||||
|
m = write_manifest(b, "booth-dev", why="first line\nsecond line\r\nthird")
|
||||||
|
assert "\n" not in m.why and "\r" not in m.why
|
||||||
|
assert "first line" in m.why and "second line" in m.why
|
||||||
|
|
||||||
|
long = write_manifest(b, "booth-dev", why="x" * 5000)
|
||||||
|
assert len(long.why) <= 200
|
||||||
|
assert len(write_manifest(b, "y" * 500).handle) <= 64
|
||||||
|
assert len(write_manifest(b, "booth-dev", title="t" * 500).title) <= 120
|
||||||
|
|
||||||
|
|
||||||
|
# ---- slice 2: the read cannot raise (INV-2) ---------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"payload",
|
||||||
|
[
|
||||||
|
b"{truncated", # not JSON at all
|
||||||
|
b"[]", # JSON, wrong shape
|
||||||
|
b'"a string"', # JSON, wronger shape
|
||||||
|
b"null",
|
||||||
|
b'{"handle": 7}', # right shape, wrong type
|
||||||
|
b'{"why": "no handle here"}', # the one required field missing
|
||||||
|
b"\xff\xfe not utf-8",
|
||||||
|
b"",
|
||||||
|
],
|
||||||
|
ids=["truncated", "list", "string", "null", "wrong-type", "no-handle",
|
||||||
|
"not-utf8", "empty"],
|
||||||
|
)
|
||||||
|
def test_a_damaged_manifest_never_raises(tmp_path, payload):
|
||||||
|
"""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 a hypothetical — a poisoned `.marks.json` did
|
||||||
|
exactly that to `/` and `/healthz` across all 25 booths, and the fix shipped
|
||||||
|
in v0.2.2. The same reader posture, applied before the same mistake."""
|
||||||
|
b = tmp_path / "b"
|
||||||
|
b.mkdir()
|
||||||
|
(b / MANIFEST_FILE).write_bytes(payload)
|
||||||
|
|
||||||
|
got = read_manifest(b)
|
||||||
|
assert isinstance(got, Manifest)
|
||||||
|
assert got.error, "a damaged manifest read clean"
|
||||||
|
|
||||||
|
|
||||||
|
def test_damaged_is_not_the_same_as_absent(tmp_path):
|
||||||
|
"""INV-5. Silently folding "cannot be read" into "never announced" would
|
||||||
|
hide the one case somebody has to go and fix."""
|
||||||
|
absent = tmp_path / "absent"
|
||||||
|
absent.mkdir()
|
||||||
|
damaged = tmp_path / "damaged"
|
||||||
|
damaged.mkdir()
|
||||||
|
(damaged / MANIFEST_FILE).write_text("{oops")
|
||||||
|
|
||||||
|
assert read_manifest(absent) is None
|
||||||
|
assert read_manifest(damaged).error
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_manifest_the_module_did_not_write_still_reads(tmp_path):
|
||||||
|
"""Hand-written is a supported input: the file is plain JSON in a folder the
|
||||||
|
operator owns, and half the point is that a booth is just a directory. Only
|
||||||
|
`handle` is required; everything else has a default."""
|
||||||
|
b = tmp_path / "b"
|
||||||
|
b.mkdir()
|
||||||
|
(b / MANIFEST_FILE).write_text(json.dumps({"handle": "shutter-dev"}))
|
||||||
|
|
||||||
|
got = read_manifest(b)
|
||||||
|
assert got.handle == "shutter-dev" and got.error is None
|
||||||
|
assert got.title == "b"
|
||||||
|
assert got.why == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---- slice 3: re-announcement (INV-3) ---------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_re_announcing_preserves_created(tmp_path):
|
||||||
|
"""INV-3. `created` is when the booth APPEARED. Saying something more about
|
||||||
|
it later is not a second appearance, and a `booth add` on an existing booth
|
||||||
|
is the common case — the poster adds the second batch and sharpens the why."""
|
||||||
|
b = tmp_path / "b"
|
||||||
|
b.mkdir()
|
||||||
|
first = write_manifest(b, "booth-dev", why="first pass")
|
||||||
|
second = write_manifest(b, "booth-dev", why="second pass, sharper")
|
||||||
|
|
||||||
|
assert second.created == first.created
|
||||||
|
assert second.why == "second pass, sharper"
|
||||||
|
|
||||||
|
|
||||||
|
def test_re_announcing_over_a_damaged_file_does_not_inherit_its_created(tmp_path):
|
||||||
|
"""A `created` that cannot be read back is replaced rather than guessed at.
|
||||||
|
The alternative is a stamp that is silently wrong, which is worse than one
|
||||||
|
that is silently new."""
|
||||||
|
b = tmp_path / "b"
|
||||||
|
b.mkdir()
|
||||||
|
(b / MANIFEST_FILE).write_text("{not json")
|
||||||
|
|
||||||
|
m = write_manifest(b, "booth-dev", why="rescued")
|
||||||
|
assert m.created and m.error is None
|
||||||
|
assert read_manifest(b).why == "rescued"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- slice 4: the write is atomic, and invisible to every listing -----------
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_write_is_atomic(tmp_path):
|
||||||
|
"""INV-5 of CLAUDE.md. The CLI writes this in one process while the browser
|
||||||
|
reads it in another, so a reader must never see a half-written document."""
|
||||||
|
b = tmp_path / "b"
|
||||||
|
b.mkdir()
|
||||||
|
write_manifest(b, "booth-dev", why="x")
|
||||||
|
assert not list(b.glob("*.tmp")), "a temp file survived the write"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_manifest_is_not_an_item(tmp_path):
|
||||||
|
"""The whole integration story: it is a DOTFILE, so the existing
|
||||||
|
`startswith('.')` skip in `booth_items` already keeps it out of tiles,
|
||||||
|
counts and zips. No new exclusion rule anywhere. Asserted rather than
|
||||||
|
assumed, because the claim is load-bearing for the contract's scope."""
|
||||||
|
from booth.app import zip_booth
|
||||||
|
from booth.items import booth_items
|
||||||
|
|
||||||
|
b = tmp_path / "b"
|
||||||
|
b.mkdir()
|
||||||
|
(b / "a.txt").write_text("real content")
|
||||||
|
write_manifest(b, "booth-dev", why="x")
|
||||||
|
|
||||||
|
assert [i.rel for i in booth_items(b)] == ["a.txt"]
|
||||||
|
assert MANIFEST_FILE not in zip_booth(b).decode("latin-1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_announcing_is_activity(tmp_path):
|
||||||
|
"""A manifest is a dotfile but not a `.lock` dotfile, so `_newest_mtime`
|
||||||
|
counts it. Creating or re-announcing a booth resets its TTL, which is right:
|
||||||
|
both are somebody touching it. The lock exemption added in v0.2.2 is for
|
||||||
|
machinery a READ path creates; this is a deliberate write."""
|
||||||
|
from booth.app import booth_age_seconds
|
||||||
|
|
||||||
|
b = tmp_path / "b"
|
||||||
|
b.mkdir()
|
||||||
|
old = 1_000_000_000
|
||||||
|
os.utime(b, (old, old))
|
||||||
|
|
||||||
|
write_manifest(b, "booth-dev", why="look at this")
|
||||||
|
assert booth_age_seconds(b, now=old + 90_000) < 86_400
|
||||||
|
|
||||||
|
|
||||||
|
def test_stdlib_only():
|
||||||
|
"""INV-4, and the reason this module exists separately from anything that
|
||||||
|
imports a third-party package. `scripts/booth` imports it under the system
|
||||||
|
python3 with NO venv, through a `python3 -c` heredoc no AST extractor can
|
||||||
|
see. It must also not import `booth.*`: a cross-import between two
|
||||||
|
stdlib-only modules is a second way for the invariant to break."""
|
||||||
|
src = pathlib.Path(__file__).parent.parent / "booth" / "manifest.py"
|
||||||
|
roots = set()
|
||||||
|
for node in ast.walk(ast.parse(src.read_text())):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
roots.update(a.name.split(".")[0] for a in node.names)
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
||||||
|
roots.add(node.module.split(".")[0])
|
||||||
|
assert not (roots - set(sys.stdlib_module_names)), (
|
||||||
|
f"booth/manifest.py imports outside the stdlib: "
|
||||||
|
f"{sorted(roots - set(sys.stdlib_module_names))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- slice 5: what the operator actually sees -------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from booth.app import create_app
|
||||||
|
return TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False)), tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _booth(data, name, *, kept=False):
|
||||||
|
b = data / name
|
||||||
|
b.mkdir()
|
||||||
|
(b / "a.txt").write_text("content")
|
||||||
|
if kept:
|
||||||
|
(b / ".forever").touch()
|
||||||
|
return b
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kept", [False, True], ids=["ephemeral", "kept"])
|
||||||
|
def test_the_index_card_carries_the_announcement(client, kept):
|
||||||
|
"""BOTH LANES. Kept boards render first and are a separate block in
|
||||||
|
index.html, so patching only the ephemeral lane would leave the 15 kept
|
||||||
|
booths — the durable, most-looked-at ones — with exactly the defect this
|
||||||
|
unit closes. Same lesson as the `blurtoggle` macro: three branches, one
|
||||||
|
definition; here it is two lanes and one rule."""
|
||||||
|
c, data = client
|
||||||
|
b = _booth(data, "r18-ab", kept=kept)
|
||||||
|
write_manifest(b, "booth-dev", why="pick the winning denoiser")
|
||||||
|
|
||||||
|
html = c.get("/").text
|
||||||
|
assert "booth-dev" in html
|
||||||
|
assert "pick the winning denoiser" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kept", [False, True], ids=["ephemeral", "kept"])
|
||||||
|
def test_a_booth_that_never_spoke_up_is_marked(client, kept):
|
||||||
|
"""All 26 live booths are in this state, and rsync keeps making more. The
|
||||||
|
marker is what makes the convention adoptable at all: the link board rotted
|
||||||
|
to 69% precisely because nothing ever showed which rows were dead.
|
||||||
|
|
||||||
|
ASSERTED ON THE CLASS, not on the word, and the test is named around it.
|
||||||
|
`pytest`'s `tmp_path` is derived from the TEST NAME and the index renders
|
||||||
|
`data_dir` in its empty-state hint — so a test called
|
||||||
|
`test_an_unannounced_booth_says_so` put the literal string "unannounced"
|
||||||
|
into the page and passed against a template that did not yet exist. A
|
||||||
|
structural hook cannot be spelled by accident — though it has to be the
|
||||||
|
rendered ELEMENT and not the bare class, since base.html ships a
|
||||||
|
`.prov-none{...}` rule into the very same page."""
|
||||||
|
c, data = client
|
||||||
|
_booth(data, "quiet", kept=kept)
|
||||||
|
|
||||||
|
html = c.get("/").text
|
||||||
|
assert 'class="prov prov-none"' in html
|
||||||
|
assert "unannounced" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_damaged_manifest_reads_differently_from_an_absent_one(client):
|
||||||
|
"""INV-5 on the surface the operator looks at, not just in the reader."""
|
||||||
|
c, data = client
|
||||||
|
b = _booth(data, "damaged")
|
||||||
|
(b / MANIFEST_FILE).write_text("{oops")
|
||||||
|
|
||||||
|
html = c.get("/").text
|
||||||
|
assert 'class="prov prov-broken"' in html
|
||||||
|
assert "unreadable" in html
|
||||||
|
assert c.get("/b/damaged/").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_announced_booth_with_no_why_shows_only_its_handle(client):
|
||||||
|
"""`booth new x` with no --why is legal and common. The card shows who made
|
||||||
|
it and does not invent a purpose or leave a dangling separator."""
|
||||||
|
c, data = client
|
||||||
|
b = _booth(data, "scratch")
|
||||||
|
write_manifest(b, "booth-dev")
|
||||||
|
|
||||||
|
html = c.get("/").text
|
||||||
|
assert "booth-dev" in html
|
||||||
|
assert 'class="prov prov-none"' not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_booth_page_header_carries_it_too(client):
|
||||||
|
"""Deliberate scope, not creep: a booth URL handed to the operator lands
|
||||||
|
HERE, never on the index. Job 5 is 'operator, look at this', so the page he
|
||||||
|
actually opens is where the answer has to be."""
|
||||||
|
c, data = client
|
||||||
|
b = _booth(data, "r18-ab")
|
||||||
|
write_manifest(b, "booth-dev", why="pick the winning denoiser")
|
||||||
|
|
||||||
|
html = c.get("/b/r18-ab/").text
|
||||||
|
assert "booth-dev" in html
|
||||||
|
assert "pick the winning denoiser" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_poisoned_manifest_cannot_take_down_the_index(client):
|
||||||
|
"""The v0.2.2 lesson, asserted for the new reader before it can repeat:
|
||||||
|
`list_booths` touches every booth on every page load, so one bad file must
|
||||||
|
cost that booth's provenance and nothing else."""
|
||||||
|
c, data = client
|
||||||
|
_booth(data, "good")
|
||||||
|
bad = _booth(data, "bad")
|
||||||
|
(bad / MANIFEST_FILE).write_bytes(b"\xff\xfe not utf-8 at all")
|
||||||
|
|
||||||
|
assert c.get("/").status_code == 200
|
||||||
|
assert c.get("/healthz").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_pickup_booth_announces_itself_as_the_booths_own(client):
|
||||||
|
"""No exemption list. A booth the service made says the service made it,
|
||||||
|
which is true — and it keeps the rule to one line: a booth with no manifest
|
||||||
|
is unannounced."""
|
||||||
|
c, data = client
|
||||||
|
r = c.post("/upload", files=[("files", ("a.txt", b"hello", "text/plain"))],
|
||||||
|
follow_redirects=False)
|
||||||
|
assert r.status_code in (200, 303)
|
||||||
|
|
||||||
|
booth = next(p for p in data.iterdir() if p.is_dir())
|
||||||
|
got = read_manifest(booth)
|
||||||
|
assert got is not None and got.handle == "booth"
|
||||||
|
assert 'class="prov prov-none"' not in c.get("/").text
|
||||||
+4
-1
@@ -276,12 +276,15 @@ def test_as_dict_round_trips_through_json(tmp_path):
|
|||||||
# ---- the stdlib-only invariant (INV-5) --------------------------------------
|
# ---- the stdlib-only invariant (INV-5) --------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("module", ["marks", "asks", "links"])
|
@pytest.mark.parametrize("module", ["marks", "asks", "links", "manifest"])
|
||||||
def test_stdlib_only(module):
|
def test_stdlib_only(module):
|
||||||
"""INV-5. scripts/booth imports these under the system python3 with NO venv,
|
"""INV-5. scripts/booth imports these under the system python3 with NO venv,
|
||||||
through a `python3 -c` heredoc that no AST extractor can see — so nothing
|
through a `python3 -c` heredoc that no AST extractor can see — so nothing
|
||||||
but this test stands between a casual third-party import and `booth ask`
|
but this test stands between a casual third-party import and `booth ask`
|
||||||
breaking on every fleet host."""
|
breaking on every fleet host."""
|
||||||
|
# `manifest` also carries a stricter copy in tests/test_manifest.py, which
|
||||||
|
# additionally forbids importing `booth.*` — a cross-import between two
|
||||||
|
# stdlib-only modules is a second way for this invariant to break.
|
||||||
src = pathlib.Path(__file__).parent.parent / "booth" / f"{module}.py"
|
src = pathlib.Path(__file__).parent.parent / "booth" / f"{module}.py"
|
||||||
tree = ast.parse(src.read_text())
|
tree = ast.parse(src.read_text())
|
||||||
roots = set()
|
roots = set()
|
||||||
|
|||||||
Reference in New Issue
Block a user