`/heid-bug-hunt` on U2's diff, four arms, artifact-only. Eight findings were real against live code; a ninth was already closed by v0.2.1 and is recorded as declined. Full triage in persistent-memory.d/2026-09-22-bug-hunt-panel.md. THE LOCK LIFECYCLE (4/4 convergent, and two defects in one place) `_Locked.__exit__` unlinked `.marks.lock` on the no-op path so a booth that had never been marked was left exactly as it was found. `flock` binds to an INODE: unlinking it under a blocked waiter leaves that waiter holding an exclusive lock on a deleted file while the next writer creates a fresh lock and takes it immediately. Two processes then run the read-modify-write concurrently, the later os.replace drops the earlier one's mark, and both obeyed the protocol. The cleanup existed to protect the booth's TTL, and was failing at that too: creating or removing a directory entry bumps the DIRECTORY's mtime, which is what `_newest_mtime` seeds from. The guard's comment reasons about the lock file's own mtime and misses that the directory moved underneath it. One fix: never unlink the lock, exempt `.<name>.lock` dotfiles from `_newest_mtime`, and restore the directory's mtime after creating one. THE READ PATH'S BLAST RADIUS `_clean_text` did `(text or "").replace(...)` and `marks_for` sorts on `(created, id)`, so a stored `text` that was a dict or a `created` that was a number raised out of the read path. `list_booths` reads every booth's marks on every index load, so one hand-edited file returned 500 for `/` and `/healthz` across all 25 booths. Guarded in two layers — a named type check and a `_hydrate_safe` backstop that cannot raise — and an unreadable mark now renders as ⚠ broken rather than as an empty note. ALSO - import_legacy_asks stamped `created` at whole-second resolution, so two sidecars from the same second lost the ordering the importer had just established and re-sorted alphabetically. Microseconds, per the stated `(mtime, name)` rule. - The five mark-write routes ran a blocking flock on the event loop; they now dispatch through run_in_threadpool, asserted structurally like INV-1. - `/answer` 500'd on a non-string `notes` form value where `/note` handled it. - The inline-doc tile had a flag control and no note field. - The marks panel was suppressed on any booth carrying a links.md. - The viewer's arrow keys and Escape threw away a note being typed. CLI `booth marks` printed a traceback and exited 0 on a failed read, and `--wait` emitted a whole JSON document per poll. `booth answer --wait` read a damaged file as "not yet" and spun the full hour. Both now use real exit codes — 0 ok, 1 unanswered/timed-out, 2 no such pick, 3 unreadable — and `--wait` prints once. `marks.read_error()` lets the CLI ask what the page must not: the browser stays lenient, the machine consumer gets the truth. `scripts/booth` had no tests; it has five now, run against the real script under the system python3, which also makes them a live check on INV-1. 275 tests (253 before). Live service restarted, 25/25 booth pages verified 200.
377 lines
19 KiB
HTML
377 lines
19 KiB
HTML
{% extends "base.html" %}
|
||
{# 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
|
||
three" impossible rather than merely unlikely. #}
|
||
{% macro blurtoggle(name_url, it, cls='') -%}
|
||
<form class="blurtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/blur">
|
||
<input type="hidden" name="f" value="{{ it.name }}">
|
||
<input type="hidden" name="on" value="{{ '0' if it.blurred else '1' }}">
|
||
<button title="{{ 'un-blur this item' if it.blurred else 'blur this item — cosmetic only, the file is still served' }}"
|
||
aria-label="{{ 'un-blur' if it.blurred else 'blur' }} {{ it.name }}"
|
||
>{{ '◉ blurred' if it.blurred else '◌ blur' }}</button>
|
||
</form>
|
||
{%- endmacro %}
|
||
|
||
{# The per-item MARK controls: flag (the operator pointing at this one) and a
|
||
note field. Same macro discipline as blurtoggle above — three item branches,
|
||
one definition. `marks` here is THIS item's marks, from item_marks. #}
|
||
{% macro markcontrols(name_url, it, marks, cls='') -%}
|
||
{% set flagged = marks | selectattr('shape', 'equalto', 'flag') | list | length > 0 %}
|
||
<form class="flagtoggle {{ cls }}" method="post" action="/b/{{ name_url }}/flag">
|
||
<input type="hidden" name="target" value="{{ it.name }}">
|
||
<input type="hidden" name="on" value="{{ '0' if flagged else '1' }}">
|
||
<button title="{{ 'un-flag this item' if flagged else 'flag this one — the session that posted it can read the selection' }}"
|
||
aria-label="{{ 'un-flag' if flagged else 'flag' }} {{ it.name }}"
|
||
>{{ '✔ flagged' if flagged else '○ flag' }}</button>
|
||
</form>
|
||
{%- endmacro %}
|
||
|
||
{# An item's notes, rendered BESIDE the artifact — the 2026-09-09 ruling that a
|
||
judgment belongs with the thing it is about, applied to notes as well as
|
||
picks. The add-field is a <details> so 270 tiles do not each carry an open
|
||
textarea. #}
|
||
{% macro marknotes(name_url, it, marks) -%}
|
||
{% for m in marks if m.shape == 'note' %}
|
||
<div class="item-note" id="mark-{{ m.id }}">
|
||
<pre>{{ m.text }}</pre>
|
||
<form method="post" action="/b/{{ name_url }}/unmark">
|
||
<input type="hidden" name="mark" value="{{ m.id }}">
|
||
<button class="mark-x" title="withdraw this note">×</button>
|
||
</form>
|
||
</div>
|
||
{% endfor %}
|
||
<details class="item-addnote">
|
||
<summary>+ note</summary>
|
||
<form method="post" action="/b/{{ name_url }}/note">
|
||
<input type="hidden" name="target" value="{{ it.name }}">
|
||
<textarea name="text" rows="2" placeholder="a note on this item"></textarea>
|
||
<button type="submit">Add</button>
|
||
</form>
|
||
</details>
|
||
{%- endmacro %}
|
||
|
||
{% block title %}{{ name }} · The Booth{% endblock %}
|
||
{% block content %}
|
||
<div class="boothhead">
|
||
<a class="back" href="/">‹ all booths</a>
|
||
<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>
|
||
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
|
||
{# A durable multi-writer board gets no one-click wipe — same rule as the
|
||
kept lane on the index. Remove rows with the per-row ×, or release the
|
||
board from the index and wipe it from there. #}
|
||
{# Promote or release without going back to the index. `next` keeps you on
|
||
this page instead of bouncing you to /. #}
|
||
{% if kept %}
|
||
<form class="keep-lg" method="post" action="/b/{{ name_url }}/unkeep">
|
||
<input type="hidden" name="next" value="/b/{{ name_url }}/">
|
||
<button title="release — rejoins the TTL sweep">★ kept — release</button>
|
||
</form>
|
||
{% else %}
|
||
<form class="keep-lg" method="post" action="/b/{{ name_url }}/keep">
|
||
<input type="hidden" name="next" value="/b/{{ name_url }}/">
|
||
<button title="keep — exempt from the TTL sweep">☆ keep</button>
|
||
</form>
|
||
{% endif %}
|
||
{% if not board %}
|
||
<form class="wipe wipe-lg" method="post" action="/b/{{ name_url }}/delete"
|
||
onsubmit="return confirm('Wipe this booth now?')">
|
||
<button>Wipe now</button>
|
||
</form>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% if uploaded %}
|
||
<div class="pickup-note">
|
||
📦 Pickup <code>{{ name }}</code>
|
||
<button type="button" class="copy-btn" data-copy="{{ name }}" title="copy id to clipboard">⧉ copy</button>
|
||
— download files below, or on nh3-dev grab <code>~/booth-data/{{ name }}/</code>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{# The marks panel: the session's questions, the operator's notes, and the way
|
||
back to the flagged items. Always rendered on a gallery booth — the add-note
|
||
field is a control, not a result, so it has to be there before the first
|
||
mark exists. #}
|
||
{# `marks or not board`: the standing link board renders as a board rather than
|
||
a gallery, and the add-note control would be noise on it — but the
|
||
suppression was unconditional, so a pick declared on a booth that happens to
|
||
carry a links.md had no form to answer it and nothing said so. #}
|
||
{% if marks or not board %}
|
||
{% include "_marks.html" %}
|
||
{% endif %}
|
||
|
||
{% if board %}
|
||
{# THE STANDING LINK BOARD. Every agent session on the fleet appends here, so
|
||
this is the one booth where the useful granularity is the ROW, not the
|
||
folder. Rendered as real UI rather than a markdown blob so a dead link can
|
||
be removed without hand-editing the file — and so provenance (who posted
|
||
it, when) is readable at a glance, which is the whole reason a bare URL
|
||
three days old is useless.
|
||
|
||
ORDER: pinned rows first, then newest-first (order_for_display). Pin a row
|
||
with the ★ so the ones you care about stop scrolling off the bottom.
|
||
|
||
ONE <form>, not one-per-row: checkboxes drive the bulk delete, while the
|
||
per-row × and ★ are submit buttons with their own `formaction`. That keeps
|
||
all three actions in a single form (nested forms are invalid HTML) AND lets
|
||
every one work with JS off — JS only adds select-all and the live count.
|
||
|
||
Every action posts a CONTENT ID, never a row number: another session can
|
||
append between this page rendering and a click, and an index would then hit
|
||
a neighbour. An id matches the row the operator saw, or nothing. #}
|
||
{% set pinned_n = board | selectattr('pinned') | list | length %}
|
||
<form class="board" method="post" action="/b/{{ name_url }}/unlink-many" id="boardform">
|
||
<div class="board-head">
|
||
<label class="board-selall" title="select all"><input type="checkbox" id="board-selall"></label>
|
||
<span class="board-title">{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if pinned_n %} · {{ pinned_n }} pinned{% endif %}</span>
|
||
<span class="board-note">pinned first · newest on top · ★ pins a row · tick rows to delete</span>
|
||
<span class="board-spacer"></span>
|
||
<button type="submit" class="board-del-sel" id="board-del-sel"
|
||
formaction="/b/{{ name_url }}/unlink-many">🗑 delete <span id="board-selcount">0</span></button>
|
||
</div>
|
||
{% for e in board %}
|
||
<div class="board-row{% if e.pinned %} is-pinned{% endif %}">
|
||
<input class="board-check" type="checkbox" name="sel" value="{{ e.id }}" aria-label="select {{ e.desc }}">
|
||
<button type="submit" class="board-pin{% if e.pinned %} on{% endif %}" formaction="/b/{{ name_url }}/pin"
|
||
name="entry" value="{{ e.id }}" aria-pressed="{{ 'true' if e.pinned else 'false' }}"
|
||
title="{{ 'unpin' if e.pinned else 'pin to top' }}">{{ '★' if e.pinned else '☆' }}</button>
|
||
<div class="board-main">
|
||
<a class="board-link" href="{{ e.url }}" target="_blank" rel="noopener">{{ e.desc }}</a>
|
||
<div class="board-url">{{ e.url }}</div>
|
||
</div>
|
||
<div class="board-meta">
|
||
{% if e.who %}<span class="board-who">{{ e.who }}</span>{% endif %}
|
||
{% if e.when %}<span class="board-when">{{ e.when }}</span>{% endif %}
|
||
</div>
|
||
<button type="button" class="copy-btn board-copy" data-copy="{{ e.url }}" title="copy URL">⧉</button>
|
||
<button type="submit" class="board-rm-btn" formaction="/b/{{ name_url }}/unlink"
|
||
name="entry" value="{{ e.id }}" title="remove this link"
|
||
data-desc="{{ e.desc }}" data-url="{{ e.url }}">×</button>
|
||
</div>
|
||
{% endfor %}
|
||
</form>
|
||
{% endif %}
|
||
|
||
{% if not items and not board and not marks %}
|
||
<div class="empty">This booth is empty.</div>
|
||
{% elif items %}
|
||
{# `elif items` and not a bare `else`: a board booth has NO gallery items (its
|
||
links.md is rendered as the board above and filtered out), so a plain else
|
||
would emit an empty <div class="gallery"> under the board. #}
|
||
<div class="gallery">
|
||
{% for it in items %}
|
||
{% if it.doc and it.rendered is not none %}
|
||
{# Docs render INLINE, collapsible, and closable — not a link to a
|
||
separate page. <details open> is native collapse (works with JS off);
|
||
the ✕ hides the item for the session (JS, progressive enhancement).
|
||
The item spans the full grid width so prose has room to read. #}
|
||
<figure class="item item-doc{% if it.blurred %} blurred{% endif %}" data-name="{{ it.name }}" data-item="{{ it.name }}" id="item-{{ it.name }}">
|
||
{% if it.blurred %}
|
||
{# Inline docs need this MORE than images, not less: a rendered doc puts
|
||
its text straight on the page, so "blur the picture" logic that skips
|
||
the doc branch leaves the most readable content unblurred. Missed on
|
||
the first pass; caught by a live check, not by the suite. #}
|
||
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
|
||
{% endif %}
|
||
<details class="doc-inline" open>
|
||
<summary class="doc-bar">
|
||
<span class="doc-chevron" aria-hidden="true">▸</span>
|
||
<span class="doc-name">{{ it.name }}</span>
|
||
<span class="doc-spacer"></span>
|
||
<a class="doc-act" href="view?f={{ it.url }}" title="open full page">⤢</a>
|
||
<a class="doc-act" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
||
{{ blurtoggle(name_url, it, 'doc-act') }}
|
||
{{ markcontrols(name_url, it, item_marks.get(it.name, []), 'doc-act') }}
|
||
<button type="button" class="doc-act doc-close" title="close (hide for now)" aria-label="close">✕</button>
|
||
</summary>
|
||
{% if it.rendered_html %}
|
||
<article class="markdown-body doc-body">{{ it.rendered|safe }}</article>
|
||
{% else %}
|
||
<pre class="textview doc-body">{{ it.rendered }}</pre>
|
||
{% endif %}
|
||
{# The doc branch had `markcontrols` and not `marknotes`, so the
|
||
operator could point at a report and not write down why — on the
|
||
one item kind whose whole content is prose. Exactly the
|
||
"patched two of three" failure the blurtoggle macro above was
|
||
written to prevent, recurring on the macro written to prevent it. #}
|
||
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
|
||
</details>
|
||
</figure>
|
||
{% else %}
|
||
<figure class="item item-{{ it.kind }}{% if it.blurred %} blurred{% endif %}{% if item_marks.get(it.name, []) | selectattr('shape', 'equalto', 'flag') | list %} is-flagged{% endif %}" data-item="{{ it.name }}" id="item-{{ it.name }}">
|
||
{% if it.blurred %}
|
||
{# Click-to-reveal is per-viewer and client-side: nothing is persisted, so
|
||
a reload re-hides it. No-JS degrades to STAYS BLURRED, which is the
|
||
safe direction to fail in. #}
|
||
<button type="button" class="reveal" aria-label="reveal {{ it.name }}">👁 reveal</button>
|
||
{% endif %}
|
||
{% if it.kind == 'image' %}
|
||
<a href="view?f={{ it.url }}"><img loading="lazy" src="{{ it.url }}" alt="{{ it.name }}"></a>
|
||
{% elif it.kind == 'video' %}
|
||
{# preload="none": a booth of a dozen webms was fetching them
|
||
all at page load ("metadata" still pulls real ranges per
|
||
file); nothing loads until the viewer hits play #}
|
||
<video controls preload="none" src="{{ it.url }}"></video>
|
||
{% elif it.kind == 'audio' %}
|
||
<audio controls preload="none" src="{{ it.url }}"></audio>
|
||
{% elif it.doc %}
|
||
{# a doc too large to inline (over DOC_MAX_BYTES) still links out #}
|
||
<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 %}
|
||
{% if it.kind == 'other' %}
|
||
{# Always render the caption row now: it carries the blur toggle, and
|
||
"no caption" is not a reason to deny an item its controls. #}
|
||
<figcaption>
|
||
{% if it.caption %}<span class="cap-text">{{ it.caption }}</span>{% endif %}
|
||
{{ blurtoggle(name_url, it) }}
|
||
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
|
||
</figcaption>
|
||
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
|
||
{% else %}
|
||
<figcaption>
|
||
<a class="dl-link" href="{{ it.url }}" download title="download {{ it.name }}">⬇</a>
|
||
<span class="cap-text">{{ it.caption or it.name }}</span>
|
||
{{ blurtoggle(name_url, it) }}
|
||
{{ markcontrols(name_url, it, item_marks.get(it.name, [])) }}
|
||
</figcaption>
|
||
{{ marknotes(name_url, it, item_marks.get(it.name, [])) }}
|
||
{% endif %}
|
||
</figure>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<script>
|
||
/* Copy-to-clipboard for any .copy-btn[data-copy]. The Booth serves over plain
|
||
HTTP on a LAN IP, where navigator.clipboard is undefined (secure-context
|
||
only) — so fall back to a hidden-textarea execCommand('copy'). */
|
||
(function () {
|
||
function copyText(t) {
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
return navigator.clipboard.writeText(t);
|
||
}
|
||
var ta = document.createElement('textarea');
|
||
ta.value = t;
|
||
ta.setAttribute('readonly', '');
|
||
ta.style.position = 'fixed';
|
||
ta.style.top = '-1000px';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
try { document.execCommand('copy'); } catch (e) {}
|
||
document.body.removeChild(ta);
|
||
return Promise.resolve();
|
||
}
|
||
document.querySelectorAll('.copy-btn').forEach(function (btn) {
|
||
var label = btn.textContent;
|
||
btn.addEventListener('click', function () {
|
||
copyText(btn.getAttribute('data-copy')).then(function () {
|
||
btn.classList.add('copied');
|
||
btn.textContent = '✓ copied';
|
||
setTimeout(function () { btn.classList.remove('copied'); btn.textContent = label; }, 1300);
|
||
});
|
||
});
|
||
});
|
||
})();
|
||
|
||
/* Inline-doc ✕ closes (hides) a rendered doc for the session. The button sits
|
||
inside <summary>, so without this its click would just toggle the <details>
|
||
open/closed — stopPropagation + preventDefault make ✕ mean "close", not
|
||
"collapse". Collapse stays available via the rest of the summary bar. With
|
||
JS off the button is inert and collapse via <details> still works. */
|
||
(function () {
|
||
/* A form inside <summary> would otherwise collapse the doc on submit. */
|
||
document.querySelectorAll('.doc-bar .blurtoggle').forEach(function (f) {
|
||
f.addEventListener('click', function (ev) { ev.stopPropagation(); });
|
||
});
|
||
document.querySelectorAll('.doc-close').forEach(function (btn) {
|
||
btn.addEventListener('click', function (ev) {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
var item = btn.closest('.item-doc');
|
||
if (item) item.classList.add('is-closed');
|
||
});
|
||
});
|
||
})();
|
||
|
||
/* Link-board multi-select. PROGRESSIVE ENHANCEMENT: the checkboxes, the per-row
|
||
× / ★, and the bulk 🗑 all submit as plain form POSTs with JS off — this only
|
||
adds select-all, a live count, and disabling 🗑 when nothing is ticked. The
|
||
per-row × confirm reads desc/url from data-* attributes rather than being
|
||
interpolated into an inline handler, so an arbitrary agent-posted description
|
||
(quotes, newlines) can never break out into the page's JS. */
|
||
(function () {
|
||
var form = document.getElementById('boardform');
|
||
if (!form) return;
|
||
var boxes = Array.prototype.slice.call(form.querySelectorAll('.board-check'));
|
||
var selall = document.getElementById('board-selall');
|
||
var delBtn = document.getElementById('board-del-sel');
|
||
var countEl = document.getElementById('board-selcount');
|
||
|
||
function selected() { return boxes.filter(function (b) { return b.checked; }); }
|
||
function refresh() {
|
||
var n = selected().length;
|
||
if (countEl) countEl.textContent = n;
|
||
if (delBtn) delBtn.disabled = n === 0;
|
||
if (selall) {
|
||
selall.checked = n > 0 && n === boxes.length;
|
||
selall.indeterminate = n > 0 && n < boxes.length;
|
||
}
|
||
}
|
||
if (selall) {
|
||
selall.addEventListener('change', function () {
|
||
boxes.forEach(function (b) { b.checked = selall.checked; });
|
||
refresh();
|
||
});
|
||
}
|
||
boxes.forEach(function (b) { b.addEventListener('change', refresh); });
|
||
|
||
// Bulk delete: confirm with the count. Attached to the button (not the form's
|
||
// submit) so the per-row × / ★ submits — which share this form — are unaffected.
|
||
if (delBtn) {
|
||
delBtn.addEventListener('click', function (ev) {
|
||
var n = selected().length;
|
||
if (n === 0) { ev.preventDefault(); return; }
|
||
if (!confirm('Delete ' + n + ' selected link' + (n === 1 ? '' : 's') + '?\n\nThe rest of the board is untouched.')) {
|
||
ev.preventDefault();
|
||
}
|
||
});
|
||
}
|
||
|
||
form.querySelectorAll('.board-rm-btn').forEach(function (btn) {
|
||
btn.addEventListener('click', function (ev) {
|
||
var d = btn.getAttribute('data-desc') || '';
|
||
var u = btn.getAttribute('data-url') || '';
|
||
if (!confirm('Remove this link?\n\n' + d + '\n' + u + '\n\nThe rest of the board is untouched.')) {
|
||
ev.preventDefault();
|
||
}
|
||
});
|
||
});
|
||
|
||
refresh();
|
||
})();
|
||
|
||
/* Blur reveal. WARNING: this handler previously sat after the content
|
||
block's closing tag, which in a
|
||
child template Jinja DISCARDS — the button rendered and did nothing, and
|
||
two commits plus a README claimed click-to-reveal worked. Anything that
|
||
must reach the page belongs inside the content block. Verified now by
|
||
grepping the SERVED html for this function, not the template for the text.
|
||
Per-viewer and never persisted: a reload re-hides. */
|
||
document.querySelectorAll('.item.blurred .reveal').forEach(function (btn) {
|
||
btn.addEventListener('click', function (ev) {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
var fig = btn.closest('.item');
|
||
var on = fig.classList.toggle('revealed');
|
||
btn.textContent = on ? '🙈 hide' : '👁 reveal';
|
||
});
|
||
});
|
||
</script>
|
||
{% endblock %}
|