← Commit history

Publish 1.0.76

John Lauer ·85d02e5fd4 ·1mo ago ·parent 0c2b171
3 files changed +162−27
dashboard/kicad_dashboard.py+160−25
@@ -35,7 +35,6 @@ PROXY NOTE (paid for live): adom.cloud STRIPS query strings on sub-paths, so the target rides the PATH (/api/status/<target>/force), never ?target=. The SSE route also needs the padding + no-transform headers or the proxy buffers every byte. """-import collections import json import os import queue@@ -136,7 +135,7 @@ def say_send(phrase):     button opened a tab and delivered nothing.      Still NEW threads only: resume and typing-into-existing stay parked.-    Every failure path returns ok:False with a readable `why`; the page then+    Every failure returns ok:False with a readable `why`; the page then     error-toasts AND copies the phrase, so a user is never stranded."""     import urllib.request @@ -157,8 +156,8 @@ def say_send(phrase):         cur = (d.get("updater") or d).get("current_version") or d.get("current_version")         if cur and _ver_tuple(cur) < MIN_SHELL_VERSION:             return {"ok": False, "shellTooOld": True, "currentVersion": str(cur),-                    "why": f"your Hydrogen ({cur}) predates the Send to AI fix; "-                           f"update to 1.0.142 or newer to enable it. The phrase was copied instead."}+                    "why": f"your Hydrogen ({cur}) predates the Send to AI fix; update to "+                           f"1.0.142 or newer to enable it. The phrase was copied instead."}     except Exception:         pass   # cannot read the updater: fall through rather than block a healthy shell @@ -184,30 +183,169 @@ def say_send(phrase):         hint = d.get("_hints") or d.get("_hint") or res.get("_hints") or ""         if isinstance(hint, dict):             hint = " ".join(str(v) for v in hint.values())-        return {"ok": False, "why": ("the AI panel opened a thread but did not confirm delivery. "-                                     + (str(hint)[:200] if hint else "")).strip()}+        return {"ok": False, "why": ("the AI panel opened a thread but did not confirm "+                                     "delivery. " + (str(hint)[:200] if hint else "")).strip()}     return {"ok": True, "deliveredTo": str(delivered), "sid": d.get("sid")}  -# Console ring + logger. RESTORED 2026-08-24: deleting the dormant-# stage_phrase() body took these with it, because they sat between it and-# build_status(). The grep-the-installed-artifact checks all passed while the-# app could not even boot - a static check proves a string is present, only-# RUNNING it proves the program works.-CONSOLE = collections.deque(maxlen=400)+def ab(target, verb, args=None, timeout=140):+    """One bridge verb. Returns the parsed payload dict (never raises)."""+    cmd = ["adom-bridge", "--ai-thread", AI_THREAD]+    if target and target != "-":+        cmd += ["--target", target]+    cmd += [verb, json.dumps(args or {})]+    try:+        p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)+        d = json.loads(p.stdout or "{}")+        # ab wraps some payloads in output-as-JSON-string; unwrap when it is one+        out = d.get("output")+        if isinstance(out, str) and out.strip().startswith("{"):+            try:+                inner = json.loads(out)+                merged = dict(d)+                merged.update(inner.get("data") if isinstance(inner.get("data"), dict) else inner)+                return merged+            except Exception:+                pass+        return d+    except subprocess.TimeoutExpired:+        return {"error": f"no answer within {timeout}s", "timedOut": True}+    except Exception as e:+        # never leak a raw Python exception (a TimeoutExpired repr used to land+        # verbatim in the Bridge LED as "Command '['adom-bridge', ...]'")+        return {"error": str(e).split("\n")[0][:120]}+++def get_targets():+    try:+        p = subprocess.run(["adom-bridge", "targets"], capture_output=True, text=True, timeout=30)+        d = json.loads(p.stdout or "{}")+        return [t.get("name") for t in (d.get("targets") or []) if t.get("name")]+    except Exception:+        return []+++# ------------------------------------------------------------------- SSE hub+class Hub:+    def __init__(self):+        self._clients = []+        self._lock = threading.Lock()++    def add(self):+        q = queue.Queue(maxsize=64)+        with self._lock:+            self._clients.append(q)+        return q++    def drop(self, q):+        with self._lock:+            if q in self._clients:+                self._clients.remove(q)++    def count(self):+        with self._lock:+            return len(self._clients)++    def broadcast(self, event, data):+        msg = f"event: {event}\ndata: {json.dumps(data)}\n\n"+        with self._lock:+            for q in list(self._clients):+                try:+                    q.put_nowait(msg)+                except queue.Full:+                    pass+++HUB = Hub()+CONSOLE = [] _console_lock = threading.Lock()   def log(line):-    """One line to stdout and to the /console ring the dashboard exposes."""-    line = f"[{time.strftime('%H:%M:%S')}] {line}"+    with _console_lock:+        CONSOLE.append(f"{time.strftime('%H:%M:%S')} {line}")+        del CONSOLE[:-300]+    print(line, file=sys.stderr, flush=True)+++# ------------------------------------------------------------------ LED model+DEV_MODE = bool(os.environ.get("KICAD_DASHBOARD_DEV"))+++def _suite_led():+    """DEV-ONLY (John, 2026-08-18: "the daily suite should not be in here. no+    normal adom user runs that. that's just for me as the main dev"). Shown only+    with --dev / KICAD_DASHBOARD_DEV=1: the nightly verb-suite verdict plus+    container-cron liveness (the cron died unnoticed for 4 days once)."""     try:-        with _console_lock:-            CONSOLE.append(line)+        try:+            hb = time.time() - os.path.getmtime("/tmp/cron-heartbeat")+            if hb > 600:+                return {"label": "Daily suite", "state": "err",+                        "detail": f"container cron DEAD ({int(hb // 60)} min since heartbeat)",+                        "_hint": "The container's cron stopped, so the 6:30am verb suite will not run. "+                                 "Restart cron in the container and re-check."}+        except OSError:+            pass+        rp = "/home/adom/.kicad-verbtest-last-report.json"+        if not os.path.exists(rp):+            rp = "/tmp/kicad-verbtest/report.json"+        age_h = (time.time() - os.path.getmtime(rp)) / 3600+        t = (json.load(open(rp)).get("totals")) or {}+        det = f"{t.get('pass', '?')} pass / {t.get('fail', '?')} fail ({int(age_h)}h ago)"+        if age_h > 48:+            return {"label": "Daily suite", "state": "warn", "detail": f"stale: {det}",+                    "_hint": "No suite run in over 48h. Check the cron entry and /tmp/kicad-verbtest/cron.log."}+        if t.get("fail"):+            return {"label": "Daily suite", "state": "warn", "detail": det,+                    "_hint": "Failures are triaged on the wiki issues; the suite files a ticket only for NEW ones."}+        return {"label": "Daily suite", "state": "ok", "detail": det,+                "_hint": "Every verb passed on the last nightly run."}     except Exception:-        pass-    print(line, flush=True)-+        return {"label": "Daily suite", "state": "off", "detail": "no suite report yet",+                "_hint": "The nightly suite writes ~/.kicad-verbtest-last-report.json; "+                         "it runs at 6:30am when the box is idle."}++++# ── The dock card's ROLLUP (wiki #43, John) ────────────────────────────────+# Without a top-level `state` the dock takes our WORST led, so a yellow+# "update available" turns the whole card yellow while the bridge answers+# perfectly. That is not what a user should read off a card. We own the+# rollup, so we own the judgement about which facets are load-bearing.+#+# I initially declined this, reasoning that a disagreement between our rollup+# and the dock's derivation would be a signal worth seeing. John's argument is+# better: the card is a USER-facing health light, not a debugging surface, and+# an advisory LED must not be able to raise an alarm.+_CRITICAL_LEDS = ("relay", "box", "bridge", "kicad")   # can this thing work at all+_ADVISORY_LEDS = ("update", "focus", "presence", "plugin")  # informational, never alarming+++def rollup_state(leds: dict) -> dict:+    """Green when the bridge can actually do its job; yellow only when a+    CRITICAL facet is degraded; red when one is broken."""+    def st(k):+        return (leds.get(k) or {}).get("state")+    crit = [st(k) for k in _CRITICAL_LEDS if st(k)]+    if "err" in crit:+        worst, why = "err", [k for k in _CRITICAL_LEDS if st(k) == "err"]+    elif "warn" in crit:+        worst, why = "warn", [k for k in _CRITICAL_LEDS if st(k) == "warn"]+    else:+        worst, why = "ok", []+    degraded = [k for k in _ADVISORY_LEDS if st(k) in ("warn", "err")]+    return {+        "state": worst,+        "reason": (", ".join(why) if why else+                   ("healthy" + (f" (advisory: {', '.join(degraded)})" if degraded else ""))),+        "criticalLeds": list(_CRITICAL_LEDS),+        "advisoryLeds": list(_ADVISORY_LEDS),+        "_hint": ("Top-level rollup for the dock card LED. Advisory facets (an available "+                  "update, focus bounces, user presence, a plugin that only degrades "+                  "background navigation) never turn the card yellow, because the bridge "+                  "still works. Only relay/box/bridge/kicad can."),+    }  def build_status(target):     """The LED payload. Each facet: {label, state: ok|warn|err|off, detail, _hint}."""@@ -884,12 +1022,9 @@ def do_action(target, name, args):     if name == "say":         return {"success": True, "phrases": say_phrases()}     if name == "try_phrase":-        # RETIRED 2026-08-24. This was the staged-prompt path, parked on ship-        # night and then superseded by Send to AI (say_send, one call, new-        # threads only). Its implementation is DELETED rather than dormant:-        # it contained an /ai-threads/resume call, and resume stays parked by-        # ruling, so leaving it alive-but-flagged put a forbidden call one-        # flag flip from running. Nothing calls this; answer honestly.+        # RETIRED 2026-08-24: superseded by Send to AI (say_send: one call, new+        # threads only). Body DELETED rather than dormant because it contained+        # an /ai-threads/resume call, and resume stays parked by ruling.         return {"success": False, "retired": True,                 "error": "try_phrase is retired. Use the dashboard's Send to AI "                          "(POST /api/say-send), which opens a NEW thread and submits.",
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "kicad-bridge",-  "version": "1.0.75",+  "version": "1.0.76",   "type": "app",   "description": "Skills for your container so your AI knows how to drive the KiCad bridge. The bridge runtime itself is the release zip; Adom Bridge loads that.",   "tags": [
page.json+1−1
@@ -4,7 +4,7 @@   "slug": "kicad-bridge",   "title": "KiCad - the KiCad Bridge",   "brief": "Reference implementation of the KiCad bridge — multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.",-  "version": "1.0.75",+  "version": "1.0.76",   "tags": [     "kicad",     "pcb",