master
John Lauer Publish 1.1.0 63c1c64 1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035
//! adom-ds2sf dashboard: the AI-drivable live app over a chip-fetcher library.
//!
//! GET /            the dashboard UI
//! GET /events      SSE live channel (state on connect, 15s heartbeat)
//! GET /state       full dashboard state (library summary, current chip, running job)
//! GET /api/chips   per-chip extraction status
//! GET /api/chip/<MPN>[/svg/<kind>|/pdf|/png]
//! POST /extract    {mpn, force?, pdf?, model?, symbolOnly?, footprintOnly?, aiThread}
//! POST /select     {mpn, aiThread}
//! POST /ui/toast   {text, kind}      POST /ui/click {selector}   POST /ui/reveal {selector}
//! GET/POST /console, GET/POST /settings, GET /health, GET /api/status, POST /shutdown
//!
//! Lifecycle (app-creator-server-lifecycle): registry entry under
//! ~/.adom/instances/adom-ds2sf/<thread>.json, 24h idle reaper with a per-thread
//! snapshot, --keep-alive / --ttl overrides. Every state-changing POST needs an
//! ai-thread name (app-creator-ai-thread-name).

use serde_json::{json, Value};
use std::collections::VecDeque;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{channel, Sender};
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};

pub const APP: &str = "adom-ds2sf";
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const DEFAULT_PORT: u16 = 8791;
pub const WEBVIEW_TAB: &str = "DS2SF";
const APP_HTML: &str = include_str!("app.html");
const FAVICON: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 3.5h9l4 4V13"/><path d="M13 3.5v4h4"/><path d="M7 8.5h3M7 11.5h5"/><rect x="12" y="14" width="8" height="7" rx="1"/><path d="M14 14v-2M18 14v-2M14 21v1.5M18 21v1.5M12 16.5h-2M12 19h-2"/></svg>"##;

// ───────────────────────── state ─────────────────────────

#[derive(Clone, Default)]
pub struct Job {
    pub mpn: String,
    pub started: u64,
    pub stage: String,
    pub args: Vec<String>,
    pub ai_thread: String,
}

#[derive(Default)]
struct Dash {
    current: String,
    job: Option<Job>,
    last_job: Option<Value>,
    log: VecDeque<String>,
    console: Vec<String>,
    /// Threads that touched this instance, most recent first.
    threads: Vec<String>,
}

static DASH: Mutex<Option<Dash>> = Mutex::new(None);
static CLIENTS: Mutex<Vec<Sender<Vec<u8>>>> = Mutex::new(Vec::new());
static LAST_ACTIVITY: Mutex<Option<Instant>> = Mutex::new(None);
static OWNER: Mutex<Option<(String, u16)>> = Mutex::new(None);
static SETTINGS: Mutex<Option<Value>> = Mutex::new(None);
/// Recent UI directives with a sequence number, so a page that only polls
/// (the cloud proxy buffers SSE) still executes click/tab/toast/reveal.
static UI_EVENTS: Mutex<VecDeque<(u64, String, Value)>> = Mutex::new(VecDeque::new());
static UI_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);

fn dash<R>(f: impl FnOnce(&mut Dash) -> R) -> R {
    let mut g = DASH.lock().unwrap();
    if g.is_none() {
        *g = Some(Dash::default());
    }
    f(g.as_mut().unwrap())
}

fn touch() {
    *LAST_ACTIVITY.lock().unwrap() = Some(Instant::now());
}

fn now_unix() -> u64 {
    SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}

// ───────────────────────── settings ─────────────────────────

fn settings_path() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_default();
    Path::new(&home).join(".config/adom-ds2sf/settings.json")
}

fn default_settings() -> Value {
    json!({
        "ttl_hours": 24,
        "default_surface": "wv",
        "open_in_background": true,
        "default_model": "claude-opus-4-7",
        "dev_mode": false,
        "library_root": Value::Null,
    })
}

fn settings_enums() -> Value {
    json!({
        "ttl_hours": {"type": "number", "min": 1, "max": 720},
        "default_surface": {"type": "enum", "values": ["wv", "pup", "hbw", "nb"]},
        "open_in_background": {"type": "bool"},
        "default_model": {"type": "enum", "values": ["claude-opus-4-7", "claude-sonnet-4-5", "claude-haiku-4-5"]},
        "dev_mode": {"type": "bool"},
        "library_root": {"type": "path"},
    })
}

pub fn settings() -> Value {
    let mut g = SETTINGS.lock().unwrap();
    if g.is_none() {
        let mut s = default_settings();
        if let Ok(raw) = std::fs::read_to_string(settings_path()) {
            if let Ok(Value::Object(saved)) = serde_json::from_str::<Value>(&raw) {
                for (k, v) in saved {
                    s[k] = v;
                }
            }
        }
        *g = Some(s);
    }
    g.clone().unwrap()
}

fn save_settings(s: &Value) {
    if let Some(p) = settings_path().parent() {
        let _ = std::fs::create_dir_all(p);
    }
    let _ = std::fs::write(settings_path(), serde_json::to_string_pretty(s).unwrap_or_default());
    *SETTINGS.lock().unwrap() = Some(s.clone());
}

// ───────────────────────── library ─────────────────────────

pub fn library_root() -> Option<PathBuf> {
    if let Some(r) = settings().get("library_root").and_then(|v| v.as_str()) {
        if !r.is_empty() && Path::new(r).is_dir() {
            return Some(PathBuf::from(r));
        }
    }
    let home = std::env::var("HOME").unwrap_or_default();
    for c in [
        format!("{home}/project/chip-fetcher/library"),
        format!("{home}/project/adom-chip-fetcher/library"),
        "chip-fetcher/library".to_string(),
    ] {
        if Path::new(&c).is_dir() {
            return Some(PathBuf::from(c));
        }
    }
    None
}

fn read_json(p: &Path) -> Value {
    std::fs::read_to_string(p)
        .ok()
        .and_then(|s| serde_json::from_str::<Value>(&s).ok())
        .unwrap_or(Value::Null)
}

fn mtime(p: &Path) -> u64 {
    std::fs::metadata(p)
        .and_then(|m| m.modified())
        .ok()
        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

fn pdfs_in(dir: &Path) -> Vec<String> {
    let mut v: Vec<String> = std::fs::read_dir(dir)
        .map(|rd| {
            rd.flatten()
                .filter(|e| e.path().extension().map(|x| x.eq_ignore_ascii_case("pdf")).unwrap_or(false))
                .map(|e| e.file_name().to_string_lossy().to_string())
                .collect()
        })
        .unwrap_or_default();
    v.sort();
    v
}

/// One row of the library rail: everything the UI needs without opening the big JSONs.
fn chip_summary(dir: &Path) -> Value {
    let mpn = dir.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
    let f = |suffix: &str| dir.join(format!("{mpn}{suffix}"));
    let result = read_json(&f("-extraction.result.json"));
    let symbol = read_json(&f("-symbol.extracted.json"));
    let validation = read_json(&f("-validation-status.json"));
    let info = read_json(&dir.join("info.json"));
    let pdfs = pdfs_in(dir);
    let has_pdf = f(".pdf").is_file();
    let status = match result.get("status").and_then(|s| s.as_str()) {
        Some(s) => s.to_string(),
        None if has_pdf || !pdfs.is_empty() => "not_extracted".to_string(),
        None => "no_datasheet".to_string(),
    };
    let pins = symbol.get("pins").and_then(|p| p.as_array()).map(|a| a.len()).unwrap_or(0);
    let package = symbol.get("package").cloned()
        .or_else(|| info.get("package").cloned())
        .unwrap_or(Value::Null);
    let manufacturer = symbol.get("manufacturer").cloned()
        .or_else(|| info.get("manufacturer").cloned())
        .unwrap_or(Value::Null);
    let has_3d = f("-3d-iso-named.png").is_file() || f("-3d-iso.png").is_file();
    let running = dash(|d| d.job.as_ref().map(|j| j.mpn == mpn).unwrap_or(false));
    json!({
        "mpn": mpn,
        "status": status,
        "running": running,
        "summary": result.get("summary").cloned().unwrap_or(Value::Null),
        "review_count": result.get("review_required").and_then(|r| r.as_array()).map(|a| a.len()).unwrap_or(0),
        "hint_count": result.get("hints").and_then(|r| r.as_array()).map(|a| a.len()).unwrap_or(0),
        "extractor_version": result.get("extractor_version").cloned().unwrap_or(Value::Null),
        "model": result.get("claude_model").cloned().unwrap_or(Value::Null),
        "extracted_at": mtime(&f("-extraction.result.json")),
        "pins": pins,
        "package": package,
        "manufacturer": manufacturer,
        "concur": validation.get("concur_status").cloned().unwrap_or(Value::Null),
        "has_pdf": has_pdf,
        "pdfs": pdfs,
        "has_symbol": f("-symbol.extracted.json").is_file(),
        "has_footprint": f("-footprint.extracted.json").is_file(),
        "has_provenance": f("-extraction.provenance.json").is_file(),
        "has_3d": has_3d,
        "svgs": {
            "ds2sf": f("-footprint.ds2sf.svg").is_file(),
            "pcbnew": f("-footprint.pcbnew.svg").is_file(),
            "symbol": f("-symbol.ds2sf.svg").is_file(),
        },
    })
}

fn list_chips() -> Vec<Value> {
    let root = match library_root() {
        Some(r) => r,
        None => return vec![],
    };
    let mut dirs: Vec<PathBuf> = std::fs::read_dir(&root)
        .map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect())
        .unwrap_or_default();
    dirs.sort_by_key(|p| p.file_name().map(|s| s.to_string_lossy().to_lowercase()));
    dirs.iter().map(|d| chip_summary(d)).collect()
}

fn safe_mpn(mpn: &str) -> bool {
    !mpn.is_empty() && !mpn.contains('/') && !mpn.contains('\\') && !mpn.starts_with('.')
}

fn chip_dir(mpn: &str) -> Option<PathBuf> {
    if !safe_mpn(mpn) {
        return None;
    }
    let d = library_root()?.join(mpn);
    if d.is_dir() { Some(d) } else { None }
}

fn chip_detail(mpn: &str) -> Value {
    let dir = match chip_dir(mpn) {
        Some(d) => d,
        None => return Value::Null,
    };
    let f = |suffix: &str| dir.join(format!("{mpn}{suffix}"));
    let mut cache: Vec<String> = std::fs::read_dir(dir.join(".ds2sf-cache"))
        .map(|rd| rd.flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect())
        .unwrap_or_default();
    cache.sort();
    json!({
        "summary": chip_summary(&dir),
        "dir": dir.to_string_lossy(),
        "result": read_json(&f("-extraction.result.json")),
        "symbol": read_json(&f("-symbol.extracted.json")),
        "footprint": read_json(&f("-footprint.extracted.json")),
        "provenance": read_json(&f("-extraction.provenance.json")),
        "validation": read_json(&f("-validation-status.json")),
        "concur": read_json(&f("-concur.result.json")),
        "info": read_json(&dir.join("info.json")),
        "cache_files": cache,
    })
}

// ───────────────────────── SSE ─────────────────────────

struct ChanReader {
    rx: std::sync::mpsc::Receiver<Vec<u8>>,
    buf: Vec<u8>,
    pos: usize,
}

impl Read for ChanReader {
    fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
        if self.pos >= self.buf.len() {
            match self.rx.recv() {
                Ok(b) => {
                    self.buf = b;
                    self.pos = 0;
                }
                Err(_) => return Ok(0),
            }
        }
        let n = out.len().min(self.buf.len() - self.pos);
        out[..n].copy_from_slice(&self.buf[self.pos..self.pos + n]);
        self.pos += n;
        Ok(n)
    }
}

/// tiny_http sits on a 1 KB BufWriter, so every SSE frame is padded past it
/// with a comment line: a write >= the buffer size goes straight to the socket.
fn sse_frame(event: &str, data: &Value) -> Vec<u8> {
    let mut s = format!("event: {event}\ndata: {}\n\n", data);
    if s.len() < 1200 {
        let pad = 1200 - s.len();
        s.push_str(": ");
        s.push_str(&" ".repeat(pad));
        s.push_str("\n\n");
    }
    s.into_bytes()
}

pub fn broadcast(event: &str, data: Value) {
    if matches!(event, "toast" | "click" | "reveal" | "cursor" | "tab" | "reload" | "eval" | "chip") {
        let seq = UI_SEQ.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        let mut q = UI_EVENTS.lock().unwrap();
        q.push_back((seq, event.to_string(), data.clone()));
        while q.len() > 50 {
            q.pop_front();
        }
    }
    let frame = sse_frame(event, &data);
    let mut clients = CLIENTS.lock().unwrap();
    clients.retain(|tx| tx.send(frame.clone()).is_ok());
}

fn broadcast_state() {
    broadcast("state", state_json());
}

fn push_log(line: String) {
    let line = crate::io::strip_ansi(&line);
    dash(|d| {
        d.log.push_back(line.clone());
        while d.log.len() > 600 {
            d.log.pop_front();
        }
        if let Some(j) = d.job.as_mut() {
            let l = line.trim();
            let stage = if l.starts_with("[1a]") {
                Some("symbol pass (Claude reading the pinout)")
            } else if l.starts_with("[1b]") {
                Some("footprint pass (Claude reading the package drawing)")
            } else if l.starts_with("[svg]") {
                Some("rendering footprint SVG")
            } else if l.starts_with("[normalize]") || l.contains("service-kicad") {
                Some("normalizing package against the KiCad stdlib")
            } else if l.starts_with("OK:") || l.starts_with("ERROR:") {
                Some("writing outputs")
            } else {
                None
            };
            if let Some(s) = stage {
                j.stage = s.to_string();
            }
        }
    });
    broadcast("log", json!({ "line": line }));
}

// ───────────────────────── extraction job ─────────────────────────

fn start_job(body: &Value, ai_thread: &str) -> Result<Value, (u16, Value)> {
    let mpn = body.get("mpn").and_then(|m| m.as_str()).unwrap_or("").to_string();
    let dir = chip_dir(&mpn).ok_or_else(|| {
        (404, json!({ "ok": false, "errorCode": "chip_not_found",
            "error": format!("no chip directory for {mpn:?} in the library"),
            "hint": "Land it first with `adom-chip-fetcher fetch <MPN>`, then extract." }))
    })?;
    if let Some(j) = dash(|d| d.job.clone()) {
        return Err((409, json!({ "ok": false, "errorCode": "job_running",
            "error": format!("an extraction is already running for {}", j.mpn),
            "hint": "Wait for it to finish (watch GET /state .job) or open the Log tab. One Claude extraction at a time keeps the datasheet reads honest." })));
    }
    let model = body.get("model").and_then(|m| m.as_str())
        .map(String::from)
        .unwrap_or_else(|| settings().get("default_model").and_then(|m| m.as_str()).unwrap_or("claude-opus-4-7").to_string());
    let mut args: Vec<String> = vec!["extract".into(), dir.to_string_lossy().to_string(), "--model".into(), model.clone()];
    if let Some(p) = body.get("pdf").and_then(|p| p.as_str()).filter(|p| !p.is_empty()) {
        let pdf = if p.contains('/') { PathBuf::from(p) } else { dir.join(p) };
        if !pdf.is_file() {
            return Err((404, json!({ "ok": false, "errorCode": "pdf_not_found",
                "error": format!("{} is not a file", pdf.display()),
                "hint": "Pick one of GET /api/chip/<MPN> .summary.pdfs, or fetch a datasheet with `adom-chip-fetcher pup-pdf-fetch <MPN> --pdf-url <url>`." })));
        }
        args.push("--pdf".into());
        args.push(pdf.to_string_lossy().to_string());
    } else if !dir.join(format!("{mpn}.pdf")).is_file() {
        let pdfs = pdfs_in(&dir);
        if let Some(first) = pdfs.first() {
            args.push("--pdf".into());
            args.push(dir.join(first).to_string_lossy().to_string());
        } else {
            return Err((422, json!({ "ok": false, "errorCode": "no_datasheet",
                "error": format!("{mpn} has no datasheet PDF in its chip directory"),
                "hint": format!("Fetch one first: `adom-chip-fetcher --ai-thread \"{ai_thread}\" pup-pdf-fetch {mpn} --pdf-url <manufacturer PDF url>`, then POST /extract again.") })));
        }
    }
    if body.get("force").and_then(|f| f.as_bool()).unwrap_or(false) {
        args.push("--force".into());
    }
    if body.get("symbolOnly").and_then(|f| f.as_bool()).unwrap_or(false) {
        args.push("--symbol-only".into());
    }
    if body.get("footprintOnly").and_then(|f| f.as_bool()).unwrap_or(false) {
        args.push("--footprint-only".into());
    }
    let job = Job { mpn: mpn.clone(), started: now_unix(), stage: "starting".into(), args: args.clone(), ai_thread: ai_thread.to_string() };
    dash(|d| {
        d.job = Some(job.clone());
        d.log.clear();
        d.current = mpn.clone();
    });
    push_log(format!("thread \"{ai_thread}\": adom-ds2sf {}", args.join(" ")));
    let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("adom-ds2sf"));
    std::thread::spawn(move || run_job(exe, args, mpn));
    broadcast_state();
    broadcast("toast", json!({ "text": format!("Extraction started for {}", job.mpn), "kind": "info" }));
    Ok(json!({ "ok": true, "job": job_json(&job) }))
}

fn run_job(exe: PathBuf, args: Vec<String>, mpn: String) {
    use std::io::BufRead;
    use std::process::{Command, Stdio};
    let child = Command::new(&exe)
        .args(&args)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn();
    let exit = match child {
        Ok(mut ch) => {
            let out = ch.stdout.take();
            let err = ch.stderr.take();
            let t1 = std::thread::spawn(move || {
                if let Some(o) = out {
                    for l in std::io::BufReader::new(o).lines().flatten() {
                        push_log(l);
                    }
                }
            });
            let t2 = std::thread::spawn(move || {
                if let Some(e) = err {
                    for l in std::io::BufReader::new(e).lines().flatten() {
                        push_log(l);
                    }
                }
            });
            let st = ch.wait().ok().and_then(|s| s.code()).unwrap_or(-1);
            let _ = t1.join();
            let _ = t2.join();
            st
        }
        Err(e) => {
            push_log(format!("ERROR: could not spawn {}: {e}", exe.display()));
            -1
        }
    };
    let dir = chip_dir(&mpn);
    let result = dir.map(|d| read_json(&d.join(format!("{mpn}-extraction.result.json")))).unwrap_or(Value::Null);
    let status = result.get("status").and_then(|s| s.as_str()).unwrap_or(if exit == 0 { "ok" } else { "unrecoverable" }).to_string();
    let summary = result.get("summary").and_then(|s| s.as_str()).unwrap_or("").to_string();
    dash(|d| {
        d.job = None;
        d.last_job = Some(json!({ "mpn": mpn, "exit": exit, "status": status, "summary": summary, "finished": now_unix() }));
    });
    push_log(format!("[done] exit {exit} status {status}"));
    let kind = match status.as_str() { "ok" => "success", "needs_better_pdf" => "info", _ => "error" };
    let text = match status.as_str() {
        "ok" => format!("{mpn}: extraction complete"),
        "needs_better_pdf" => format!("{mpn}: needs a better datasheet PDF"),
        _ => format!("{mpn}: extraction failed (exit {exit})"),
    };
    broadcast("toast", json!({ "text": text, "kind": kind, "sticky": kind == "error" }));
    broadcast_state();
    broadcast("chip", json!({ "mpn": mpn }));
    touch();
}

fn job_json(j: &Job) -> Value {
    json!({ "mpn": j.mpn, "started": j.started, "elapsed": now_unix().saturating_sub(j.started),
        "stage": j.stage, "args": j.args, "aiThread": j.ai_thread })
}

// ───────────────────────── state ─────────────────────────

fn state_json() -> Value {
    let chips = list_chips();
    let counts = |s: &str| chips.iter().filter(|c| c.get("status").and_then(|x| x.as_str()) == Some(s)).count();
    let (cur, job, last_job, threads) = dash(|d| (d.current.clone(), d.job.clone(), d.last_job.clone(), d.threads.clone()));
    let (thread, port) = OWNER.lock().unwrap().clone().unwrap_or_default();
    json!({
        "app": APP,
        "version": VERSION,
        "port": port,
        "thread": thread,
        "threads": threads,
        "library_root": library_root().map(|p| p.to_string_lossy().to_string()),
        "counts": {
            "total": chips.len(),
            "ok": counts("ok"),
            "needs_better_pdf": counts("needs_better_pdf"),
            "unrecoverable": counts("unrecoverable"),
            "not_extracted": counts("not_extracted"),
            "no_datasheet": counts("no_datasheet"),
        },
        "chips": chips,
        "current": cur,
        "job": job.as_ref().map(job_json),
        "last_job": last_job,
        "settings": settings(),
        "proxy_url": proxy_url_for_port(port),
        "log_len": dash(|d| d.log.len()),
        "ui": UI_EVENTS.lock().unwrap().iter().map(|(seq, ev, data)| json!({ "seq": seq, "event": ev, "data": data })).collect::<Vec<_>>(),
        "_hint": "POST /extract {mpn, force?, pdf?, model?, symbolOnly?, footprintOnly?, aiThread} runs the extractor; POST /select {mpn, aiThread} focuses a chip; POST /ui/toast {text, kind} narrates; GET /events is the SSE channel; GET /console reads the UI log.",
    })
}

// ───────────────────────── URLs, registry, lifecycle ─────────────────────────

pub fn proxy_url_for_port(port: u16) -> String {
    if let Ok(host) = std::env::var("ADOM_PROXY_HOST") {
        return format!("https://{host}/proxy/{port}/");
    }
    if let Ok(slug) = std::env::var("ADOM_CONTAINER_SLUG") {
        return format!("https://{slug}.adom.cloud/proxy/{port}/");
    }
    if let Ok(tpl) = std::env::var("VSCODE_PROXY_URI") {
        if tpl.contains("{{port}}") {
            return tpl.replace("{{port}}", &port.to_string());
        }
    }
    format!("http://127.0.0.1:{port}/")
}

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

pub fn slugify(s: &str) -> String {
    let mut out = String::new();
    for c in s.chars() {
        if c.is_ascii_alphanumeric() {
            out.push(c.to_ascii_lowercase());
        } else if !out.ends_with('-') {
            out.push('-');
        }
    }
    let t = out.trim_matches('-').to_string();
    if t.is_empty() { "anonymous".into() } else { t }
}

pub fn instances_dir() -> PathBuf {
    let home = std::env::var("HOME").unwrap_or_default();
    Path::new(&home).join(".adom/instances").join(APP)
}

fn registry_path(thread: &str) -> PathBuf {
    instances_dir().join(format!("{}.json", slugify(thread)))
}

fn write_registry(thread: &str, port: u16, started: u64) {
    let _ = std::fs::create_dir_all(instances_dir());
    let entry = json!({
        "pid": std::process::id(), "port": port, "thread": thread, "app": APP,
        "version": VERSION, "startedAt": started, "lastActivity": now_unix(),
        "url": direct_url(port), "proxyUrl": proxy_url_for_port(port),
    });
    let _ = std::fs::write(registry_path(thread), serde_json::to_string_pretty(&entry).unwrap_or_default());
}

fn snapshot(thread: &str) {
    let dir = instances_dir().join("snapshots");
    let _ = std::fs::create_dir_all(&dir);
    let (cur, log) = dash(|d| (d.current.clone(), d.log.iter().cloned().collect::<Vec<_>>()));
    let snap = json!({ "thread": thread, "current": cur, "log": log, "savedAt": now_unix() });
    let _ = std::fs::write(dir.join(format!("{}.json", slugify(thread))), serde_json::to_string_pretty(&snap).unwrap_or_default());
}

fn restore_snapshot(thread: &str) -> bool {
    let p = instances_dir().join("snapshots").join(format!("{}.json", slugify(thread)));
    let v = read_json(&p);
    if v.is_null() {
        return false;
    }
    dash(|d| {
        d.current = v.get("current").and_then(|c| c.as_str()).unwrap_or("").to_string();
        if let Some(lines) = v.get("log").and_then(|l| l.as_array()) {
            d.log = lines.iter().filter_map(|l| l.as_str().map(String::from)).collect();
        }
    });
    true
}

/// Registry rows with liveness (dead pids pruned), for `adom-ds2sf ls`.
pub fn list_instances() -> Vec<Value> {
    let mut out = Vec::new();
    if let Ok(rd) = std::fs::read_dir(instances_dir()) {
        for e in rd.flatten() {
            let p = e.path();
            if p.extension().map(|x| x == "json").unwrap_or(false) {
                let v = read_json(&p);
                let pid = v.get("pid").and_then(|x| x.as_u64()).unwrap_or(0);
                let alive = pid > 0 && Path::new(&format!("/proc/{pid}")).exists();
                if alive {
                    out.push(v);
                } else {
                    let _ = std::fs::remove_file(&p);
                }
            }
        }
    }
    out
}

pub fn remove_webview_tab() {
    let _ = std::process::Command::new("timeout")
        .args(["10", "adom-cli", "hydrogen", "workspace", "remove-tab", "--name", WEBVIEW_TAB])
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status();
}

fn shutdown_sequence(thread: &str, reason: &str) -> ! {
    eprintln!("[adom-ds2sf] shutting down ({reason})");
    snapshot(thread);
    let _ = std::fs::remove_file(registry_path(thread));
    remove_webview_tab();
    std::process::exit(0)
}

/// Probe a port: Some(version) when an adom-ds2sf answers /health there.
pub fn probe(port: u16) -> Option<Value> {
    use std::io::Write;
    use std::net::TcpStream;
    let addr = format!("127.0.0.1:{port}");
    let mut s = TcpStream::connect_timeout(&addr.parse().ok()?, Duration::from_millis(600)).ok()?;
    let _ = s.set_read_timeout(Some(Duration::from_millis(1500)));
    let _ = s.write_all(format!("GET /health HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n").as_bytes());
    let mut buf = String::new();
    let _ = s.read_to_string(&mut buf);
    let body = buf.split("\r\n\r\n").nth(1)?;
    let v: Value = serde_json::from_str(body.trim()).ok()?;
    if v.get("app").and_then(|a| a.as_str()) == Some(APP) { Some(v) } else { None }
}

pub fn post_json(port: u16, path: &str, body: &Value) -> Option<Value> {
    use std::io::Write;
    use std::net::TcpStream;
    let addr = format!("127.0.0.1:{port}");
    let mut s = TcpStream::connect_timeout(&addr.parse().ok()?, Duration::from_millis(800)).ok()?;
    let _ = s.set_read_timeout(Some(Duration::from_secs(10)));
    let b = body.to_string();
    let _ = s.write_all(format!("POST {path} HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", b.len(), b).as_bytes());
    let mut buf = String::new();
    let _ = s.read_to_string(&mut buf);
    let body = buf.split("\r\n\r\n").nth(1)?;
    serde_json::from_str(body.trim()).ok()
}

pub fn post_json_host(host: &str, port: u16, path: &str, body: &Value) -> Option<Value> {
    use std::io::Write;
    use std::net::{TcpStream, ToSocketAddrs};
    let addr = (host, port).to_socket_addrs().ok()?.next()?;
    let mut s = TcpStream::connect_timeout(&addr, Duration::from_millis(800)).ok()?;
    let _ = s.set_read_timeout(Some(Duration::from_secs(10)));
    let b = body.to_string();
    let _ = s.write_all(format!("POST {path} HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", b.len(), b).as_bytes());
    let mut buf = String::new();
    let _ = s.read_to_string(&mut buf);
    let body = buf.split("\r\n\r\n").nth(1)?;
    Some(serde_json::from_str(body.trim()).unwrap_or(json!({ "raw": body })))
}

pub fn get_json(port: u16, path: &str) -> Option<Value> {
    use std::io::Write;
    use std::net::TcpStream;
    let addr = format!("127.0.0.1:{port}");
    let mut s = TcpStream::connect_timeout(&addr.parse().ok()?, Duration::from_millis(800)).ok()?;
    let _ = s.set_read_timeout(Some(Duration::from_secs(10)));
    let _ = s.write_all(format!("GET {path} HTTP/1.0\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n").as_bytes());
    let mut buf = String::new();
    let _ = s.read_to_string(&mut buf);
    let body = buf.split("\r\n\r\n").nth(1)?;
    serde_json::from_str(body.trim()).ok()
}

pub fn free_port() -> u16 {
    std::net::TcpListener::bind("127.0.0.1:0").ok().and_then(|l| l.local_addr().ok()).map(|a| a.port()).unwrap_or(DEFAULT_PORT)
}

// ───────────────────────── server ─────────────────────────

pub struct ServeOpts {
    pub port: u16,
    pub ai_thread: String,
    pub keep_alive: bool,
    pub ttl_hours: Option<f64>,
}

pub fn run(opts: ServeOpts) -> anyhow::Result<()> {
    let port = opts.port;
    let server = Server::http(format!("0.0.0.0:{port}")).map_err(|e| anyhow::anyhow!("bind {port}: {e}"))?;
    let started = now_unix();
    *OWNER.lock().unwrap() = Some((opts.ai_thread.clone(), port));
    touch();
    dash(|d| d.threads.insert(0, opts.ai_thread.clone()));
    if restore_snapshot(&opts.ai_thread) {
        eprintln!("[adom-ds2sf] restored snapshot for thread \"{}\"", opts.ai_thread);
    }
    write_registry(&opts.ai_thread, port, started);
    eprintln!("[adom-ds2sf] v{VERSION} serving on {} (thread \"{}\")", direct_url(port), opts.ai_thread);

    // Heartbeat + reaper + registry refresh.
    let ttl_h = opts.ttl_hours.unwrap_or_else(|| settings().get("ttl_hours").and_then(|t| t.as_f64()).unwrap_or(24.0));
    let keep_alive = opts.keep_alive;
    let thread = opts.ai_thread.clone();
    std::thread::spawn(move || {
        let mut tick = 0u64;
        loop {
            std::thread::sleep(Duration::from_secs(15));
            {
                let mut clients = CLIENTS.lock().unwrap();
                clients.retain(|tx| tx.send(b": hb\n\n".to_vec()).is_ok());
            }
            tick += 1;
            if tick % 4 == 0 {
                write_registry(&thread, port, started);
                let idle = LAST_ACTIVITY.lock().unwrap().map(|t| t.elapsed()).unwrap_or_default();
                let busy = dash(|d| d.job.is_some());
                if !keep_alive && !busy && idle > Duration::from_secs_f64(ttl_h * 3600.0) {
                    shutdown_sequence(&thread, &format!("idle for {:.1}h, ttl {ttl_h}h", idle.as_secs_f64() / 3600.0));
                }
            }
        }
    });

    for req in server.incoming_requests() {
        std::thread::spawn(move || handle(req));
    }
    Ok(())
}

fn handle(mut req: Request) {
    let full = req.url().to_string();
    let url = full.split('?').next().unwrap_or("/").to_string();
    let query = full.splitn(2, '?').nth(1).unwrap_or("").to_string();
    let method = req.method().clone();
    let mut body = String::new();
    if matches!(method, Method::Post) {
        let _ = req.as_reader().read_to_string(&mut body);
    }
    if url != "/events" && url != "/state" && url != "/health" && url != "/api/status" && url != "/console" {
        touch();
    }

    if matches!(method, Method::Options) {
        let _ = req.respond(cors(Response::from_string("").with_status_code(204)));
        return;
    }

    if url == "/events" && matches!(method, Method::Get) {
        let (tx, rx) = channel::<Vec<u8>>();
        let mut first = Vec::new();
        first.extend_from_slice(format!(":{}\n\n", " ".repeat(32768)).as_bytes());
        first.extend_from_slice(&sse_frame("state", &state_json()));
        let _ = tx.send(first);
        CLIENTS.lock().unwrap().push(tx);
        let reader = ChanReader { rx, buf: Vec::new(), pos: 0 };
        let resp = Response::new(
            StatusCode(200),
            vec![
                hdr("Content-Type", "text/event-stream"),
                hdr("Cache-Control", "no-cache"),
                hdr("X-Accel-Buffering", "no"),
                hdr("Access-Control-Allow-Origin", "*"),
            ],
            reader,
            None,
            None,
        );
        let _ = req.respond(resp);
        return;
    }

    let shutting_down = url == "/shutdown" && matches!(method, Method::Post);
    let (thread_for_exit, resp) = route(&method, &url, &query, &body);
    let _ = req.respond(resp);
    if shutting_down {
        if let Some(t) = thread_for_exit {
            std::thread::sleep(Duration::from_millis(150));
            shutdown_sequence(&t, "POST /shutdown");
        }
    }
}

type Resp = Response<std::io::Cursor<Vec<u8>>>;

fn ai_thread_of(body: &Value) -> Option<String> {
    body.get("aiThread").and_then(|t| t.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
}

fn identity_refusal(verb: &str) -> Resp {
    json_status(400, &json!({
        "ok": false,
        "errorCode": "caller_identity_required",
        "error": "This app requires every AI caller to give its ai-thread name.",
        "hint": format!("Re-run with the global flag: adom-ds2sf --ai-thread \"<name>\" {verb}, or add a top-level \"aiThread\" field if you are POSTing directly. Pick a short name a HUMAN would recognize for THIS conversation (like 'ds2sf' or 'stm32 board review'), and reuse it on every call to every Adom tool."),
    }))
}

fn note_thread(t: &str, what: &str) {
    dash(|d| {
        d.threads.retain(|x| x != t);
        d.threads.insert(0, t.to_string());
        d.threads.truncate(12);
        d.console.push(format!("[thread] \"{t}\": {what}"));
        let n = d.console.len();
        if n > 500 {
            d.console.drain(0..n - 500);
        }
    });
}

fn route(method: &Method, url: &str, query: &str, body: &str) -> (Option<String>, Resp) {
    let v: Value = serde_json::from_str(body).unwrap_or(Value::Null);
    let r = match (method, url) {
        (Method::Get, "/") | (Method::Get, "/index.html") => html(APP_HTML),
        (Method::Get, "/favicon.svg") => Response::from_string(FAVICON).with_header(hdr("Content-Type", "image/svg+xml")),
        (Method::Get, "/health") | (Method::Get, "/api/status") => {
            let (thread, port) = OWNER.lock().unwrap().clone().unwrap_or_default();
            cors(json_ok(&json!({ "ok": true, "app": APP, "version": VERSION, "port": port, "thread": thread,
                "job": dash(|d| d.job.clone()).map(|j| job_json(&j)), "chips": list_chips().len() })))
        }
        (Method::Get, "/version") => json_ok(&json!({ "app": APP, "version": VERSION })),
        (Method::Get, "/state") => cors(json_ok(&state_json())),
        (Method::Get, "/api/chips") => cors(json_ok(&json!({ "chips": list_chips(), "current": dash(|d| d.current.clone()) }))),
        (Method::Get, "/settings") => json_ok(&json!({ "settings": settings(), "enums": settings_enums(), "version": VERSION })),
        (Method::Post, "/settings") => {
            let t = match ai_thread_of(&v) { Some(t) => t, None => return (None, identity_refusal("settings set <key> <value>")) };
            let mut s = settings();
            let enums = settings_enums();
            if let Some(obj) = v.as_object() {
                for (k, val) in obj {
                    if k == "aiThread" { continue; }
                    let spec = match enums.get(k) {
                        Some(sp) => sp,
                        None => return (None, json_status(400, &json!({ "ok": false, "errorCode": "unknown_setting",
                            "error": format!("no setting named {k:?}"), "hint": format!("Valid keys: {}", enums.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>().join(", ")).unwrap_or_default()) }))),
                    };
                    let ok = match spec.get("type").and_then(|t| t.as_str()) {
                        Some("bool") => val.is_boolean(),
                        Some("number") => val.as_f64().map(|n| n >= spec["min"].as_f64().unwrap_or(0.0) && n <= spec["max"].as_f64().unwrap_or(1e9)).unwrap_or(false),
                        Some("enum") => val.as_str().map(|s| spec["values"].as_array().map(|a| a.iter().any(|x| x.as_str() == Some(s))).unwrap_or(false)).unwrap_or(false),
                        _ => val.is_string() || val.is_null(),
                    };
                    if !ok {
                        return (None, json_status(400, &json!({ "ok": false, "errorCode": "invalid_setting_value",
                            "error": format!("{k} cannot be {val}"), "hint": format!("Allowed: {spec}") })));
                    }
                    s[k] = val.clone();
                }
            }
            save_settings(&s);
            note_thread(&t, "settings updated");
            broadcast_state();
            json_ok(&json!({ "ok": true, "settings": s }))
        }
        (Method::Get, "/console") => json_ok(&json!({ "lines": dash(|d| d.console.clone()) })),
        (Method::Post, "/console") => {
            let line = format!("[{}] {}", v.get("level").and_then(|l| l.as_str()).unwrap_or("log"), v.get("msg").and_then(|m| m.as_str()).unwrap_or(""));
            dash(|d| {
                d.console.push(line);
                let n = d.console.len();
                if n > 500 { d.console.drain(0..n - 500); }
            });
            json_ok(&json!({ "ok": true }))
        }
        (Method::Get, "/log") => json_ok(&json!({ "lines": dash(|d| d.log.iter().cloned().collect::<Vec<_>>()), "job": dash(|d| d.job.clone()).map(|j| job_json(&j)) })),
        (Method::Post, "/select") => {
            let t = match ai_thread_of(&v) { Some(t) => t, None => return (None, identity_refusal("select <MPN>")) };
            let mpn = v.get("mpn").and_then(|m| m.as_str()).unwrap_or("").to_string();
            if chip_dir(&mpn).is_none() {
                return (None, json_status(404, &json!({ "ok": false, "errorCode": "chip_not_found", "error": format!("{mpn:?} is not in the library"), "hint": "GET /api/chips lists what is." })));
            }
            dash(|d| d.current = mpn.clone());
            note_thread(&t, &format!("select {mpn}"));
            broadcast_state();
            json_ok(&json!({ "ok": true, "current": mpn }))
        }
        (Method::Post, "/extract") => {
            let t = match ai_thread_of(&v) { Some(t) => t, None => return (None, identity_refusal("extract <MPN>")) };
            note_thread(&t, &format!("extract {}", v.get("mpn").and_then(|m| m.as_str()).unwrap_or("?")));
            match start_job(&v, &t) {
                Ok(j) => json_ok(&j),
                Err((code, e)) => json_status(code, &e),
            }
        }
        (Method::Post, "/ui/toast") => {
            let text = v.get("text").or(v.get("message")).and_then(|x| x.as_str()).unwrap_or("").to_string();
            let kind = v.get("kind").and_then(|x| x.as_str()).unwrap_or("info").to_string();
            broadcast("toast", json!({ "text": text, "kind": kind, "sticky": v.get("sticky").and_then(|s| s.as_bool()).unwrap_or(false) }));
            json_ok(&json!({ "ok": true }))
        }
        (Method::Post, "/ui/click") | (Method::Post, "/ui/reveal") | (Method::Post, "/ui/cursor") | (Method::Post, "/ui/tab") | (Method::Post, "/ui/reload") => {
            let ev = &url[4..];
            broadcast(ev, v.clone());
            json_ok(&json!({ "ok": true, "event": ev }))
        }
        (Method::Post, "/ui/eval") => {
            if !settings().get("dev_mode").and_then(|d| d.as_bool()).unwrap_or(false) {
                return (None, json_status(403, &json!({ "ok": false, "errorCode": "dev_mode_off", "error": "eval is only available in dev mode", "hint": "adom-ds2sf --ai-thread \"<name>\" settings set dev_mode true" })));
            }
            broadcast("eval", v.clone());
            json_ok(&json!({ "ok": true }))
        }
        (Method::Post, "/shutdown") => {
            let t = match ai_thread_of(&v) { Some(t) => t, None => return (None, identity_refusal("shutdown")) };
            let reason = v.get("reason").and_then(|r| r.as_str()).unwrap_or("");
            if reason.len() < 10 {
                return (None, json_status(400, &json!({ "ok": false, "errorCode": "reason_required",
                    "error": "Shutting down a dashboard other threads may be using requires a reason.",
                    "hint": "Re-call with a \"reason\" field (>= 10 chars) quoting what the USER asked for, e.g. {\"reason\": \"user said close the ds2sf dashboard\"}. If nobody asked, leave it: the 24h idle reaper cleans up by itself." })));
            }
            note_thread(&t, &format!("shutdown: {reason}"));
            let owner = OWNER.lock().unwrap().clone().map(|o| o.0).unwrap_or(t);
            return (Some(owner), json_ok(&json!({ "ok": true, "shutting_down": true })));
        }
        _ if url.starts_with("/api/chip/") && matches!(method, Method::Get) => {
            let rest = &url["/api/chip/".len()..];
            let mut parts = rest.splitn(2, '/');
            let mpn = urldecode(parts.next().unwrap_or(""));
            let sub = parts.next().unwrap_or("");
            match chip_dir(&mpn) {
                None => json_status(404, &json!({ "ok": false, "error": format!("{mpn:?} not in library") })),
                Some(dir) => {
                    if sub.is_empty() {
                        cors(json_ok(&chip_detail(&mpn)))
                    } else if let Some(kind) = sub.strip_prefix("svg/") {
                        let file = match kind {
                            "ds2sf" => format!("{mpn}-footprint.ds2sf.svg"),
                            "pcbnew" => format!("{mpn}-footprint.pcbnew.svg"),
                            "symbol" => format!("{mpn}-symbol.ds2sf.svg"),
                            "authoritative" => format!("{mpn}-footprint.authoritative.svg"),
                            other => format!("{mpn}-{other}.svg"),
                        };
                        match std::fs::read(dir.join(&file)) {
                            Ok(b) => Response::from_data(b).with_header(hdr("Content-Type", "image/svg+xml")).with_header(hdr("Cache-Control", "no-cache")),
                            Err(_) => not_found(),
                        }
                    } else if sub == "pdf" {
                        let name = query.split('&').find_map(|kv| kv.strip_prefix("name=")).map(urldecode).unwrap_or_else(|| format!("{mpn}.pdf"));
                        if !safe_mpn(&name) || !name.to_lowercase().ends_with(".pdf") {
                            return (None, not_found());
                        }
                        match std::fs::read(dir.join(&name)) {
                            Ok(b) => Response::from_data(b).with_header(hdr("Content-Type", "application/pdf")).with_header(hdr("Content-Disposition", &format!("inline; filename=\"{name}\""))),
                            Err(_) => not_found(),
                        }
                    } else if sub == "png" {
                        let mut found = None;
                        for c in [format!("{mpn}-3d-iso-named.png"), format!("{mpn}-3d-iso.png")] {
                            if let Ok(b) = std::fs::read(dir.join(&c)) { found = Some(b); break; }
                        }
                        match found {
                            Some(b) => Response::from_data(b).with_header(hdr("Content-Type", "image/png")),
                            None => not_found(),
                        }
                    } else if let Some(name) = sub.strip_prefix("cache/") {
                        let name = urldecode(name);
                        if !safe_mpn(&name) { return (None, not_found()); }
                        match std::fs::read(dir.join(".ds2sf-cache").join(&name)) {
                            Ok(b) => Response::from_data(b).with_header(hdr("Content-Type", "text/plain; charset=utf-8")),
                            Err(_) => not_found(),
                        }
                    } else {
                        not_found()
                    }
                }
            }
        }
        _ => not_found(),
    };
    (None, r)
}

fn urldecode(s: &str) -> String {
    let b = s.as_bytes();
    let mut out = Vec::with_capacity(b.len());
    let mut i = 0;
    while i < b.len() {
        if b[i] == b'%' && i + 2 < b.len() + 0 && i + 2 <= b.len() - 1 {
            if let Ok(h) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
                out.push(h);
                i += 3;
                continue;
            }
        }
        out.push(if b[i] == b'+' { b' ' } else { b[i] });
        i += 1;
    }
    String::from_utf8_lossy(&out).to_string()
}

fn hdr(k: &str, v: &str) -> Header {
    Header::from_bytes(k.as_bytes(), v.as_bytes()).unwrap()
}
fn html(s: &str) -> Resp {
    Response::from_string(s).with_header(hdr("Content-Type", "text/html; charset=utf-8"))
}
fn json_ok(v: &Value) -> Resp {
    Response::from_string(v.to_string()).with_header(hdr("Content-Type", "application/json"))
}
fn json_status(code: u16, v: &Value) -> Resp {
    Response::from_string(v.to_string()).with_status_code(code).with_header(hdr("Content-Type", "application/json"))
}
fn cors(r: Resp) -> Resp {
    r.with_header(hdr("Access-Control-Allow-Origin", "*"))
        .with_header(hdr("Access-Control-Allow-Headers", "Content-Type"))
        .with_header(hdr("Access-Control-Allow-Methods", "GET, POST, OPTIONS"))
}
fn not_found() -> Resp {
    json_status(404, &json!({ "ok": false, "error": "not found" }))
}