3 Commits

Author SHA1 Message Date
vh 315faac4b5 feat(booth): view .md (rendered) and .txt/.log in-booth without downloading
Loose .md/.txt/.log files rendered as forced-download links in the gallery
and downloaded (or showed raw) when opened. Now they open in a readable
in-booth page via the existing /b/<name>/view route:
  - .md  -> rendered HTML (Python-Markdown: fenced code, tables, sane lists),
           styled in an Australis .markdown-body with the viewer chrome;
  - .txt/.log -> preformatted <pre> text view.
The gallery links docs to the viewer (📄) instead of a download; the view
page keeps a ⬇ (?dl=1) for saving. Files over 2 MB hand back raw. New
markdown dep (optional-import: degrades .md to text view if absent).
booth_image_view -> booth_view_file (now handles image + doc + raw-fallback).
9 new tests, suite 44 passing; deployed + verified live on nh3-dev :8090.
2026-08-05 01:13:38 -07:00
vh b5ae9365ff memory: triton backend is a HARD NO on the Ampere comfy box (fp8e4nv), deferred to Ada
Recorded under Tried-and-abandoned: --enable-triton-backend on irv-ml1's
A6000 crashes every render (triton's fp8 dequant needs fp8e4nv/e4m3, which
sm_86 lacks; the global --fp8_e4m3fn-text-enc dequant dies at CLIPTextEncode,
taking fp8 and int8 alike). Reverted cleanly; cu130 rebuild won't fix a
hardware format limit; revisit at the Ada refresh. Also captures the
compose-env-needs-recreate-not-restart mechanics lesson.
2026-08-03 21:29:00 -07:00
vh 348c5c12a2 chore(booth): gitignore uv.lock (stray artifact from uv run; booth tracks no lockfile) 2026-08-03 13:06:02 -07:00
7 changed files with 160 additions and 20 deletions
+2
View File
@@ -225,6 +225,8 @@ _167 older entries archived to archival-memory.md._
## Tried and abandoned
- `[2026-08-03]` **ComfyUI `--enable-triton-backend` on the irv-ml1 A6000 crashes EVERY render — Ampere has no hardware e4m3.** adhoc-agent's operator-approved probe: comfy_kitchen's triton backend has a FUSED int8 matmul that would beat the eager backend's ~1.9x-slower unfused int8 path (21.3s vs 11.2s fp8 on the Moody Krea2 int8 checkpoints). Flipped it (added to `COMFY_CMDLINE_EXTRA`, recreated) → `triton.compiler.errors.CompilationError: ValueError("type fp8e4nv not supported in this architecture. supported: fp8e4b15, fp8e5")` in `comfy_kitchen/backends/triton/quantization.py:145 dequantize_per_tensor_fp8`, failing at **node 5 CLIPTextEncode**. Triton's fp8 dequant kernel targets `fp8e4nv` (Hopper/Ada e4m3); **sm_86 Ampere (A6000) lacks hardware e4m3** → the JIT compile dies. With triton on it grabs the **global** `--fp8_e4m3fn-text-enc` dequant, so every render (fp8 AND int8) dies upstream at the text-encode step — the int8 UNet path never ran, so the convrot-coverage caveat wasn't even the limiter. Reverted cleanly (~15s to healthy, image unchanged `sha256:94afb8ca`, sage intact, prod restored). **The parked cu130 rebuild won't fix it** (e4m3 = hardware format, not CUDA version). **DEFERRED to the Ada refresh** (operator: "ada is coming, we'll optimize then" — Ada sm_89 has native e4m3, so triton's fp8 path should compile there). **Mechanics:** `--enable-triton-backend` is a compose `environment:` var, so toggling it needs `docker compose up -d` (**recreate**), NOT `docker restart` (reuses the baked env, no-ops silently). Full: auto-memory `parked_triton_backend_ampere_fp8`.
- `[2026-08-03]` **corviduo-dev shared containerd: a concurrent-pull race fails ONE instance's deploy; DON'T "prune to fix" — the image is in-use by the instance that won the race.** b169 personal deploy failed at `docker compose pull` (`Lchown … no such file or directory` on the big torch layer → looked like disk pressure / corrupt snapshot). ACTUAL: NOT disk (56G free, inodes 7%). demo + personal + pinned share ONE `/var/lib/containerd` on corviduo-dev; demo (from main) and personal (from staging tag) extracted b169's shared torch layer simultaneously → personal's hit a partial snapshot mid-race and aborted while demo's completed. The image `6e34a87` was FULLY VALID — demo was RUNNING it healthy. Fix = just re-run the failed deploy (image already materialized; compose pull finds it present). **NEAR-MISS:** worldtree-dev's suggested "prune unused images/snapshots" would have rmi'd `6e34a87` = the image the running demo depends on → demo outage. **Lesson: before any prune/rmi "cleanup," `docker ps` the running images — an "unused" image may be a co-tenant's live one; and verify the failure's REAL cause (disk? inode? in-use? race?) before applying the suggested remedy.** (Pipeline fix, deferred: serialize demo-from-main + personal-from-staging, or a per-image pull lock, to avoid the shared-layer extraction race.)
- `[2026-08-02]` **`docker exec` into worldtree containers defaults to ROOT — root writes contaminate the uid-1000 (vh) KB tree.** My `sudo docker exec … --reindex` on personal ran as ROOT (muninn app = uid 1000); its wing git-commit + atomic note-swap left root-owned files in the `worldtree-personal_worldtree-kb` volume: a root-owned `.old-<job>` backup dir (blocked the uid-1000 retry's `rmtree` → Errno 13, because unlink needs write on the DIR and it was root:root 755) AND **60 root-owned loose git objects** in `.git/objects/`. Fix (host-side, corviduo-dev): `sudo rm -rf` the superseded `.old-` dir (tar'd aside to /tmp first) + `sudo find … -user 0 -exec chown 1000:1000` the objects (ownership-only, git-content-safe; the `.git/objects/XX/` dirs were vh-owned so these weren't a hard blocker, but violated "clean tree"). **RUNBOOK RULE (worldtree-dev, ADOPTED):** any `docker exec` into worldtree containers that WRITES pipeline state runs **`-u 1000`**, never default-root — same genus as the mv footgun (acting without matching the target's constraints; 3rd such slip in one session). **GOTCHA that hid the scope:** `find … -user 0 | head -20` TRUNCATED (the `.old-` dir alone had 153 files, so the first page was all `.old-`) → I "verified clean" off a partial list. Never `head` a scope-defining find; count first (`| wc -l`). **Related blind-spot (muninn-dev):** a root-owned job SUBDIR passes every requeue guard (job_row/dispatch/list_jobs render fine) AND `/health` (contract's `os.access(ingestion_root, W_OK)` tests only the ROOT dir, so a foreign-owned subdir under `pending/` still reports `ingestion_root_writable: true`) — then the uid-1000 gate can't write into it. "Clean board + green /health + failure at next mutation." muninn-dev added an OWNERSHIP column to the standing post-move check to catch it; two green signals both miss a foreign-owned subdir otherwise.
+1
View File
@@ -4,3 +4,4 @@ __pycache__/
*.egg-info/
.pytest_cache/
booth-data/
uv.lock
+47 -15
View File
@@ -38,6 +38,11 @@ from fastapi.responses import (
)
from fastapi.templating import Jinja2Templates
try:
import markdown as _markdown
except ImportError: # optional dep — .md then degrades to a plain-text view
_markdown = None
TEMPLATES_DIR = Path(__file__).parent / "templates"
# Browser-playable media buckets. Anything else renders as a download link.
@@ -47,6 +52,30 @@ AUDIO_EXTS = {".mp3", ".wav", ".ogg", ".oga", ".flac", ".m4a", ".opus", ".aac"}
CAPTION_MAX = 800 # chars of a sidecar .txt caption we render
# Loose text docs that render as a readable in-booth page (not a download).
MARKDOWN_EXTS = {".md", ".markdown", ".mdown"}
TEXT_EXTS = {".txt", ".text", ".log"}
DOC_MAX_BYTES = 2 * 1024 * 1024 # above this, a doc is handed back raw, not rendered
def doc_kind(name: str) -> str | None:
"""'markdown' | 'text' | None — a booth file viewable as a readable page."""
ext = Path(name).suffix.lower()
if ext in MARKDOWN_EXTS:
return "markdown"
if ext in TEXT_EXTS:
return "text"
return None
def render_doc(text: str, kind: str) -> tuple[str, bool]:
"""(rendered, is_html). Markdown → HTML (fenced code, tables, sane lists);
plain text — or markdown when the lib is unavailable — → raw text for <pre>."""
if kind == "markdown" and _markdown is not None:
html = _markdown.markdown(text, extensions=["fenced_code", "tables", "sane_lists"])
return html, True
return text, False
def classify(name: str) -> str:
"""image | video | audio | other, by extension."""
@@ -203,6 +232,7 @@ def build_gallery(child: Path) -> list[dict]:
{
"name": rel,
"kind": classify(p.name),
"doc": doc_kind(p.name),
"url": quote(rel, safe="/"),
"caption": caption.get(rel),
}
@@ -484,7 +514,7 @@ def create_app(
)
@app.get("/b/{name}/view", response_class=HTMLResponse)
def booth_image_view(request: Request, name: str, f: str):
def booth_view_file(request: Request, name: str, f: str):
booth = resolve_booth(name)
try:
target = (booth / f).resolve()
@@ -493,20 +523,22 @@ def create_app(
if not str(target).startswith(str(booth) + os.sep) or not target.is_file():
raise HTTPException(status_code=404, detail="no such file")
file_url = quote(f, safe="/")
if classify(target.name) != "image":
# nothing to zoom on a non-image — hand back the raw file
return RedirectResponse(url=f"/b/{quote(name, safe='')}/{file_url}", status_code=307)
return templates.TemplateResponse(
request,
"view.html",
{
**base_ctx,
"name": name,
"name_url": quote(name, safe=""),
"file": f,
"file_url": file_url,
},
)
common = {**base_ctx, "name": name, "name_url": quote(name, safe=""), "file": f, "file_url": file_url}
if classify(target.name) == "image":
return templates.TemplateResponse(request, "view.html", common)
# .md renders, .txt/.log show as text — viewable in-booth, no download
dk = doc_kind(target.name)
if dk:
try:
if target.stat().st_size <= DOC_MAX_BYTES:
body, is_html = render_doc(target.read_text(encoding="utf-8", errors="replace"), dk)
return templates.TemplateResponse(
request, "doc.html", {**common, "kind": dk, "body": body, "is_html": is_html}
)
except OSError:
raise HTTPException(status_code=404, detail="no such file")
# nothing to render — hand back the raw file
return RedirectResponse(url=f"/b/{quote(name, safe='')}/{file_url}", status_code=307)
@app.get("/b/{name}/{filepath:path}")
def booth_file(name: str, filepath: str, dl: int = 0):
@@ -32,6 +32,8 @@
<video controls preload="metadata" src="{{ it.url }}"></video>
{% elif it.kind == 'audio' %}
<audio controls preload="metadata" src="{{ it.url }}"></audio>
{% elif it.doc %}
<a class="dl doc" href="view?f={{ it.url }}" title="view {{ it.name }}">📄 {{ it.name }}</a>
{% else %}
<a class="dl" href="{{ it.url }}" download>⬇ {{ it.name }}</a>
{% endif %}
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}{{ file }} · {{ name }} · The Booth{% endblock %}
{% block content %}
<div class="docview">
<div class="vbar">
<a class="vbtn vx" href="/b/{{ name_url }}/" title="back to gallery (Esc)"></a>
<span class="vname">{{ file }}</span>
<span class="vspacer"></span>
<a class="vbtn" href="{{ file_url }}?dl=1" title="download {{ file }}"></a>
</div>
{% if is_html %}
<article class="markdown-body">{{ body|safe }}</article>
{% else %}
<pre class="textview">{{ body }}</pre>
{% endif %}
</div>
<style>
.docview{max-width:52rem;margin:0 auto;padding:0 clamp(12px,3vw,20px) 4rem}
.textview{white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);
font-size:.86rem;line-height:1.5;color:var(--fg-1);background:var(--rk-well);
border:1px solid var(--rk-line,#252a35);border-radius:10px;padding:1rem 1.15rem;overflow-x:auto}
.markdown-body{color:var(--fg-1);line-height:1.62;font-size:.98rem;overflow-wrap:break-word}
.markdown-body h1,.markdown-body h2,.markdown-body h3{line-height:1.25;margin:1.6em 0 .5em}
.markdown-body h1{font-size:1.7em}.markdown-body h2{font-size:1.35em}.markdown-body h3{font-size:1.12em}
.markdown-body h1,.markdown-body h2{border-bottom:1px solid var(--rk-line,#252a35);padding-bottom:.3em}
.markdown-body p,.markdown-body ul,.markdown-body ol,.markdown-body blockquote{margin:.7em 0}
.markdown-body a{color:var(--aus-bright-cyan,#42dcd1)}
.markdown-body code{font-family:var(--font-mono);font-size:.86em;background:var(--rk-well);
padding:.12em .38em;border-radius:5px}
.markdown-body pre{background:var(--rk-well);border:1px solid var(--rk-line,#252a35);
border-radius:10px;padding:.9rem 1.05rem;overflow-x:auto}
.markdown-body pre code{background:none;padding:0}
.markdown-body blockquote{border-left:3px solid var(--aus-bright-cyan,#42dcd1);
padding-left:1em;color:var(--fg-2);margin-left:0}
.markdown-body table{border-collapse:collapse;display:block;overflow-x:auto}
.markdown-body th,.markdown-body td{border:1px solid var(--rk-line,#252a35);padding:.4em .7em}
.markdown-body img{max-width:100%}
</style>
<script>
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') window.location.href = {{ ('/b/' ~ name_url ~ '/')|tojson }};
});
</script>
{% endblock %}
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "booth"
version = "0.1.5"
version = "0.1.6"
description = "The Booth — a dead-simple standing web server that scans a data dir of drop-folders and renders each as an ephemeral media 'booth' (image/webm/audio auto-gallery, or a folder's own index.html verbatim). Also accepts browser/curl uploads for pickup under a human-readable id. 24h TTL, then the folder is wiped. Fleet tool for CC sessions to surface A/B and smoke results to the operator."
requires-python = ">=3.11"
dependencies = [
@@ -8,6 +8,7 @@ dependencies = [
"uvicorn[standard]>=0.34",
"jinja2>=3.1",
"python-multipart>=0.0.9",
"markdown>=3.5",
]
[project.optional-dependencies]
+62 -4
View File
@@ -10,9 +10,11 @@ from booth.app import (
build_gallery,
classify,
create_app,
doc_kind,
generate_pickup_id,
human_dur,
is_expired,
render_doc,
safe_upload_name,
sweep_once,
wrap_verbatim_html,
@@ -353,12 +355,12 @@ def test_image_view_traversal_404(client):
assert c.get("/b/run1/view", params={"f": "../../etc/passwd"}).status_code == 404
def test_image_view_nonimage_redirects_to_raw(client):
def test_view_nonviewable_redirects_to_raw(client):
c, data = client
_touch(data / "run1" / "notes.txt")
r = c.get("/b/run1/view", params={"f": "notes.txt"}, follow_redirects=False)
_touch(data / "run1" / "data.bin") # not image/md/txt -> nothing to render, hand back raw
r = c.get("/b/run1/view", params={"f": "data.bin"}, follow_redirects=False)
assert r.status_code == 307
assert r.headers["location"] == "/b/run1/notes.txt"
assert r.headers["location"] == "/b/run1/data.bin"
# ---- verbatim-index.html wrapper --------------------------------------------
@@ -435,3 +437,59 @@ def test_verbatim_index_raw_file_route_unwrapped(client):
r = c.get("/b/brief/index.html")
assert r.status_code == 200
assert "booth-nav-home" not in r.text
# ---- .md / .txt in-booth doc viewer -----------------------------------------
def test_doc_kind():
assert doc_kind("notes.md") == "markdown"
assert doc_kind("a.markdown") == "markdown"
assert doc_kind("log.txt") == "text"
assert doc_kind("run.log") == "text"
assert doc_kind("shot.png") is None
assert doc_kind("data.bin") is None
def test_render_doc_markdown():
html, is_html = render_doc("# Title\n\n- a\n- b\n", "markdown")
assert is_html is True
assert "<h1>" in html and "Title" in html
assert "<li>" in html
def test_render_doc_text_is_verbatim():
body, is_html = render_doc("plain\ntext", "text")
assert is_html is False and body == "plain\ntext"
def test_view_markdown_renders(client):
c, data = client
d = data / "run1"; d.mkdir()
(d / "notes.md").write_text("# Heading\n\nsome **bold** text\n")
r = c.get("/b/run1/view", params={"f": "notes.md"})
assert r.status_code == 200
assert "<h1>" in r.text and "Heading" in r.text # rendered, not raw markdown
assert "<strong>bold</strong>" in r.text
assert "attachment" not in r.headers.get("content-disposition", "") # viewed, not downloaded
def test_view_text_shows_preformatted(client):
c, data = client
d = data / "run1"; d.mkdir()
(d / "out.txt").write_text("line one\nline two")
r = c.get("/b/run1/view", params={"f": "out.txt"})
assert r.status_code == 200
assert "<pre" in r.text and "line one" in r.text
assert "attachment" not in r.headers.get("content-disposition", "")
def test_gallery_links_docs_to_view(client):
c, data = client
d = data / "run1"; d.mkdir()
(d / "readme.md").write_text("# hi")
(d / "notes.txt").write_text("hello") # loose txt (no media partner) -> own item
page = c.get("/b/run1/")
assert "view?f=readme.md" in page.text # md -> viewer
assert "view?f=notes.txt" in page.text # txt -> viewer
assert 'href="readme.md" download' not in page.text # not a forced download