Commit dd851bc2 by PLN (Algolia)

bridge: a GATE panel, because every dot was green on the night it failed

PLN, 2026-09-25: "a better 'tableau de bord' to confirm all is armed".

The Bridge answered liveness: units active, processes alive, surface
connected. On 2026-09-24 all of that was green while Tidal 08 was
unarmed, gig-log's MIDI lens held a dead subscription and the black box
had never run. None of those are liveness questions, so no number of
green process dots could have shown them.

The panel renders tools/check-gig.py --json and decides nothing itself.
Severity, wording and — the part that matters on a screen — the FIX all
come from the probe that owns the question, so a probe added to check-gig
appears here for free. A bespoke "armed" tile would have been a third
place the truth lives, which is the failure this rig keeps paying for.

Read-only by construction: check-gig is fenced by gig_gate.mutations()
and tested for it, so unlike RIG UP this button changes nothing and makes
no sound. It runs off the request thread like _Converge, because the repo
layer takes ~40s and holding a poll open reads as a hung dashboard.

Also: two failing rows said nothing useful. gig_gate._detail() shows a
probe's LAST line, and check-mix ended on a paragraph — so the dashboard
read "is tools/gig_record.sh." and "which looks exactly like Tidal
emitting nothing. It isn't." Both now end on the fact: "1 orbit(s) NOT
ARMED — Tidal 08" and "9 route(s) cannot be heard — Tidal 02, ...". A
verdict is read standing up, in a field.

Verified against a throwaway instance on a spare port: 43 checks, NO-GO,
ardour armed at the top naming Tidal 08.
parent 83ced8fd
...@@ -25,7 +25,7 @@ Each line pays off a `TODO_GIG` "NOT TONIGHT" entry or a loss measured in ...@@ -25,7 +25,7 @@ Each line pays off a `TODO_GIG` "NOT TONIGHT" entry or a loss measured in
would have saved the missing 17m44s AND orbit 08. Caveat to settle first: would have saved the missing 17m44s AND orbit 08. Caveat to settle first:
it records `$(pactl get-default-sink).monitor`, which on the 24th was the it records `$(pactl get-default-sink).monitor`, which on the 24th was the
laptop card while Master was hand-linked to the UMC. laptop card while Master was hand-linked to the UMC.
- [ ] **5. Tableau de bord.** The Bridge should render `check-gig.py --json`, - [x] **5. Tableau de bord.** The Bridge should render `check-gig.py --json`,
not grow a bespoke "armed" tile. One source of truth. not grow a bespoke "armed" tile. One source of truth.
- [ ] **6. Ardour's OSC arm lens is broken** — `arm:false roll:false` for 76 - [ ] **6. Ardour's OSC arm lens is broken** — `arm:false roll:false` for 76
recorded minutes, `conflict:true` on every record. Find out whether the recorded minutes, `conflict:true` on every record. Find out whether the
......
"""The gate, on the dashboard: is this rig ready, and is it ARMED?
WHY THIS EXISTS (PLN, 2026-09-25)
*"we should have belt sus and logs validation before a gig and a better
'tableau de bord' to confirm all is armed"* — after a set that lost 17m44s of
audio to a late REC and one whole orbit to a record button nobody had ever
looked at.
The Bridge already answers "is it UP": units active, processes alive, surface
connected. Every one of those was green on 2026-09-24. `Tidal 08` was unarmed,
gig-log's MIDI lens was bound to a dead subscription, and the black box had
never run — and none of that is a liveness question, so no amount of green
process dots could have shown it.
ONE SOURCE OF TRUTH, NOT A SECOND OPINION
This does not re-implement any check. It runs `tools/check-gig.py --json`, the
gate that already owns every probe, its severity and — the part that matters on
a screen — the FIX for each answer. A bespoke "armed" tile on this panel would
be a third place the truth lives, which is the failure this rig keeps paying
for. If a probe is added to check-gig, it appears here for free.
READ-ONLY BY CONSTRUCTION. check-gig is fenced by `gig_gate.mutations()` and
tested for it, so this button is safe to press mid-set, backstage, and at 3am —
the same promise RIG UP makes, minus the doing.
OFF THE REQUEST THREAD. The repo layer takes a few seconds (a GHC sweep, a
silent-eval of the set), which is far too long to hold an HTTP poll open: that
reads as a hung dashboard. So it runs in a worker and the panel watches it
through the ordinary status poll, exactly like `_Converge`.
"""
from __future__ import annotations
import json
import subprocess
import threading
import time
from pathlib import Path
TIDAL = Path(__file__).resolve().parents[2]
CHECK_GIG = TIDAL / "tools" / "check-gig.py"
# The layers a dashboard press should cover. `--audio` is deliberately absent:
# it MAKES SOUND. Nothing on this panel may surprise a room.
LAYERS = ("--repo", "--machine")
# Order the panel reads top-down: what stops a gig, then what to know, then the
# rest. SKIP last because an unanswerable probe is not a problem.
RANK = {"FAIL": 0, "WARN": 1, "OK": 2, "SKIP": 3}
class Gate:
def __init__(self) -> None:
self._lock = threading.Lock()
self._running = False
self._started = 0.0
self._finished = 0.0
self._rc: int | None = None
self._rows: list[dict] = []
self._err = ""
def state(self) -> dict:
with self._lock:
rows = list(self._rows)
counts = {k: 0 for k in RANK}
for r in rows:
counts[r.get("state", "SKIP")] = counts.get(r.get("state", "SKIP"), 0) + 1
return {
"running": self._running,
"rc": self._rc,
"started": self._started,
"finished": self._finished,
"error": self._err,
"counts": counts,
# A verdict, not a count: the whole point is one glance.
"verdict": ("?" if self._rc is None else
"GO" if counts["FAIL"] == 0 else "NO-GO"),
"rows": rows,
}
def start(self) -> dict:
with self._lock:
if self._running:
return {"ok": True, "status": "already-running",
"msg": "the gate is already running"}
self._running = True
self._started = time.time()
self._rc = None
self._err = ""
threading.Thread(target=self._run, daemon=True).start()
return {"ok": True, "status": "started", "msg": "checking the rig…"}
def _run(self) -> None:
rows: list[dict] = []
rc, err = 1, ""
try:
r = subprocess.run(
["python3", str(CHECK_GIG), "--json", *LAYERS],
capture_output=True, text=True, timeout=180, cwd=str(TIDAL))
rc = r.returncode
try:
rows = json.loads(r.stdout or "[]")
except json.JSONDecodeError:
# Say what happened rather than showing an empty green panel —
# a dashboard that fails silently is the thing being fixed here.
err = (r.stderr or r.stdout or "check-gig produced no JSON")[-2000:]
rc = rc or 1
except subprocess.TimeoutExpired:
err, rc = "check-gig timed out after 180s", 124
except (OSError, subprocess.SubprocessError) as e:
err, rc = f"could not run check-gig: {e}", 127
rows.sort(key=lambda r: (RANK.get(r.get("state"), 3), r.get("check", "")))
with self._lock:
self._running = False
self._finished = time.time()
self._rc = rc
self._rows = rows
self._err = err
_gate = Gate()
def state() -> dict:
return _gate.state()
def run() -> dict:
return _gate.start()
...@@ -39,6 +39,7 @@ sys.path.insert(0, str(HERE)) ...@@ -39,6 +39,7 @@ sys.path.insert(0, str(HERE))
import perf as P # noqa: E402 import perf as P # noqa: E402
import launchers as L # noqa: E402 import launchers as L # noqa: E402
import rig as R # noqa: E402 import rig as R # noqa: E402
import gate as G # noqa: E402
import midistream as MS # noqa: E402 import midistream as MS # noqa: E402
UI = HERE / "ui" UI = HERE / "ui"
...@@ -64,6 +65,8 @@ class Handler(BaseHTTPRequestHandler): ...@@ -64,6 +65,8 @@ class Handler(BaseHTTPRequestHandler):
return self._json(L.snapshot()) return self._json(L.snapshot())
if p == "/api/rig": if p == "/api/rig":
return self._json(R.status()) return self._json(R.status())
if p == "/api/gate":
return self._json(G.state())
if p == "/api/midi/ports": if p == "/api/midi/ports":
return self._json(_MIDI.list_ports()) return self._json(_MIDI.list_ports())
if p == "/api/midi/stream": if p == "/api/midi/stream":
...@@ -90,6 +93,11 @@ class Handler(BaseHTTPRequestHandler): ...@@ -90,6 +93,11 @@ class Handler(BaseHTTPRequestHandler):
launch = body.get("launch_apps", True) launch = body.get("launch_apps", True)
r = R.rig_up(bool(launch)) r = R.rig_up(bool(launch))
return self._json(r, 200 if r["ok"] else 400) return self._json(r, 200 if r["ok"] else 400)
if p == "/api/gate":
# Read-only by construction (check-gig is fenced by
# gig_gate.mutations() and tested for it), so unlike /api/rig this
# one changes nothing and makes no sound — safe to press mid-set.
return self._json(G.run())
if p == "/api/launch": if p == "/api/launch":
key = (self._body().get("key") or "").strip() key = (self._body().get("key") or "").strip()
r = L.launch(key) r = L.launch(key)
......
...@@ -102,6 +102,46 @@ ...@@ -102,6 +102,46 @@
.rig-row button:hover{border-color:var(--brand);color:var(--ink)} .rig-row button:hover{border-color:var(--brand);color:var(--ink)}
.rig-log{font-family:var(--mono);font-size:11px;color:var(--faint);white-space:pre-wrap; .rig-log{font-family:var(--mono);font-size:11px;color:var(--faint);white-space:pre-wrap;
margin-bottom:28px;min-height:15px} margin-bottom:28px;min-height:15px}
/* ── the gate: is it READY, not just UP ───────────────────── */
/* The panel above answers liveness. On 2026-09-24 every dot there was green
while Tidal 08 was unarmed, gig-log's MIDI lens held a dead subscription
and the black box had never run. None of those are liveness questions, so
readiness gets its own block — and it renders check-gig's verdicts rather
than inventing a second opinion. */
.gate-sub{color:var(--mute);font-size:12px;line-height:1.55;max-width:62ch;margin:-6px 0 14px}
.gate-sub b{color:var(--ink)}
.gate-head{display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap}
.gatebtn{background:transparent;border:1px solid var(--brand);color:var(--ink);
border-radius:12px;padding:10px 20px;font-family:var(--mono);font-size:12px;
letter-spacing:.08em;cursor:pointer}
.gatebtn:hover:not(:disabled){background:var(--brand-deep);color:#fff}
.gatebtn:disabled{opacity:.55;cursor:progress}
.verdict{font-family:var(--mono);font-size:12px;font-weight:700;letter-spacing:.1em;
border-radius:99px;padding:5px 14px;border:1px solid var(--hairline);color:var(--mute)}
.verdict.go{color:var(--cool);border-color:var(--cool)}
.verdict.nogo{color:var(--critical);border-color:var(--critical)}
.gate-when{font-family:var(--mono);font-size:11px;color:var(--faint)}
.gate-rows{border:1px solid var(--hairline);border-radius:14px;overflow:hidden;
background:var(--raised)}
.gate-row{display:flex;align-items:baseline;gap:12px;padding:9px 16px;
border-bottom:1px solid var(--hairline);font-size:13px}
.gate-row:last-child{border-bottom:0}
.gate-row .sv{font-family:var(--mono);font-size:10px;font-weight:700;letter-spacing:.06em;
min-width:42px;color:var(--faint)}
.gate-row.FAIL{background:#c6282814}
.gate-row.FAIL .sv{color:var(--critical)}
.gate-row.WARN .sv{color:var(--warm,#c98a12)}
.gate-row.OK .sv{color:var(--cool)}
/* SKIP is not a problem: an unanswerable probe (no Ardour on a train) must
not read like a fault, or the panel trains you to ignore it. */
.gate-row.SKIP{opacity:.42}
.gate-row .nm{min-width:150px;font-weight:600}
.gate-row .why{color:var(--mute);font-family:var(--mono);font-size:11px;flex:1}
/* The FIX is the whole reason this is on a screen. A verdict you cannot act
on at 17:55 is decoration. */
.gate-row .fx{display:block;color:var(--ink);font-family:var(--mono);font-size:11px;
margin-top:4px}
.gate-row .fx::before{content:"→ ";color:var(--brand)}
/* ── live MIDI ───────────────────────────────────────────── */ /* ── live MIDI ───────────────────────────────────────────── */
.midi-ctl{display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap} .midi-ctl{display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
.midi-ctl select{background:var(--raised);border:1px solid var(--hairline);color:var(--ink); .midi-ctl select{background:var(--raised);border:1px solid var(--hairline);color:var(--ink);
...@@ -160,7 +200,16 @@ ...@@ -160,7 +200,16 @@
<div class="hub" id="web"></div> <div class="hub" id="web"></div>
<h2>Launch</h2> <h2>Launch</h2>
<div class="hub" id="apps"></div> <div class="hub" id="apps"></div>
<h2>MIDI</h2> <h2>GATE</h2>
<p class="gate-sub">is it <b>ready</b> — armed, wired, recording. The panel above answers whether things are <i>up</i>; every dot there was green on the night one orbit recorded nothing.</p>
<div class="gate-head">
<button class="gatebtn" id="gatebtn">CHECK</button>
<span class="verdict" id="gateverdict">not run</span>
<span class="gate-when" id="gatewhen"></span>
</div>
<div class="gate-rows" id="gaterows"></div>
<div class="rig-log" id="gatelog"></div>
<h2>MIDI</h2>
<div class="midi-ctl"> <div class="midi-ctl">
<select id="midiPort" aria-label="MIDI input port"></select> <select id="midiPort" aria-label="MIDI input port"></select>
<button id="midiToggle" class="off">Connect</button> <button id="midiToggle" class="off">Connect</button>
...@@ -355,6 +404,42 @@ $("#rigup").onclick=async()=>{ ...@@ -355,6 +404,42 @@ $("#rigup").onclick=async()=>{
loadRig(); loadRig();
}; };
// ── the gate ─────────────────────────────────────────────────
// Renders check-gig's own verdicts. Nothing here re-decides anything: the
// severity, the wording and the FIX all come from the probe that owns the
// question, so adding a probe to check-gig.py shows up here for free.
function gateRow(r){
const fix = (r.state==="FAIL"||r.state==="WARN") && r.fix
? `<span class="fx">${r.fix}</span>` : "";
return `<div class="gate-row ${r.state}">
<span class="sv">${r.state}</span>
<span class="nm">${r.check}</span>
<span class="why">${(r.detail||"").slice(0,220)}${fix}</span></div>`;
}
function paintGate(g){
const btn=$("#gatebtn"), v=$("#gateverdict");
btn.disabled = !!g.running;
btn.textContent = g.running ? "CHECKING…" : "CHECK";
const c=g.counts||{};
if(g.running){ v.className="verdict"; v.textContent="checking"; }
else if(g.rc===null){ v.className="verdict"; v.textContent="not run"; }
else { v.className="verdict "+(g.verdict==="GO"?"go":"nogo");
v.textContent=g.verdict; }
$("#gatewhen").textContent = g.finished
? `${c.FAIL||0} fail · ${c.WARN||0} warn · ${c.OK||0} ok · ${c.SKIP||0} skipped`
+ ` — ${new Date(g.finished*1000).toLocaleTimeString()}`
: "";
$("#gaterows").innerHTML = (g.rows||[]).map(gateRow).join("");
$("#gatelog").textContent = g.error || "";
}
async function loadGate(){ try{ paintGate(await api("/api/gate")); }catch(e){} }
$("#gatebtn").onclick=async()=>{
$("#gatebtn").disabled=true; toast("checking the rig…");
try{ const r=await api("/api/gate",{method:"POST"}); toast((r.ok?"":"✗ ")+r.msg,!r.ok); }
catch(e){ toast("✗ "+e,true); $("#gatebtn").disabled=false; }
loadGate();
};
// ── live MIDI ──────────────────────────────────────────────── // ── live MIDI ────────────────────────────────────────────────
let es=null; let es=null;
async function loadMidiPorts(){ async function loadMidiPorts(){
...@@ -441,6 +526,7 @@ loadLaunchers(); setInterval(loadLaunchers,5000); tick(); setInterval(tick,2000) ...@@ -441,6 +526,7 @@ loadLaunchers(); setInterval(loadLaunchers,5000); tick(); setInterval(tick,2000)
// The rig polls faster than the fleet: while a converge is in flight this is the // The rig polls faster than the fleet: while a converge is in flight this is the
// only thing telling PLN it is still working, and a 5 s gap reads as a hang. // only thing telling PLN it is still working, and a 5 s gap reads as a hang.
loadRig(); setInterval(loadRig,2500); loadRig(); setInterval(loadRig,2500);
loadGate(); setInterval(loadGate,2500);
</script> </script>
</body> </body>
</html> </html>
...@@ -307,6 +307,12 @@ def main() -> int: ...@@ -307,6 +307,12 @@ def main() -> int:
" is tools/gig_record.sh.", " is tools/gig_record.sh.",
file=sys.stderr, file=sys.stderr,
) )
# LAST LINE = THE FACT. gig_gate._detail() shows a failing probe's final
# line of output, because a verdict is read standing up in a field. End
# on the names, not on the tail of a paragraph, or the dashboard row
# says "is tools/gig_record.sh." and means nothing.
print(f"\ncheck-mix: {len(unarmed)} orbit(s) NOT ARMED — "
+ ", ".join(n for n, _ in unarmed), file=sys.stderr)
if not do_gain: if not do_gain:
return 1 return 1
...@@ -340,6 +346,11 @@ def main() -> int: ...@@ -340,6 +346,11 @@ def main() -> int:
" which looks exactly like Tidal emitting nothing. It isn't.", " which looks exactly like Tidal emitting nothing. It isn't.",
file=sys.stderr, file=sys.stderr,
) )
# Same reason as the arm summary below: end on the fact. This row used
# to read "which looks exactly like Tidal emitting nothing. It isn't."
# on the dashboard, which is true and says nothing about THIS rig.
print(f"\ncheck-mix: {len(problems)} route(s) cannot be heard — "
+ ", ".join(n for n, _ in problems), file=sys.stderr)
return 1 return 1
if unarmed: if unarmed:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment