diff --git a/CLAUDE.md b/CLAUDE.md index 4c44623..97bc351 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,8 +62,9 @@ test is the only thing standing here. No database. `ls ~/booth-data` tells you everything the service knows. Per-booth operator state is a **dotfile inside the booth**: `.forever` (keep), -`.blurred` (one rel per line), `.marks.json` + `.marks.lock` (judgment), `.pins` -(link-board pin ids), `.uploaded` (upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new +`.viewed` (last deliberate look — U4's "viewing is activity"), `.blurred` (one +rel per line), `.marks.json` + `.marks.lock` (judgment), `.pins` (link-board pin +ids), `.uploaded` (upload-booth marker). `booth_items()` skips `name.startswith(".")`, so a new dotfile costs nothing in item counts, galleries or zips. That skip is why the dotfile is the right shape for new operator state — use it rather than inventing a sidecar-per-item. diff --git a/README.md b/README.md index b8b22b6..39f80c9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,9 @@ filesystem *is* the state. - **Live:** http://10.100.10.50:8090/ (nh3-dev) · linked from Homepage → *Apps → The Booth* - **Data dir:** `~/booth-data/` on nh3-dev (one subfolder per booth) - **TTL:** 24h, measured from the newest mtime in a booth's tree (it lives while - you're touching it, self-destructs 24h after you stop) + you're touching it, self-destructs 24h after you stop). Two things hold a booth + open past that: the `.forever` sentinel, and **an unanswered question** — see + *Lifetime* below. **Opening a booth page is activity**; polling it is not. ## How a session posts @@ -159,7 +161,46 @@ is exactly why the direct `×` was worth adding. an ephemeral booth could only be kept from a shell. The `/keep` route and the CLI verb both already existed; only the button was missing. -## Kept boards — the one exception to the 24h rule +## Lifetime — derived, not declared + +A booth is in exactly one of three states, and only the first is a button you +press: + +| state | what puts it there | swept? | +|---|---|---| +| **kept** | you pressed `keep` / dropped `.forever` | never | +| **held** | an **unanswered pick**, or a `.marks.json` the service cannot read | not while that holds | +| **ephemeral** | everything else | 24h after the last activity | + +**An open question holds its own booth.** A session that runs `booth ask` does +not also need to `keep` the booth — the booth cannot be swept while the operator +still owes it an answer, and it is released automatically when he answers. A +*partially* answered multi-question pick still counts as open, so a review in +flight is never swept out from under him. The index card and the booth header +say `held until answered` where the countdown would be, so a booth that has +stopped counting down always tells you why. + +**Viewing is activity.** A deliberate GET of a booth's own page — the gallery, a +verbatim report, the zoom view, the marks page, a zip download — resets the +clock. If the operator is still looking at it, it is still alive. Browsing the +index does **not** count, and neither does a session polling `marks.json` or +`booth marks --wait`: machine reads are deliberately excluded, so an agent +cannot hold its own booth open by waiting on it. + +**A held booth is still yours to delete.** The hold is protection from the +timer, never from you: `booth rm`, the UI ×, and `DELETE /b/` all work +exactly as before. `sweep_once` is the only thing that honours a hold, exactly +as it is the only thing that honours `.forever`. + +**Why this exists:** `.forever` used to be the only way to say three different +things — "this is durable", "I haven't answered yet", and "I'm still looking at +it" — and the measurement showed it carrying all three. On 2026-09-22, 17 of 24 +live booths (70%) held the sentinel, up from 54% the day before; three of the +four booths in the fleet awaiting an answer had been pinned by hand as well. +Only the first meaning is what `keep` means. The other two the service already +knew and did not consult. + +### Kept boards — the explicit pin A booth containing a **`.forever`** dotfile is **never swept**, and renders in its own **Kept** lane at the top of the index (blue top edge, `★ kept` badge, no @@ -168,6 +209,7 @@ still ephemeral, so nobody inherits a cleanup chore they didn't ask for. ```bash booth keep my-board # drop the sentinel — exempt from the sweep, forever + # (NOT for "waiting on an answer" — the pick holds it) booth unkeep my-board # release the pin — the board rejoins the sweep booth rm my-board # delete it NOW (works on kept boards; says so when it was kept) @@ -438,11 +480,10 @@ wipe it from there. Release is reversible — press keep again and nothing was lost. From the CLI, `booth rm ` deletes a kept board immediately and tells you it was kept. -**Do not "unkeep and let it expire."** Removing the sentinel *bumps the booth -directory's mtime*, and a booth's age is the newest mtime in its tree — so a -released board's clock **resets** and it survives another full TTL. -Unkeep-and-wait is a 24-hour delay, not a delete. Use the × or `booth rm` when -you mean now. +**Do not "unkeep and let it expire."** **Releasing a board is activity** — you +just touched it — so a released board's clock **resets** and it survives another +full TTL. Unkeep-and-wait is a 24-hour delay, not a delete. Use the × or +`booth rm` when you mean now. ## Ops diff --git a/ROADMAP.md b/ROADMAP.md index 5670516..36c79fc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,7 @@ # The Booth — roadmap Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md). -Current version: `0.3.0` (U1, U2 and U5 landed; extracted from eshpfi 2026-09-21). +Current version: `0.3.0` (U1, U2, U4 and U5 landed; extracted from eshpfi 2026-09-21). ## v1 target @@ -13,7 +13,7 @@ defect — not a wish. The measurements are in the IA doc. | 1 | ~~**One item record**~~ — **landed `ce598b3`** | captions never reach the zoom view (never sent, not lost) | U1 | | 2 | ~~**Marks**~~ — **landed `c7f9437`, released `v0.2.0`** | 5 mechanisms for 1 job; operator→session loop runs through chat | U2 | | 3 | **Declared embed seam** — `/_booth/embed.js`, chrome mounts via DOM | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 | -| 4 | **Derived lifetime** — open marks pin; viewing is activity | 54% of booths on the `.forever` escape hatch | U4 | +| 4 | ~~**Derived lifetime**~~ — **landed** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 | | 5 | ~~**Self-announcing booths**~~ — **landed `c015a91`, released `v0.3.0`** | job 5 had no home, so it lived on the link board as 145 dead rows | U5 | | 6 | **Benches** — registry, identity, enforced rule, migration | 69% link-board rot; the same bench posted 5× | U6 | | 7 | **Navigation at 270 items** — sections, rail, filters, grid keyboard | one flat wall; subfolder structure discarded at render | U7 | @@ -22,7 +22,7 @@ Ordering is dependency-driven, not priority-driven: **U1 → U2 → {U3, U4, U5} U7**, with **U6 independent** of all of them (different storage, different surface) and therefore the safest thing to land first or in parallel. -**U1, U2 and U5 are landed.** U3 and U4 are unblocked and unstarted; U6 remains +**U1, U2, U4 and U5 are landed.** U3 is unblocked and unstarted; U6 remains independent and unstarted; U7 waits on the rest. **U5's adoption is a measured prediction, not a finished result**, and it is @@ -74,6 +74,12 @@ Where it already binds, and what the rule is in each case: | link board rows | pinned first, then newest-first | | a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) | +U4 added no ordered collection — a booth's lifetime is one state per booth, +not a sequence — so the rule above did not need a new row. The three lifetime +surfaces (index card, booth header, marks page) render through ONE macro +precisely so they cannot disagree, which is the same property stated for +ordering: one rule, one place, every surface reading it. + Where it is still to be decided, and must be before the unit ships: **U7's section ordering and its compare pairing** (sections need a stated order among themselves, not just within; pairing by filename needs a rule for what happens diff --git a/booth/app.py b/booth/app.py index 1d44e2c..e8a20bb 100644 --- a/booth/app.py +++ b/booth/app.py @@ -7,15 +7,26 @@ Model (deliberately dead-simple, no database): * GET /b// -> if /index.html exists, serve it verbatim; otherwise auto-render a gallery of the images / webm-videos / audio in it. * GET /b// -> serve a file out of the booth (also feeds a custom index.html's assets). - * 24h TTL: a background sweeper wipes any booth untouched for TTL hours. A booth's - age is measured from the *newest* mtime in its tree, so it lives while it's being - worked on and self-destructs TTL hours after the last activity. - * KEPT BOOTHS: a booth containing the KEEP_MARKER dotfile (`.forever`) is exempt - from the sweep and renders in its own lane above the ephemeral grid. That is the - home for durable operator-facing boards — chiefly the standing link board agent - sessions post to, whose whole purpose is to survive longer than the scrollback - it replaces. Opt-in per booth, so the ephemeral default is unchanged and nobody - inherits a cleanup chore; `rm` the sentinel and the booth rejoins the sweep. + * LIFETIME IS DERIVED, not set by a boolean (U4). Three states, and `sweep_once` + is the only thing that honours the first two: + KEPT `.forever` present. Never swept, own lane at the top of the index. + Durable operator-facing boards — chiefly the standing link board, + whose whole purpose is to outlive the scrollback it replaces. + HELD an open pick in `.marks.json`, or marks that cannot be read at all. + A booth the operator still owes an answer to is not the sweeper's + to take, and one whose judgment we failed to READ is certainly not. + EPHEMERAL everything else: wiped TTL hours after the last activity. Age is the + *newest* mtime in the tree, so a booth lives while it is being + worked on and self-destructs once it stops. + * VIEWING IS ACTIVITY. A deliberate GET of a booth's own page writes VIEW_MARKER, + which the age rule already counts — if the operator is still looking at it, it + is still alive. Browsing the index is not a view, and neither is a session + polling `marks.json`: an agent must not be able to hold its own booth open. + * WHY DERIVED. `.forever` was the ONLY way to say three different things, and the + measurement showed it carrying all of them — 17 of 24 live booths on 2026-09-22 + (70%, up from 54%), with three of the four booths awaiting an answer ALSO pinned + by hand. Only "this is durable" is what keep means. The other two are facts the + service already held and did not consult. State is the filesystem — `ls ~/booth-data` tells you everything. That is the whole point. """ @@ -34,6 +45,7 @@ import time import zipfile from contextlib import asynccontextmanager from pathlib import Path +from typing import Sequence from urllib.parse import quote, unquote from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile @@ -85,6 +97,14 @@ from booth.items import ( # noqa: E402,F401 # flag to remember, no state anywhere but the filesystem. KEEP_MARKER = ".forever" +# Records the last deliberate look at a booth (U4). A dotfile for the same two +# reasons KEEP_MARKER is one — `booth_items` and `zip_booth` skip it, so it +# costs nothing in counts, galleries or zips — and NOT a `.lock` dotfile, so +# `_newest_mtime` COUNTS it and the existing age rule picks the view up with no +# new arithmetic. That is the whole integration: a view is one more thing in +# the tree, not a second term in the formula. +VIEW_MARKER = ".viewed" + # ⚠⚠ BLUR IS COSMETIC, NOT ACCESS CONTROL. The file is still served at its own # URL, still in the zip, still on disk. This hides an item from a glance — a # shoulder, a screen-share, a scroll past something you did not want to see @@ -124,12 +144,14 @@ from booth.asks import ( # noqa: E402 ) from booth.marks import ( # noqa: E402 MARKS_FILE, + Mark, MarksCorrupt, answer_pick, as_dict, declare_pick, delete_mark, import_legacy_asks, + hold_read, marks_for, marks_for_target, open_marks, @@ -189,18 +211,34 @@ def _newest_mtime(path: Path) -> float: gets its clock counted. Everything else counts too, dotfiles included, because `.marks.json`, `.blurred` and `.pins` are the operator doing something. + + ⚠ A STAT WE CANNOT DO READS AS *FRESH*, NEVER AS EPOCH-OLD. This function + feeds `is_expired`, which feeds `rmtree`. Returning 0.0 for a booth whose + own stat fails made it maximally ancient and therefore the FIRST thing the + sweeper takes — a permissions or ELOOP problem resolving to a deletion. The + bug-hunt panel found this as one of four paths into the same shape. Not + knowing a booth's age is a reason to leave it alone. + + `FileNotFoundError` on an entry is the exception, and it stays a skip: a + dangling symlink and a file removed mid-scan both raise it, and neither is + a thing with an mtime worth counting. Any OTHER per-entry OSError means we + could not read something that IS there, so the age is unknowable and the + booth reads as fresh. """ + now = time.time() try: newest = path.stat().st_mtime except OSError: - return 0.0 + return now for p in path.rglob("*"): if p.name.startswith(".") and p.name.endswith(".lock"): continue try: m = p.stat().st_mtime + except FileNotFoundError: + continue # dangling symlink, or gone mid-scan except OSError: - continue + return now # cannot read it — cannot judge the age if m > newest: newest = m return newest @@ -222,8 +260,102 @@ def is_expired(path: Path, ttl_seconds: float, now: float | None = None) -> bool def is_kept(path: Path) -> bool: - """True if this booth carries the keep sentinel and must never be swept.""" - return (path / KEEP_MARKER).exists() + """True if this booth carries the keep sentinel and must never be swept. + + `lstat`, not `Path.exists()`, and an unreadable answer counts as KEPT. The + old form collapsed ELOOP and EACCES into False, so a kept booth whose + sentinel could not be stat'd became eligible for the sweep — a failed read + authorizing a delete, which is the shape the bug-hunt panel found four ways + into. `lstat` also means a `.forever` SYMLINK counts, dangling or not: + somebody put it there to mean keep. + """ + try: + (path / KEEP_MARKER).lstat() + return True + except FileNotFoundError: + return False + except OSError: + return True + + +def record_view(booth: Path) -> None: + """Note that somebody deliberately looked at this booth (U4). + + Touches VIEW_MARKER and lets `_newest_mtime` do the rest — a view enters + the age rule as a file in the tree, not as a new term in the arithmetic. + + NEVER RAISES. A read-only mount, a booth owned by another uid, a full disk, + a booth deleted between the route's resolve and this call: every one of + those costs the timestamp, not the page. The same trade `_Locked.__enter__` + makes on its `os.utime`, and for the same stated reason — not recording the + look is a cost this service can absorb, not answering the request is not. + A booth whose view cannot be recorded simply ages on its content mtime, + which is what every booth did before this existed. + """ + # O_NOFOLLOW, not `Path.touch()`. `touch` on an existing symlink follows it, + # so a booth carrying a planted `.viewed -> /anywhere` turned EVERY page + # view into an mtime write at an arbitrary path under the service uid — and + # any fleet session can write into a booth, because making a folder is the + # whole API. Three of four bug-hunt arms found it independently. A symlink + # here now raises ELOOP into the swallow below: view-recording quietly stops + # for that booth, which is the right way to lose this argument. + # + # O_CREAT alone does not move the mtime of a file that already exists, so + # the utime is not decoration: the marker must read as NOW or the whole + # mechanism is a file nobody's clock looks at. + try: + fd = os.open(booth / VIEW_MARKER, + os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o644) + try: + os.utime(fd) + finally: + os.close(fd) + except OSError: + pass + + +HOLD_UNREADABLE = "unreadable" +HOLD_OPEN = "open" + + +def hold_reason(marks: Sequence[Mark], error: str | None) -> str | None: + """WHY this booth must not be swept, or None if it may be. THE hold predicate. + + Returns a reason rather than a bool so the surface that has to say why can + read it off the same value the sweeper acts on. A boolean plus a separate + error string is two representations of one state, and they drift. + + PURE — it takes the result of a read and does none of its own, so the index + card and the sweeper cannot answer differently about the same booth. That is + U1's rule (one resolver, every surface reads the record) applied to lifetime. + + FAIL-SAFE ON BOTH LEVELS OF DAMAGE, which is the correction the bug-hunt + panel forced (2026-09-22). `marks_for` is lenient because a review page that + will not load is worse than one missing an annotation — the right trade for + a RENDER and the wrong one for a DELETE, where the same leniency wipes the + booth whose judgment we had just failed to read, artifacts and all. The + first cut of this caught FILE-level damage only: + + * file-level — `.marks.json` will not parse at all. `hold_read` reports it. + * ENTRY-level — the document parses, but one mark fails normalization and + `_hydrate_safe` hands back a `Mark` carrying `error`. `_is_open` returns + False for an errored pick, ON PURPOSE (a broken pick can never be + answered; the CLI spells that exit code 4) — so such a booth read as + `not held` and SWEPT, while the panel beside it rendered the broken mark + in full. Four arms found four ways into that shape; this was the worst. + + A mark that cannot be read is judgment we cannot see. Deleting the booth it + belongs to is the one thing we must not do with it. + + Openness itself is `open_marks` and nothing else (U2 INV-2): a partially + answered pick is STILL open and still holds, which is the reading that + makes this rule correct rather than one that sweeps a review in flight. + """ + if error is not None or any(m.error is not None for m in marks): + return HOLD_UNREADABLE + if open_marks(marks): + return HOLD_OPEN + return None def sweep_once(data_dir: Path, ttl_seconds: float, now: float | None = None) -> list[str]: @@ -232,10 +364,25 @@ def sweep_once(data_dir: Path, ttl_seconds: float, now: float | None = None) -> Only ever removes direct children of data_dir (never data_dir itself), and skips dotfolders so a stray control dir can opt out. + TWO exemptions, and this is the only function that honours either. + A booth carrying KEEP_MARKER is exempt no matter how stale it is. That is - the one escape hatch from the 24h contract, and it is opt-in per booth: the - default stays ephemeral, so nobody inherits a cleanup chore they did not ask - for. Removing the sentinel hands the booth straight back to the sweeper. + the explicit escape hatch, opt-in per booth: the default stays ephemeral, so + nobody inherits a cleanup chore they did not ask for. Removing the sentinel + hands the booth straight back to the sweeper. + + A booth that is HELD — an open pick, or marks we cannot read — is exempt for + as long as that holds (U4). This is the derived half: the operator was + pressing `.forever` to mean "not yet" because nothing else could say it, and + the service already knew. 17 of 24 live booths carried the sentinel on + 2026-09-22, and three of the four booths in the fleet awaiting an answer + carried it too — the "not yet" case, caught in the act. + + Reading the marks costs ONE strict read per booth per tick — `hold_read`, + which answers both halves of the hold question at once. It is deliberately + not two calls: two reads of one file are not one read of one state, and the + pair that loses that race is the pair that deletes. Do not "optimize" this + back into `marks_for` plus `read_error`. """ wiped: list[str] = [] if not data_dir.is_dir(): @@ -246,6 +393,8 @@ def sweep_once(data_dir: Path, ttl_seconds: float, now: float | None = None) -> try: if is_kept(child): continue + if hold_reason(*hold_read(child)): # ONE read — see hold_read + continue if is_expired(child, ttl_seconds, now): shutil.rmtree(child) wiped.append(child.name) @@ -278,7 +427,20 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> # flag a booth that is waiting on the operator. ONE file read per booth # — 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) + # ONE read for BOTH the badge and the lifetime decision. It has to be + # one: `marks_for` is lenient, so an unreadable `.marks.json` reads as + # no marks — fine for a card, wrong for the reaper, which would then + # delete the booth whose judgment it had just failed to read. And + # asking the two questions with two reads is not one read of one state: + # a write landing between them yields `([], None)`, the pair that + # deletes. `hold_read` answers both from one read; the lenient reader + # comes back only on the error path, where leniency is the point. + held_marks, read_err = hold_read(child) + # The DECISION comes from that one read and nothing else. The lenient + # re-read below is for DISPLAY only — feeding it back into the predicate + # would rebuild the two-read seam this call exists to close. + hold = hold_reason(held_marks, read_err) + marks = held_marks if read_err is None else 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 @@ -314,6 +476,13 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) -> # tested `answer is None`, so a half-answered pick read as closed # here while the panel beside it rendered `◐ partial`. "marks_open": len(open_marks(marks)), + # U4: WHY this booth is or is not counting down. The card must + # never just stop the clock silently — `.forever` was at least + # visible as a lane, and an invisible rule would be worse than + # the boolean it replaces. + # WHY it is or is not counting down — the reason, not a bool + # beside a string that can disagree with it. + "hold": hold, "expires_in": max(0.0, ttl_seconds - (now - mtime)), "mtime": mtime, } @@ -689,6 +858,10 @@ def create_app( @app.get("/b/{name}/", response_class=HTMLResponse) def booth_view(request: Request, name: str, download: int = 0): booth = resolve_booth(name) + # U4: viewing is activity. ABOVE both early returns — the zip download + # and the verbatim-index.html branch are looks at this booth too, and a + # verbatim report is the shape the operator stares at longest. + record_view(booth) if download: # whole-booth zip — the download path for a verbatim index.html booth # (which has no gallery/per-file chrome), and a "download all" for any. @@ -721,7 +894,9 @@ def create_app( it for it in build_gallery(booth) if not ((booth / LINKS_FILE).is_file() and it["name"] == LINKS_FILE) ] - marks = marks_for(booth) + held_marks, read_err = hold_read(booth) # ONE read; see list_booths + hold = hold_reason(held_marks, read_err) + marks = held_marks if read_err is None else marks_for(booth) return templates.TemplateResponse( request, "booth.html", @@ -764,6 +939,10 @@ def create_app( # 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), + # The lifetime line, same three states as the index card: a + # booth URL handed to the operator lands HERE, not on the index, + # so "why is this not counting down" has to be answerable here. + "hold": hold, "expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)), }, ) @@ -973,13 +1152,29 @@ def create_app( place a verbatim-index.html booth can show its marks — that page is served untouched by design, so the inline panel never renders there.""" booth = resolve_booth(name) - marks = marks_for(booth) + # U4: for a verbatim booth this IS the booth page. `/b//asks` is a + # 308 into here, so the legacy URL records through this call and must + # not get one of its own. + record_view(booth) + held_marks, read_err = hold_read(booth) # ONE read; see list_booths + hold = hold_reason(held_marks, read_err) + marks = held_marks if read_err is None else marks_for(booth) return templates.TemplateResponse( request, "marks.html", {**base_ctx, "name": name, "name_url": quote(name, safe=""), "marks": marks, "marks_open": len(open_marks(marks)), - "booth_marks": marks_for_target(marks, None), "marks_page": True}, + "booth_marks": marks_for_target(marks, None), "marks_page": True, + # U4 INV-4, and this page is WHY the invariant needs a third home. + # A verbatim booth's own index.html is served untouched, so it has + # no Booth-rendered header to carry the lifetime line — this page + # is the only surface besides the index card where the Booth owns + # the chrome. Without it, the booths most likely to be held (a + # report that ASKS something is the archetype) would be the ones + # that never say they are. + "kept": is_kept(booth), + "hold": hold, + "expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth))}, ) @app.get("/b/{name}/asks", include_in_schema=False) @@ -1029,6 +1224,14 @@ def create_app( items = booth_items(booth) item = find_item(items, f) + # U4: a bookmarked zoom URL is somebody looking — but only once we know + # there is an ITEM to look at. Below the 404s, and gated on the record, + # because `f` is any path that stats inside the booth: the bug-hunt + # panel pointed `?f=.marks.lock` at this and held a booth open with a + # file the service created itself. A dotfile is not an item, and a view + # of a thing that is not an item is not a view of the booth. + if item is not None: + record_view(booth) marks = marks_for(booth) item_marks = marks_for_target(marks, f) common = { @@ -1210,7 +1413,35 @@ def create_app( @app.post("/b/{name}/unkeep") def booth_unkeep(name: str, next: str = Form("/")): # missing_ok: releasing an already-released board is a no-op, not a 500. - (resolve_booth(name) / KEEP_MARKER).unlink(missing_ok=True) + booth = resolve_booth(name) + marker = booth / KEEP_MARKER + try: + marker.unlink() + released = True + except FileNotFoundError: + released = False # already released: a no-op, not a 500 + except OSError: + # A `.forever` that is a DIRECTORY raised IsADirectoryError straight + # through this route and 500'd it, which made the card's release + # button permanently dead for that booth. Pre-existing; the panel + # re-exposed it. Removing it is still best-effort, and failing to is + # not worth refusing the request over. + released = False + if not released: + return RedirectResponse(url=_safe_next(next), status_code=303) + # U4: RELEASE IS ACTIVITY, and now it is a rule rather than an accident. + # A released board already survived another full TTL, because unlinking + # a file bumps the directory's mtime — behaviour the note above calls + # "not intuitive" precisely because nothing declared it. The behaviour + # is unchanged; its reason is now stated. Releasing a board is somebody + # touching it, so it gets one full TTL, the same as any other look. + # + # ONLY when something was actually released, which is the correction the + # bug-hunt panel forced: an unconditional call made POSTing release at + # an already-released booth an endless TTL refresh, contradicting this + # route's own no-op promise and diverging from the CLI, which `rm`s the + # sentinel without recording anything. + record_view(booth) return RedirectResponse(url=_safe_next(next), status_code=303) @app.post("/b/{name}/blur") diff --git a/booth/marks.py b/booth/marks.py index 282a025..c9b0807 100644 --- a/booth/marks.py +++ b/booth/marks.py @@ -217,12 +217,21 @@ def _fingerprint(entries: list[dict]) -> str: return json.dumps(entries, sort_keys=True, ensure_ascii=False) -def _read_raw_strict(booth: Path) -> list[dict]: +def _read_raw_strict(booth: Path, *, blank_is_corrupt: bool = False) -> list[dict]: """Like `_read_raw`, but RAISES `MarksCorrupt` on a file it cannot parse. Absent, empty and valid-but-empty are all "no marks yet" and are fine — the distinction that matters is bytes-present-but-unreadable, because that is the case where writing would destroy something. + + `blank_is_corrupt` is the DELETE path's reading of a present-but-whitespace + file, and only the delete path's: this writer never produces a blank marks + document, so a blank one that exists is something that went wrong, and + `rmtree` is not the response to that. The write path keeps the lenient + reading — a blank file is safe to overwrite, which is the question + `_Locked` is asking. A VALID document with an empty `marks` list is not + blank and never holds: that is what deleting the last mark leaves behind, + and it must stay sweepable. """ path = Path(booth) / MARKS_FILE try: @@ -245,6 +254,8 @@ def _read_raw_strict(booth: Path) -> list[dict]: except (OSError, UnicodeDecodeError, MemoryError) as exc: raise MarksCorrupt(f"{path} cannot be read: {exc}") from exc if not text.strip(): + if blank_is_corrupt: + raise MarksCorrupt(f"{path} is present but holds no marks document") return [] try: raw = json.loads(text) @@ -545,6 +556,34 @@ def open_marks(marks: Sequence[Mark]) -> list[Mark]: return [m for m in marks if _is_open(m)] +def hold_read(booth: Path) -> tuple[list[Mark], str | None]: + """ONE read of `.marks.json`, answering both questions the LIFETIME rule asks: + what is still open, and whether the file could be read at all. + + U4 decides whether a booth may be SWEPT from those two facts. Asking them + with two calls — `marks_for` then `read_error` — reads the file twice, and + two reads of one file are not one read of one state: a write or a repair + landing between them yields a pair that never described the booth at any + instant. The losing pair is `([], None)` — no marks, no error — which is + exactly the one that deletes. Cross-frontier review (2026-09-22) found it; + that is why this exists rather than the obvious two calls. + + When the file reads clean the marks are byte-identical to `marks_for`'s: + `_read_raw_strict` raises rather than dropping an entry, so a non-raising + strict read returns the same entries the lenient read would, hydrated and + sorted the same way. The caller can therefore use this ONE read for the + display too, and fall back to `marks_for` only on the error path, where + leniency is the point. + """ + try: + entries = _read_raw_strict(booth, blank_is_corrupt=True) + except MarksCorrupt as exc: + return [], str(exc) + marks = [_hydrate_safe(e) for e in entries] + marks.sort(key=lambda m: (m.created, m.id)) + return marks, None + + def marks_for_target(marks: Sequence[Mark], rel: str | None) -> list[Mark]: """The marks attached to one item, or to the booth itself for None.""" return [m for m in marks if m.target == rel] diff --git a/booth/templates/_lifetime.html b/booth/templates/_lifetime.html new file mode 100644 index 0000000..d981d9e --- /dev/null +++ b/booth/templates/_lifetime.html @@ -0,0 +1,28 @@ +{# The lifetime line, defined ONCE and called from four surfaces: the index + card (both lanes), the booth header (both branches) and the marks page. + + U4: a booth's lifetime is derived from its own state, and a booth that is + not counting down must always SAY WHY — an invisible rule that silently + stopped the clock would be strictly worse than the `.forever` boolean it + replaces, because that one was at least visible as a lane. + + `hold` is the REASON, straight off `hold_reason()`, not a bool beside a + string that can disagree with it. Kept wins over a hold because a kept booth + is exempt either way, and showing two reasons for one EXEMPTION is the + two-representations-of-one-state trap. + + Unreadable marks are the exception and ride along even on a kept board: + damaged judgment is not a second exemption, it is a thing somebody has to go + and fix, and the kept lane holds the durable boards — the ones where losing + the operator's marks costs most. #} +{% macro lifetime(kept, hold, expires_in) -%} + {%- if kept -%} + kept{% if hold == "unreadable" %} · marks unreadable{% endif %} + {%- elif hold == "unreadable" -%} + held · marks unreadable + {%- elif hold == "open" -%} + held until answered + {%- else -%} + expires in {{ expires_in|dur }} + {%- endif -%} +{%- endmacro %} diff --git a/booth/templates/base.html b/booth/templates/base.html index 8f81f1b..300f22e 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -330,6 +330,11 @@ use for state), green check once answered; the accent is a TOP edge, per Australis, never a coloured left border. */ .badge-mark{background:var(--aus-bright-yellow);color:var(--fg-on-accent)} + /* U4: the lifetime line's HELD states. Marked rather than styled into + invisibility — the whole safety argument for an unbounded hold is that + a booth which stopped counting down says so where the countdown was. */ + .held{color:var(--aus-bright-yellow)} + .held-broken{color:var(--fg-3);text-decoration:underline dotted} .thumb .badge+.badge-mark{top:2.2rem} .marks{display:flex;flex-direction:column;gap:.9rem;margin:.2rem 0 1.4rem} .mark{border:1px solid var(--border-subtle);border-top:2px solid var(--aus-bright-yellow); diff --git a/booth/templates/booth.html b/booth/templates/booth.html index b742e9d..d432d92 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% from "_provenance.html" import provenance %} +{% from "_lifetime.html" import lifetime %} {# 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 @@ -65,7 +66,7 @@ {% else %}

{{ name }}

{% endif %} - {% 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 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 %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}{{ marks_open }} open · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %} {% if items %}⬇ zip{% endif %} {{ provenance(manifest) }} {# A durable multi-writer board gets no one-click wipe — same rule as the diff --git a/booth/templates/index.html b/booth/templates/index.html index 56482e2..1443505 100644 --- a/booth/templates/index.html +++ b/booth/templates/index.html @@ -1,5 +1,6 @@ {% extends "base.html" %} {% from "_provenance.html" import provenance %} +{% from "_lifetime.html" import lifetime %} {% block content %}