← Commit history

Publish 1.0.59

John Lauer ·c9f17d9a47 ·1mo ago ·parent 09e168f
3 files changed +107−27
dashboard/kicad_dashboard.py+105−25
@@ -49,7 +49,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote, urlparse -VERSION = "2.2.1"+VERSION = "2.2.2" AI_THREAD = "kicad-dashboard" DEFAULT_PORT = 8931 UI_DIR = Path(__file__).resolve().parent / "ui"@@ -95,32 +95,60 @@ def stage_phrase(phrase):     """'Try it': stage the phrase (unsubmitted) into this app's own AI thread     through the Hydrogen control API. Reachable from WSL2 under mirrored     networking; from a cloud container it is not, and the page then falls back-    to copying. Never raises; returns {staged, reason, sid}."""+    to copying. Never raises; returns {staged, reason, sid|tid}.++    Lookup recipe measured against the live API (dock thread, 2026-08-23):+    CENSUS FIRST (GET /ai-threads -> groups[].threads[] with tags, sid|null,+    tid), HISTORY SECOND (GET /ai-threads/history?tag=... -> items[].id; it+    EXCLUDES open threads by design, so history-first returns empty exactly+    when the user has the thread open). Deliver with sid when present, else+    tid. A closed thread is resumed (POST /ai-threads/resume {ai,id}) before+    run-prompt {sid:id}."""     import urllib.request     tag = "app:adom/kicad-bridge"++    def _get(path):+        with urllib.request.urlopen(f"{HYDROGEN_CONTROL}{path}", timeout=2) as r:+            return json.loads(r.read().decode("utf-8") or "{}")++    def _post(path, body):+        req = urllib.request.Request(f"{HYDROGEN_CONTROL}{path}",+                                     data=json.dumps(body).encode("utf-8"), method="POST",+                                     headers={"Content-Type": "application/json"})+        with urllib.request.urlopen(req, timeout=4) as r:+            return json.loads(r.read().decode("utf-8") or "{}")+     try:-        with urllib.request.urlopen(-                f"{HYDROGEN_CONTROL}/ai-threads/history?tag={tag}", timeout=2) as r:-            hist = json.loads(r.read().decode("utf-8") or "{}")+        census = _get("/ai-threads")     except Exception as e:         return {"staged": False, "reason": f"hydrogen control api unreachable ({e.__class__.__name__})"}-    threads = hist.get("threads") or hist.get("items") or hist.get("data") or []-    sid = None-    for t in threads if isinstance(threads, list) else []:-        sid = t.get("sid") or t.get("id")-        if sid:-            break-    if not sid:-        return {"staged": False, "reason": f"no AI thread tagged {tag} yet"}+    # 1. an OPEN thread tagged for this app+    for g in census.get("groups") or []:+        for t in g.get("threads") or []:+            if tag in (t.get("tags") or []):+                target = {"sid": t["sid"]} if t.get("sid") else {"tid": t.get("tid")}+                try:+                    _post("/ai-threads/run-prompt", {**target, "prompt": phrase, "submit": False})+                    return {"staged": True, "where": "open thread", **target}+                except Exception as e:+                    return {"staged": False, "reason": f"run-prompt failed ({e.__class__.__name__})", **target}+    # 2. a CLOSED thread from history: resume it, then stage     try:-        body = json.dumps({"sid": sid, "prompt": phrase, "submit": False}).encode("utf-8")-        req = urllib.request.Request(f"{HYDROGEN_CONTROL}/ai-threads/run-prompt", data=body,-                                     method="POST", headers={"Content-Type": "application/json"})-        with urllib.request.urlopen(req, timeout=4) as r:-            r.read()-        return {"staged": True, "sid": sid}+        hist = _get(f"/ai-threads/history?tag={tag}")     except Exception as e:-        return {"staged": False, "reason": f"run-prompt failed ({e.__class__.__name__})", "sid": sid}+        return {"staged": False, "reason": f"history lookup failed ({e.__class__.__name__})"}+    items = hist.get("items") or []+    if items:+        it = items[0]+        try:+            _post("/ai-threads/resume", {"ai": it.get("ai") or "claude", "id": it["id"]})+            _post("/ai-threads/run-prompt", {"sid": it["id"], "prompt": phrase, "submit": False})+            return {"staged": True, "where": "resumed thread", "sid": it["id"]}+        except Exception as e:+            return {"staged": False, "reason": f"resume/run-prompt failed ({e.__class__.__name__})",+                    "sid": it.get("id")}+    # 3. neither+    return {"staged": False, "reason": f"no AI thread tagged {tag} yet (open or closed)"}   # ---------------------------------------------------------------- bridge calls@@ -1198,7 +1226,8 @@ def main():         # console. An already-running dashboard should make relaunch INSTANT,         # not fatal, so: if a healthy instance of OURS already owns the port,         # print its URL and exit 0.-        def _healthy_instance(p_):+        def _running_version(p_):+            """Version of OUR instance on the port, or None if none/not ours."""             import urllib.request          # imported per-use in this module             try:                 with urllib.request.urlopen(@@ -1206,15 +1235,66 @@ def main():                     d_ = json.loads(r.read().decode("utf-8") or "{}")                 # Confirm it is OURS - another service on the port is not a                 # reason to claim success.-                return bool(d_.get("ok")) and d_.get("app") == "kicad-dashboard"+                if bool(d_.get("ok")) and d_.get("app") == "kicad-dashboard":+                    return str(d_.get("version") or "?")             except Exception:-                return False+                pass+            return None -        if _healthy_instance(port):+        def _healthy_instance(p_):+            return _running_version(p_) is not None++        def _retire_stale(p_, their):+            """VERSION-BLIND REUSE (dock thread, 2026-08-23, twice): after an+            install, `serve` found a healthy OLD instance and happily reused+            it, so /health kept reporting 2.0.0 and new actions answered+            "unknown action" until someone killed it by hand. A healthy+            instance of a DIFFERENT version is not a reason to skip serving;+            it is the thing to replace. Kill only our own serve processes+            (matched by command line), never anything broad."""+            log(f"kicad-dashboard {their} is serving on {p_} but this is {VERSION}; "+                f"retiring the stale instance")+            me = os.getpid()+            victims = []+            for pid in os.listdir("/proc"):+                if not pid.isdigit() or int(pid) == me:+                    continue+                try:+                    cmd = open(f"/proc/{pid}/cmdline", "rb").read().replace(b"\0", b" ")+                except Exception:+                    continue+                if b"kicad_dashboard.py" in cmd and b" serve" in cmd:+                    victims.append(int(pid))+            import signal+            for pid in victims:+                try:+                    os.kill(pid, signal.SIGTERM)+                except Exception:+                    pass+            for _ in range(40):                # up to ~4s for the port to free+                if _running_version(p_) is None:+                    return True+                time.sleep(0.1)+            for pid in victims:+                try:+                    os.kill(pid, signal.SIGKILL)+                except Exception:+                    pass+            time.sleep(0.5)+            return _running_version(p_) is None++        their = _running_version(port)+        if their is not None and their != VERSION:+            if not _retire_stale(port, their):+                print(f"kicad-dashboard: a stale {their} instance owns port {port} and "+                      f"would not exit; kill it and relaunch.", file=sys.stderr, flush=True)+                raise SystemExit(2)+            their = None+        if their is not None:             if "--print-url" in argv:                 print(direct_url(port), flush=True)             else:-                log(f"kicad-dashboard already serving on {port}; nothing to do")+                log(f"kicad-dashboard {VERSION} already serving on {port}; nothing to do")             return          # BIND FIRST. The old order printed the URL and only then tried to bind,
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "kicad-bridge",-  "version": "1.0.58",+  "version": "1.0.59",   "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.58",+  "version": "1.0.59",   "tags": [     "kicad",     "pcb",