master
John Lauer 1.4.4: close the mid-update race that left a stale build serving (serve hands off when it is not the installed build), retire by port ownership rather than name, strip the (deleted) suffix before any path use; reproduced via /proc/pid/exe and verified fixed d0c7483 1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731
//! The Hydrogen Dock Bar contract for chip-fetcher.
//!
//! Reference: the Dock Bar guide on adom/hydrogen (docs/dockbar.md) and
//! adom/kicad-bridge's dashboard, which was verified green end to end on
//! 2026-08-23. Everything here exists because one of the first four bridges
//! shipped it wrong:
//!
//! * `serve --print-url` must print the DIRECT `http://127.0.0.1:<port>/`
//!   URL (never the `/proxy/<port>/` form, never the name `localhost`), then
//!   SELF-BACKGROUND and exit 0, or the dock's synchronous exec kills the
//!   server at its timeout and the readiness probe finds nothing listening.
//! * serve must be IDEMPOTENT and VERSION-AWARE: an instance of ours already
//!   on the port makes relaunch instant; an instance of a DIFFERENT version
//!   is the thing to replace, matched by exact argv (a substring match once
//!   killed the shell that invoked it).
//! * every `/api/*` response sends `Access-Control-Allow-Origin: *` and
//!   answers `OPTIONS`, or the dock's cross-origin health probe is blocked
//!   by the browser and the card LED reads "not answering" while curl says
//!   200.
//! * the dock sets `ADOM_DOCK_WEBVIEW` to the tab it opened for us; we take
//!   that tab over with `adom-cli hydrogen webview open-or-refresh`.
//! * the `say` phrases in dockbar.json are how users learn to ASK for the
//!   app; the dashboard carries the same list at the very top.

use serde_json::{json, Value};
use std::collections::VecDeque;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};

pub const APP: &str = "adom-chip-fetcher";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const DOCKBAR_JSON: &str = include_str!("../dockbar.json");
pub const APP_TAG: &str = "app:adom/adom-chip-fetcher";
const HYDROGEN_CONTROL_PORT: u16 = 47084;

/// The sister CLIs `adom-wiki pkg install adom/adom-chip-fetcher` pulls in
/// (page.json `dependencies`). Each is a wiki package whose binary carries the
/// same name. chip-fetcher orchestrates them: symbol/footprint/3D viewers,
/// the datasheet and STEP converters, pin-1 sign-off, thumbnails, and the
/// distributor backends.
pub const SISTER_TOOLS: &[(&str, &str)] = &[
    ("adom-symbol", "symbol viewer + .kicad_sym export"),
    ("adom-footprint", "footprint viewer + .kicad_mod export"),
    ("adom-step", "3D STEP viewer"),
    ("adom-lbr", "Fusion 360 / EAGLE .lbr export"),
    ("adom-chipsmith", "pin-1 alignment + sign-off"),
    ("adom-chip-thumbnailer", "card thumbnails (3D iso, symbol, footprint)"),
    ("adom-concur", "symbol/footprint cross-check"),
    ("adom-ds2sf", "datasheet to symbol + footprint"),
    ("adom-sfconvert", "EDA format conversion"),
    ("adom-parts-search", "unified Mouser + DigiKey + JLCPCB search"),
    ("adom-mouser", "Mouser stock + pricing"),
    ("adom-digikey", "DigiKey stock + CAD links"),
    ("adom-jlcpcb", "JLCPCB stock"),
];

pub fn direct_url(port: u16) -> String {
    format!("http://127.0.0.1:{port}/")
}

// ---------------------------------------------------------------- loopback http

/// Minimal HTTP/1.0 request over loopback with a hard timeout. Returns the
/// body (headers stripped). std only: shelling to curl costs 20-50ms per
/// probe, which matters when the dock polls every 15s and budgets 2s.
fn http(port: u16, method: &str, path: &str, body: Option<&str>, timeout: Duration) -> Option<String> {
    let addr: SocketAddr = format!("127.0.0.1:{port}").parse().ok()?;
    let mut s = TcpStream::connect_timeout(&addr, timeout).ok()?;
    s.set_read_timeout(Some(timeout)).ok()?;
    s.set_write_timeout(Some(timeout)).ok()?;
    let body = body.unwrap_or("");
    let req = format!(
        "{method} {path} HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nUser-Agent: {APP}/{VERSION}\r\n\
         Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
        body.len()
    );
    s.write_all(req.as_bytes()).ok()?;
    let mut raw = Vec::new();
    let _ = s.read_to_end(&mut raw);
    let text = String::from_utf8_lossy(&raw).to_string();
    let idx = text.find("\r\n\r\n")?;
    Some(text[idx + 4..].to_string())
}

pub fn http_get(port: u16, path: &str, timeout: Duration) -> Option<String> {
    http(port, "GET", path, None, timeout)
}

pub fn http_post(port: u16, path: &str, body: &str, timeout: Duration) -> Option<String> {
    http(port, "POST", path, Some(body), timeout)
}

// ------------------------------------------------------- idempotent serve

/// Version of OUR dashboard on `port`, or None when nothing (or something
/// that is not us) answers. Another service on the port is not a reason to
/// claim success.
pub fn running_version(port: u16) -> Option<String> {
    let body = http_get(port, "/health", Duration::from_millis(1500))?;
    let v: Value = serde_json::from_str(&body).ok()?;
    if v.get("ok").and_then(|b| b.as_bool()) == Some(true) && v.get("app").and_then(|a| a.as_str()) == Some(APP) {
        Some(v.get("version").and_then(|s| s.as_str()).unwrap_or("?").to_string())
    } else {
        None
    }
}

/// The PID listening on `port`, found through the kernel rather than through
/// process names: parse /proc/net/tcp{,6} for the LISTEN socket on that port,
/// then find the process holding that socket inode. Names and argv can lie or
/// drift (a rename, an alias, a wrapper); the socket cannot.
pub fn pid_owning_port(port: u16) -> Option<u32> {
    let mut inodes: Vec<String> = Vec::new();
    for f in ["/proc/net/tcp", "/proc/net/tcp6"] {
        let Ok(text) = std::fs::read_to_string(f) else { continue };
        for line in text.lines().skip(1) {
            let cols: Vec<&str> = line.split_whitespace().collect();
            if cols.len() < 10 {
                continue;
            }
            // local_address is "HEXADDR:HEXPORT"; state 0A is LISTEN.
            let Some((_, p)) = cols[1].split_once(':') else { continue };
            if u16::from_str_radix(p, 16).ok() != Some(port) || cols[3] != "0A" {
                continue;
            }
            inodes.push(cols[9].to_string());
        }
    }
    if inodes.is_empty() {
        return None;
    }
    let rd = std::fs::read_dir("/proc").ok()?;
    for e in rd.flatten() {
        let name = e.file_name().to_string_lossy().to_string();
        let Ok(pid) = name.parse::<u32>() else { continue };
        let Ok(fds) = std::fs::read_dir(format!("/proc/{pid}/fd")) else { continue };
        for fd in fds.flatten() {
            if let Ok(target) = std::fs::read_link(fd.path()) {
                let t = target.to_string_lossy();
                if let Some(ino) = t.strip_prefix("socket:[").and_then(|x| x.strip_suffix(']')) {
                    if inodes.iter().any(|i| i == ino) {
                        return Some(pid);
                    }
                }
            }
        }
    }
    None
}

/// Is this PID one of OUR builds? Checks the executable behind the process,
/// stripping Linux's "(deleted)" suffix, which appears the instant a package
/// update unlinks the old binary and writes a new one at the same path. Any
/// path comparison that skips this stops matching exactly when it matters.
fn pid_is_ours(pid: u32) -> bool {
    let exe = std::fs::read_link(format!("/proc/{pid}/exe"))
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_default();
    let exe = exe.strip_suffix(" (deleted)").unwrap_or(&exe).to_string();
    let base = exe.rsplit('/').next().unwrap_or("").to_string();
    if base == "adom-chip-fetcher" || base == "chip-fetcher" {
        return true;
    }
    // Fall back to argv, which is frozen at exec and never gains "(deleted)".
    let cmd = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default();
    let argv: Vec<&[u8]> = cmd.split(|b| *b == 0).collect();
    if argv.len() < 2 || argv[1] != b"serve" {
        return false;
    }
    let bin = argv[0].rsplit(|b| *b == b'/').next().unwrap_or(argv[0]);
    bin == b"adom-chip-fetcher" || bin == b"chip-fetcher"
}

/// Retire a stale instance of a different version. Kills only processes
/// whose argv is EXACTLY `[<...>/adom-chip-fetcher, serve, ...]` (or the
/// `chip-fetcher` alias), never a shell whose command text merely mentions
/// us. Returns true once the port is free.
pub fn retire_stale(port: u16, their: &str) -> bool {
    eprintln!("{APP} {their} is serving on {port} but this is {VERSION}; retiring the stale instance");
    let me = std::process::id();
    let parent = std::os::unix::process::parent_id();
    let mut victims: Vec<u32> = Vec::new();
    // Preferred: whoever actually owns the port, confirmed to be one of ours.
    // Never ourselves, never our parent.
    if let Some(owner) = pid_owning_port(port) {
        if owner != me && owner != parent && pid_is_ours(owner) {
            victims.push(owner);
        }
    }
    if let Ok(rd) = std::fs::read_dir("/proc") {
        for e in rd.flatten() {
            let name = e.file_name().to_string_lossy().to_string();
            let pid: u32 = match name.parse() {
                Ok(p) => p,
                Err(_) => continue,
            };
            if pid == me || pid == parent || victims.contains(&pid) {
                continue;
            }
            let cmd = match std::fs::read(format!("/proc/{pid}/cmdline")) {
                Ok(c) => c,
                Err(_) => continue,
            };
            let argv: Vec<&[u8]> = cmd.split(|b| *b == 0).collect();
            if argv.len() < 2 {
                continue;
            }
            let bin = argv[0].rsplit(|b| *b == b'/').next().unwrap_or(argv[0]);
            let is_us = bin == b"adom-chip-fetcher" || bin == b"chip-fetcher";
            if is_us && argv[1] == b"serve" {
                victims.push(pid);
            }
        }
    }
    for pid in &victims {
        let _ = std::process::Command::new("kill").args(["-TERM", &pid.to_string()]).output();
    }
    for _ in 0..40 {
        if running_version(port).is_none() {
            return true;
        }
        std::thread::sleep(Duration::from_millis(100));
    }
    for pid in &victims {
        let _ = std::process::Command::new("kill").args(["-KILL", &pid.to_string()]).output();
    }
    std::thread::sleep(Duration::from_millis(500));
    running_version(port).is_none()
}

/// Take ownership of the dock's pre-opened webview tab. The dock names the
/// tab in `ADOM_DOCK_WEBVIEW`; `open-or-refresh` with that name is idempotent
/// so re-running it after a restart just renavigates. Unset env (manual
/// serve) is a silent no-op; the dock's own interstitial navigation remains
/// the fallback.
pub fn claim_dock_webview(port: u16) {
    let name = match std::env::var("ADOM_DOCK_WEBVIEW") {
        Ok(n) if !n.trim().is_empty() => n,
        _ => return,
    };
    let url = direct_url(port);
    let _ = std::process::Command::new("timeout")
        .args(["20", "adom-cli", "hydrogen", "webview", "open-or-refresh", "--name", &name, "--url", &url])
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status();
    console_push("info", &format!("claimed dock webview tab '{name}' -> {url}"), "serve");
}

/// Spawn a detached copy of ourselves to serve, wait until it answers
/// `/health`, and return its URL. This is how `serve --print-url`
/// self-backgrounds without `fork()` (tiny_http owns threads; forking a
/// threaded process is undefined behaviour).
pub fn spawn_detached_server(port: u16) -> anyhow::Result<String> {
    use std::os::unix::process::CommandExt;
    let exe = std::env::current_exe()?;
    let mut cmd = std::process::Command::new(exe);
    cmd.args(["serve", "--port", &port.to_string(), "--detached-child"])
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null());
    // New session: the dock's exec pipe must not stay open on us, and the
    // exec's process-tree kill at timeout must not take the server with it.
    unsafe {
        cmd.pre_exec(|| {
            libc_setsid();
            Ok(())
        });
    }
    cmd.spawn()?;
    let deadline = Instant::now() + Duration::from_secs(15);
    while Instant::now() < deadline {
        if running_version(port).is_some() {
            return Ok(direct_url(port));
        }
        std::thread::sleep(Duration::from_millis(100));
    }
    anyhow::bail!("the detached server never answered /health on port {port} within 15s")
}

/// `setsid(2)` without the libc crate: the syscall number is stable on
/// x86_64 (112) and aarch64 (112) Linux. Failure is harmless (we keep the
/// parent's session), so the return value is ignored.
fn libc_setsid() {
    extern "C" {
        fn setsid() -> i32;
    }
    unsafe {
        setsid();
    }
}

// ------------------------------------------------------------------ say / try

/// The `say` phrases from dockbar.json, the single source both the dock's
/// interstitial and the dashboard's "Say it" strip read.
pub fn say_phrases() -> Vec<String> {
    serde_json::from_str::<Value>(DOCKBAR_JSON)
        .ok()
        .and_then(|v| v.get("say").cloned())
        .and_then(|s| s.as_array().cloned())
        .map(|a| a.iter().filter_map(|p| p.as_str().map(|s| s.to_string())).collect())
        .unwrap_or_default()
}

/// Stage a phrase into this app's own AI thread via the Hydrogen control API.
/// Measured order (dock thread, 2026-08-23): the census FIRST (open threads),
/// history SECOND (closed ones; it deliberately excludes anything open, so
/// history-first returns empty exactly when the user has the thread open),
/// copy fallback third. The control API is reachable from WSL2 under mirrored
/// networking; in a cloud container it is not, and copying is the right
/// answer there.
pub fn try_phrase(phrase: &str) -> Value {
    // TRY_IT_PARKED (ship night 2026-08-23, dock-bar thread ruling, matching
    // adom-parts-search): every /ai-threads call from an app server can wake
    // ah's parked AI Threads backend, and run-prompt's typing path has an
    // unverified crash fix that has killed the whole ah process. Copy-only
    // until the all-clear; flip this to false to bring Try it back.
    const TRY_IT_PARKED: bool = true;
    if TRY_IT_PARKED {
        let _ = phrase;
        return json!({"staged": false, "reason": "Try it is parked tonight (ship-night posture); copy the phrase and paste it to your AI"});
    }
    let t = Duration::from_millis(1500);
    let get = |path: &str| -> Option<Value> {
        http_get(HYDROGEN_CONTROL_PORT, path, t).and_then(|b| serde_json::from_str(&b).ok())
    };
    let post = |path: &str, body: Value| -> Option<Value> {
        http_post(HYDROGEN_CONTROL_PORT, path, &body.to_string(), t).and_then(|b| serde_json::from_str(&b).ok())
    };
    // 1. census
    if let Some(c) = get("/ai-threads") {
        let mut hit: Option<Value> = None;
        for g in c.get("groups").and_then(|g| g.as_array()).cloned().unwrap_or_default() {
            for th in g.get("threads").and_then(|t| t.as_array()).cloned().unwrap_or_default() {
                let tagged = th
                    .get("tags")
                    .and_then(|t| t.as_array())
                    .map(|a| a.iter().any(|x| x.as_str() == Some(APP_TAG)))
                    .unwrap_or(false);
                if tagged {
                    hit = Some(th);
                    break;
                }
            }
            if hit.is_some() {
                break;
            }
        }
        if let Some(th) = hit {
            let mut body = json!({"prompt": phrase, "submit": false});
            match th.get("sid").and_then(|s| s.as_str()) {
                Some(sid) if !sid.is_empty() => body["sid"] = json!(sid),
                _ => body["tid"] = th.get("tid").cloned().unwrap_or(Value::Null),
            }
            return match post("/ai-threads/run-prompt", body) {
                Some(r) if r.get("ok").and_then(|b| b.as_bool()).unwrap_or(true) => {
                    json!({"staged": true, "where": "open thread"})
                }
                _ => json!({"staged": false, "reason": "run-prompt failed on the open thread"}),
            };
        }
        // 2. history
        if let Some(h) = get(&format!("/ai-threads/history?tag={APP_TAG}")) {
            if let Some(it) = h.get("items").and_then(|i| i.as_array()).and_then(|a| a.first()).cloned() {
                let id = it.get("id").cloned().unwrap_or(Value::Null);
                let ai = it.get("ai").cloned().unwrap_or(json!("claude"));
                let resumed = post("/ai-threads/resume", json!({"ai": ai, "id": id}));
                if resumed.is_some() {
                    let r = post("/ai-threads/run-prompt", json!({"sid": id, "prompt": phrase, "submit": false}));
                    if r.is_some() {
                        return json!({"staged": true, "where": "resumed thread"});
                    }
                }
                return json!({"staged": false, "reason": "resume/run-prompt failed on the closed thread"});
            }
        }
        return json!({"staged": false, "reason": "no chip-fetcher AI thread yet; start one by pasting the phrase"});
    }
    json!({"staged": false, "reason": "the Hydrogen control API is not reachable from this container"})
}

// ------------------------------------------------------- console + toasts

static CONSOLE: Mutex<VecDeque<Value>> = Mutex::new(VecDeque::new());
static TOASTS: Mutex<VecDeque<Value>> = Mutex::new(VecDeque::new());
static UI_CMDS: Mutex<VecDeque<Value>> = Mutex::new(VecDeque::new());
const CONSOLE_CAP: usize = 500;

fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// Ring buffer the dashboard's console.* override and the server both write
/// to, read back by `GET /console`. This is how the AI sees what the UI is
/// doing without DevTools.
pub fn console_push(level: &str, msg: &str, source: &str) {
    if let Ok(mut q) = CONSOLE.lock() {
        q.push_back(json!({"t": now_ms(), "level": level, "msg": msg, "source": source}));
        while q.len() > CONSOLE_CAP {
            q.pop_front();
        }
    }
}

pub fn console_json(limit: usize) -> String {
    let q = CONSOLE.lock().map(|q| q.iter().rev().take(limit).cloned().collect::<Vec<_>>()).unwrap_or_default();
    let mut items: Vec<Value> = q;
    items.reverse();
    json!({"ok": true, "count": items.len(), "items": items}).to_string()
}

/// Queue a toast for the dashboard. The dashboard drains the queue on its
/// 1s activity poll, so `adom-chip-fetcher ui toast "..."` lands over the
/// app within a second. Kinds: ok | err | info.
pub fn toast_push(message: &str, kind: &str) {
    if let Ok(mut q) = TOASTS.lock() {
        q.push_back(json!({"t": now_ms(), "message": message, "kind": kind}));
        while q.len() > 20 {
            q.pop_front();
        }
    }
}

pub fn drain_toasts() -> Vec<Value> {
    TOASTS.lock().map(|mut q| q.drain(..).collect()).unwrap_or_default()
}

/// Queue a front-end driving command (`{"cmd":"click","id":"btn-settings"}`,
/// `{"cmd":"open_settings"}`, `{"cmd":"select","mpn":"..."}`). Same drain.
pub fn ui_command_push(cmd: Value) {
    if let Ok(mut q) = UI_CMDS.lock() {
        q.push_back(cmd);
        while q.len() > 50 {
            q.pop_front();
        }
    }
}

pub fn drain_ui_commands() -> Vec<Value> {
    UI_CMDS.lock().map(|mut q| q.drain(..).collect()).unwrap_or_default()
}

// ------------------------------------------------------------- status LEDs

fn on_path(bin: &str) -> bool {
    let extra = std::env::var("HOME").map(|h| format!("{h}/.local/bin")).unwrap_or_default();
    let path = std::env::var("PATH").unwrap_or_default();
    std::iter::once(extra.as_str())
        .chain(path.split(':'))
        .filter(|d| !d.is_empty())
        .any(|d| std::path::Path::new(d).join(bin).is_file())
}

fn led(label: &str, state: &str, detail: String, hint: &str) -> Value {
    json!({"label": label, "state": state, "detail": detail, "_hint": hint})
}

fn rank(state: &str) -> u8 {
    match state {
        "fail" | "err" => 3,
        "warn" => 2,
        "ok" => 1,
        _ => 0,
    }
}

/// Whether a desktop is connected through Adom Bridge. `adom-bridge targets`
/// is the authoritative probe; it answers in well under a second when the
/// relay is up and the 2s timeout bounds the dead case.
fn bridge_targets(force: bool) -> Option<usize> {
    // The one non-trivial probe (spawns a process, talks to the relay).
    // Cached 5 minutes: a desktop appearing or leaving is a slow event, and
    // the dock/dashboard polling must not turn into a background process
    // treadmill (John, 2026-08-23). `?force=1` (the settings panel, doctor)
    // bypasses the cache.
    static CACHE: Mutex<Option<(Instant, Option<usize>)>> = Mutex::new(None);
    if !force {
        if let Ok(c) = CACHE.lock() {
            if let Some((t, v)) = c.as_ref() {
                if t.elapsed() < Duration::from_secs(300) {
                    return *v;
                }
            }
        }
    }
    let v = bridge_targets_probe();
    if let Ok(mut c) = CACHE.lock() {
        *c = Some((Instant::now(), v));
    }
    v
}

fn bridge_targets_probe() -> Option<usize> {
    let bin = if on_path("adom-bridge") { "adom-bridge" } else { "adom-bridge-cli" };
    let out = std::process::Command::new("timeout").args(["2", bin, "targets"]).output().ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    // The CLI prefixes a "note:" line for the retired alias; find the JSON.
    let start = text.find('{')?;
    let v: Value = serde_json::from_str(&text[start..]).ok()?;
    v.get("count").and_then(|c| c.as_u64()).map(|c| c as usize)
}

pub fn build_facets() -> Facets {
    build_facets_forced(true)
}

pub struct Facets {
    pub leds: serde_json::Map<String, Value>,
    pub state: String,
    pub missing_tools: Vec<String>,
}

/// Build every facet. Cheap checks only (PATH stats, one bounded bridge
/// probe, the library listing), cached by `status_json`.
pub fn build_facets_forced(force: bool) -> Facets {
    let mut leds = serde_json::Map::new();

    // 1. the dashboard itself: answering means ok.
    leds.insert(
        "dashboard".into(),
        match installed_version() {
            Some(d) if d != VERSION => led(
                "Dashboard",
                "warn",
                format!("v{VERSION} serving, v{d} installed"),
                "A newer build is on disk. This server hands the port over within a minute by itself; `adom-chip-fetcher serve --print-url` does it now.",
            ),
            _ => led("Dashboard", "ok", format!("v{VERSION} serving"), "The dashboard answers /api/status; the dock polls it every 15s."),
        },
    );

    // 2. the AI skills on this container: the main skill (+playbooks), the
    // dock skill, and the community-extensible per-vendor site pack.
    let home = std::env::var("HOME").unwrap_or_default();
    let sk = |n: &str| std::path::Path::new(&home).join(".claude/skills").join(n).join("SKILL.md").is_file();
    let sites_dir = std::path::Path::new(&home).join(".claude/skills/chip-fetcher-sites/sites");
    let site_count = std::fs::read_dir(&sites_dir).map(|rd| rd.flatten().filter(|e| e.path().is_dir()).count()).unwrap_or(0);
    let have = [sk("adom-chip-fetcher"), sk("adom-chip-fetcher-dock"), sk("chip-fetcher-sites")];
    let n_have = have.iter().filter(|b| **b).count();
    leds.insert(
        "skill".into(),
        if n_have == 3 {
            led("AI skills", "ok", format!("3 skills, {site_count} vendor site guides"), "adom-chip-fetcher (the sourcing ladder + 14 playbooks), adom-chip-fetcher-dock (the Dock Bar), and chip-fetcher-sites (one crawl guide per vendor website; anyone can contribute a new site).")
        } else {
            led("AI skills", "warn", format!("{n_have}/3 skills installed"), "Run `adom-chip-fetcher install` (or `adom-wiki pkg install adom/adom-chip-fetcher`) so your AI has the sourcing ladder, the dock guide and the per-site crawl playbooks.")
        },
    );

    // 3. sister tools.
    let missing: Vec<String> = SISTER_TOOLS.iter().filter(|(b, _)| !on_path(b)).map(|(b, _)| b.to_string()).collect();
    let total = SISTER_TOOLS.len();
    leds.insert(
        "tools".into(),
        if missing.is_empty() {
            led("Sister tools", "ok", format!("{total}/{total} installed automatically"), "The wiki installs these as dependencies of chip fetcher; nothing to manage. This light only exists to catch a broken install.")
        } else {
            led(
                "Sister tools",
                "warn",
                format!("{}/{} landed, missing {}", total - missing.len(), total, missing.join(", ")),
                "These install automatically with chip fetcher; a missing one means its own package misfired. `adom-chip-fetcher doctor --fix` re-installs each missing tool and self-heals from the downloaded payload.",
            )
        },
    );

    // 4. the desktop bridge (pup lives there; no desktop means no scraping).
    leds.insert(
        "bridge".into(),
        match bridge_targets(force) {
            Some(n) if n > 0 => led("Desktop", "ok", format!("{n} box(es) on Adom Bridge"), "pup drives the chip-fetcher Chrome profile on a connected desktop."),
            Some(_) => led("Desktop", "warn", "no desktop connected".into(), "Open Adom Hydrogen on your desktop; chip-fetcher scrapes vendor sites through pup there. The library view works without it."),
            None => led("Desktop", "warn", "adom-bridge not answering".into(), "Install the bridge CLI (`adom-wiki pkg install adom/adom-bridge`) and open Adom Hydrogen on your desktop."),
        },
    );

    // 5. the library.
    let (parts, complete) = library_counts();
    leds.insert(
        "library".into(),
        if parts == 0 {
            led("Library", "ok", "no parts yet".into(), "Ask your AI: \"fetch STM32F103C8T6 with chip fetcher\". The first card appears within seconds.")
        } else if complete == parts {
            led("Library", "ok", format!("{parts} parts, all complete"), "Every card has datasheet + STEP + symbol + footprint.")
        } else {
            led("Library", "ok", format!("{parts} parts, {} still incomplete", parts - complete), "Incomplete cards are work in progress, not a fault. `adom-chip-fetcher gate` lists exactly which artifact each one is missing.")
        },
    );

    // rollup: worst facet; "off" facets never count.
    let worst = leds.values().map(|v| v.get("state").and_then(|s| s.as_str()).unwrap_or("off")).max_by_key(|s| rank(s)).unwrap_or("ok");
    let state = match worst {
        "fail" | "err" => "fail",
        "warn" => "warn",
        _ => "ok",
    }
    .to_string();
    Facets { leds, state, missing_tools: missing }
}

fn library_counts() -> (usize, usize) {
    let root = crate::library::root();
    let mut parts = 0;
    let mut complete = 0;
    if let Ok(rd) = std::fs::read_dir(&root) {
        for e in rd.flatten() {
            let p = e.path();
            if !p.is_dir() {
                continue;
            }
            let mpn = e.file_name().to_string_lossy().to_string();
            if mpn.starts_with('.') {
                continue;
            }
            parts += 1;
            let has = |ext: &str| p.join(format!("{mpn}.{ext}")).is_file();
            if has("pdf") && has("step") && has("kicad_sym") && has("kicad_mod") {
                complete += 1;
            }
        }
    }
    (parts, complete)
}

static STATUS_CACHE: Mutex<Option<(Instant, String)>> = Mutex::new(None);

/// `/api/status`: per-facet LEDs plus a top-level `state` rollup the dock
/// honours. Cached 10s so the dock's 15s poll always answers in
/// milliseconds (the bridge probe is the slow part).
pub fn status_json(force: bool) -> String {
    if !force {
        if let Ok(c) = STATUS_CACHE.lock() {
            if let Some((t, s)) = c.as_ref() {
                if t.elapsed() < Duration::from_secs(10) {
                    return s.clone();
                }
            }
        }
    }
    let f = build_facets_forced(force);
    let body = json!({
        "ok": true,
        "app": APP,
        "version": VERSION,
        "state": f.state,
        "leds": f.leds,
        "say": say_phrases(),
        "target": crate::bridge::target(),
        "missing_tools": f.missing_tools,
        "_hint": "GET /api/library for the cards, GET /api/activity for the live heartbeat, POST /ui/toast {message,kind} to raise a toast, POST /ui/command {cmd,...} to drive the front-end.",
    })
    .to_string();
    if let Ok(mut c) = STATUS_CACHE.lock() {
        *c = Some((Instant::now(), body.clone()));
    }
    body
}

/// The version of the binary currently AT our own path on disk. After
/// `adom-wiki pkg update` replaces the file, this differs from the running
/// VERSION, and that gap is how a stale build kept serving after a dock Hard
/// Refresh (the dock reopens the tab against the running server and never
/// re-runs the launch steps, so nothing asks the old process to stand down).
/// Our own executable path, with Linux's "(deleted)" suffix stripped. A
/// package update unlinks the old file and writes a new one at the same path,
/// so a running process resolves /proc/self/exe to "<path> (deleted)"; reading
/// the SUFFIXED path fails and the upgrade watch goes blind. That is the exact
/// trap that let a stale build keep serving.
pub fn exe_path() -> Option<PathBuf> {
    let p = std::fs::read_link("/proc/self/exe").ok().or_else(|| std::env::current_exe().ok())?;
    let s = p.to_string_lossy().to_string();
    Some(PathBuf::from(s.strip_suffix(" (deleted)").unwrap_or(&s)))
}

pub fn installed_version() -> Option<String> {
    let exe = exe_path()?;
    if !exe.is_file() {
        return None;
    }
    let out = std::process::Command::new("timeout").arg("5").arg(&exe).arg("--version").output().ok()?;
    let text = String::from_utf8_lossy(&out.stdout);
    let v = text.split_whitespace().last()?.trim().to_string();
    // Only trust a clean semver; never act on a parse accident.
    if v.split('.').count() == 3 && v.chars().all(|c| c.is_ascii_digit() || c == '.') {
        Some(v)
    } else {
        None
    }
}

/// Watch our own binary and hand off when a package update replaces it.
/// Verified handoff: the replacement must answer `--version` cleanly before we
/// give up the port, and the successor is spawned detached with a short gap so
/// it can bind. Without this, `pkg update` + a tab reopen leaves the OLD build
/// serving, which is exactly how a parked-crash build stayed live.
pub fn watch_for_upgrade(port: u16) {
    std::thread::spawn(move || loop {
        std::thread::sleep(Duration::from_secs(if std::env::var("CF_UPGRADE_WATCH_FAST").is_ok() { 3 } else { 60 }));
        let Some(disk) = installed_version() else { continue };
        if disk == VERSION {
            continue;
        }
        let Some(exe) = exe_path() else { continue };
        console_push("info", &format!("binary on disk is v{disk} but this process is v{VERSION}; handing the port over"), "serve");
        eprintln!("{APP}: v{disk} is installed, this process is v{VERSION}; handing over port {port}");
        let cmd = format!("sleep 1; exec {:?} serve --port {port} --detached-child", exe);
        let spawned = std::process::Command::new("sh")
            .arg("-c")
            .arg(&cmd)
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .spawn();
        if spawned.is_ok() {
            std::thread::sleep(Duration::from_millis(200));
            std::process::exit(0);
        }
        eprintln!("{APP}: could not spawn the successor; staying up on v{VERSION}");
    });
}

pub fn health_json() -> String {
    let disk = installed_version();
    let stale = disk.as_deref().map(|d| d != VERSION).unwrap_or(false);
    json!({"ok": true, "app": APP, "version": VERSION, "installed_version": disk, "stale": stale}).to_string()
}