master
John Lauer Publish 1.1.0 63c1c64 1mo ago
//! CLI verbs for the dashboard: serve / show / ls / ui / settings / shutdown.
//! Every verb that changes server state carries the ai-thread name as
//! `aiThread` in the JSON it POSTs (app-creator-ai-thread-name).

use crate::io::{err, hint, note, ok};
use crate::server::{self, ServeOpts, APP, DEFAULT_PORT, VERSION, WEBVIEW_TAB};
use serde_json::{json, Value};
use std::time::{Duration, Instant};

static TARGET: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
pub fn set_target(t: Option<String>) {
    *TARGET.lock().unwrap() = t;
}

pub fn parse_port(p: &str) -> u16 {
    if p.eq_ignore_ascii_case("auto") {
        server::free_port()
    } else {
        p.parse().unwrap_or(DEFAULT_PORT)
    }
}

pub fn require_thread(t: Option<String>, verb: &str) -> Result<String, i32> {
    match t {
        Some(t) if !t.trim().is_empty() => Ok(t.trim().to_string()),
        _ => {
            err("caller_identity_required: this app requires every AI caller to give its ai-thread name.");
            hint(format!(
                "adom-ds2sf --ai-thread \"<name>\" {verb}   (a short name a HUMAN recognizes for THIS conversation, like 'ds2sf' or 'stm32 board review'; reuse it on every Adom tool call)"
            ));
            Err(2)
        }
    }
}

fn spawn_detached(port: u16, thread: &str, keep_alive: bool, ttl: Option<f64>) -> std::io::Result<()> {
    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(), "--ai-thread", thread, "--detached-child"]);
    if keep_alive {
        cmd.arg("--keep-alive");
    }
    if let Some(t) = ttl {
        cmd.args(["--ttl", &t.to_string()]);
    }
    cmd.stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .process_group(0);
    cmd.spawn().map(|_| ())
}

/// Make sure a current-version instance answers on `port`; returns Ok(port) once it does.
pub fn ensure_running(port: u16, thread: &str, keep_alive: bool, ttl: Option<f64>) -> Result<u16, String> {
    if let Some(h) = server::probe(port) {
        let v = h.get("version").and_then(|v| v.as_str()).unwrap_or("?").to_string();
        if v == VERSION {
            return Ok(port);
        }
        note(format!("an older adom-ds2sf (v{v}) owns port {port}; retiring it for v{VERSION}"));
        let _ = server::post_json(port, "/shutdown", &json!({
            "aiThread": "adom-ds2sf housekeeping",
            "reason": format!("version drift: replacing v{v} with v{VERSION} on port {port}")
        }));
        let t0 = Instant::now();
        while server::probe(port).is_some() && t0.elapsed() < Duration::from_secs(4) {
            std::thread::sleep(Duration::from_millis(200));
        }
    }
    spawn_detached(port, thread, keep_alive, ttl).map_err(|e| format!("could not spawn the server: {e}"))?;
    let t0 = Instant::now();
    while t0.elapsed() < Duration::from_secs(20) {
        std::thread::sleep(Duration::from_millis(250));
        if server::probe(port).is_some() {
            return Ok(port);
        }
    }
    Err(format!("server did not answer on port {port} within 20s"))
}

pub fn serve(ai_thread: Option<String>, port_s: &str, print_url: bool, detached_child: bool, keep_alive: bool, ttl: Option<f64>) -> i32 {
    let thread = match require_thread(ai_thread, "serve") { Ok(t) => t, Err(c) => return c };
    let port = parse_port(port_s);
    if detached_child {
        return match server::run(ServeOpts { port, ai_thread: thread, keep_alive, ttl_hours: ttl }) {
            Ok(()) => 0,
            Err(e) => { err(e); 1 }
        };
    }
    // Idempotent, version-aware (dock contract + lifecycle Rule 3).
    if let Some(h) = server::probe(port) {
        let v = h.get("version").and_then(|v| v.as_str()).unwrap_or("?");
        let owner = h.get("thread").and_then(|v| v.as_str()).unwrap_or("?");
        if v == VERSION {
            if print_url {
                println!("{}", server::direct_url(port));
            } else {
                ok(format!("already running on port {port} (v{v}, thread \"{owner}\"). Use that instance: {}", server::proxy_url_for_port(port)));
            }
            return 0;
        }
    }
    if print_url {
        return match ensure_running(port, &thread, keep_alive, ttl) {
            Ok(p) => { println!("{}", server::direct_url(p)); 0 }
            Err(e) => { err(e); hint("adom-ds2sf --ai-thread \"<name>\" serve   (foreground, to see the bind error)"); 1 }
        };
    }
    match server::run(ServeOpts { port, ai_thread: thread, keep_alive, ttl_hours: ttl }) {
        Ok(()) => 0,
        Err(e) => { err(e); hint(format!("adom-ds2sf ls   to see who owns port {port}, or --port auto")); 1 }
    }
}

fn bridge(thread: &str, verb: &str, body: &Value) -> Result<Value, String> {
    let mut cmd = std::process::Command::new("adom-bridge");
    cmd.args(["--ai-thread", thread]);
    if let Some(t) = TARGET.lock().unwrap().clone() {
        cmd.args(["--target", &t]);
    }
    let out = cmd.args([verb, &body.to_string()])
        .output()
        .map_err(|e| format!("could not run adom-bridge: {e}"))?;
    let text = String::from_utf8_lossy(&out.stdout).to_string();
    let v: Value = serde_json::from_str(text.trim()).unwrap_or(json!({ "raw": text }));
    if out.status.success() && v.get("ok").and_then(|o| o.as_bool()).unwrap_or(true) {
        Ok(v)
    } else {
        Err(v.get("error").and_then(|e| e.as_str()).map(String::from).unwrap_or_else(|| String::from_utf8_lossy(&out.stderr).to_string()))
    }
}

pub fn show(ai_thread: Option<String>, surface: Option<String>, port_s: &str) -> i32 {
    let thread = match require_thread(ai_thread, "show") { Ok(t) => t, Err(c) => return c };
    let port = parse_port(port_s);
    let port = match ensure_running(port, &thread, false, None) {
        Ok(p) => p,
        Err(e) => { err(e); return 1; }
    };
    let settings = server::settings();
    let surface = surface.unwrap_or_else(|| settings.get("default_surface").and_then(|s| s.as_str()).unwrap_or("wv").to_string());
    let url = server::proxy_url_for_port(port);
    match surface.as_str() {
        "wv" => {
            let name = std::env::var("ADOM_DOCK_WEBVIEW").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| WEBVIEW_TAB.to_string());
            let st = std::process::Command::new("timeout")
                .args(["20", "adom-cli", "hydrogen", "webview", "open-or-refresh", "--name", &name, "--url", &url])
                .output();
            match st {
                Ok(o) if o.status.success() => { ok(format!("dashboard open in Hydrogen webview tab \"{name}\": {url}")); 0 }
                Ok(o) => {
                    err(format!("adom-cli could not open the webview: {}", String::from_utf8_lossy(&o.stderr).trim()));
                    hint(format!("adom-ds2sf --ai-thread \"{thread}\" show --surface pup   (or open {url} yourself)"));
                    1
                }
                Err(e) => { err(format!("adom-cli not runnable: {e}")); hint(format!("open {url} in a browser, or `show --surface pup`")); 1 }
            }
        }
        "pup" => {
            // Reuse the window when the session is already open: navigate it and flash
            // the taskbar instead of spawning a second one (app-creator-surfaces).
            if bridge(&thread, "pup_navigate", &json!({ "sessionId": "adom-ds2sf", "url": url })).is_ok() {
                let _ = bridge(&thread, "pup_alert_window", &json!({ "sessionId": "adom-ds2sf" }));
                ok(format!("dashboard refreshed in the existing pup window (session adom-ds2sf): {url}"));
                return 0;
            }
            let body = json!({ "sessionId": "adom-ds2sf", "profile": "adom-ds2sf", "url": url });
            match bridge(&thread, "pup_open_window", &body) {
                Ok(_) => {
                    std::thread::sleep(Duration::from_millis(1200));
                    let _ = bridge(&thread, "desktop_set_window_state", &json!({ "titleContains": "DS2SF", "state": "maximize" }));
                    ok(format!("dashboard open in pup (session adom-ds2sf, background, maximized): {url}"));
                    0
                }
                Err(e) => { err(format!("pup_open_window failed: {e}")); hint("adom-bridge --ai-thread \"<name>\" pup_readiness   to see whether the desktop bridge is up"); 1 }
            }
        }
        "hbw" => {
            let r = server::post_json_host("host.docker.internal", 47084, "/hdbw/open", &json!({ "url": url }));
            match r {
                Some(_) => { ok(format!("dashboard open in Hydrogen browser window: {url}")); 0 }
                None => { err("Hydrogen control port did not answer (hbw needs Adom Hydrogen)"); hint("adom-ds2sf show --surface wv"); 1 }
            }
        }
        "nb" => {
            match bridge(&thread, "nbrowser_open_tab", &json!({ "url": url, "background": settings.get("open_in_background").and_then(|b| b.as_bool()).unwrap_or(true) })) {
                Ok(_) => { ok(format!("dashboard open in your signed-in browser: {url}")); 0 }
                Err(e) => { err(format!("native browser open failed: {e}")); hint(format!("open {url} yourself, or `show --surface pup`")); 1 }
            }
        }
        other => {
            err(format!("unknown surface {other:?}"));
            hint("valid: wv (Hydrogen webview tab), pup (AI's Chrome on the desktop), hbw (Hydrogen browser window), nb (your signed-in browser)");
            2
        }
    }
}

pub fn ls() -> i32 {
    let rows = server::list_instances();
    if rows.is_empty() {
        ok("no adom-ds2sf instances running");
        hint("adom-ds2sf --ai-thread \"<name>\" show");
        return 0;
    }
    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
    println!("{:<9}{:<7}{:<28}{:<10}{:<8}url", "pid", "port", "thread", "version", "idle");
    let mut drift = false;
    for r in &rows {
        let v = r.get("version").and_then(|x| x.as_str()).unwrap_or("?");
        let last = r.get("lastActivity").and_then(|x| x.as_u64()).unwrap_or(now);
        let idle = now.saturating_sub(last);
        let idle_s = if idle < 3600 { format!("{}m", idle / 60) } else { format!("{:.1}h", idle as f64 / 3600.0) };
        println!("{:<9}{:<7}{:<28}{:<10}{:<8}{}",
            r.get("pid").and_then(|x| x.as_u64()).unwrap_or(0),
            r.get("port").and_then(|x| x.as_u64()).unwrap_or(0),
            r.get("thread").and_then(|x| x.as_str()).unwrap_or("?"),
            v, idle_s,
            r.get("proxyUrl").and_then(|x| x.as_str()).unwrap_or(""));
        if v != VERSION {
            drift = true;
            crate::io::warn(format!("port {} runs {v}, disk has {VERSION}. Shut it down and respawn: adom-ds2sf --ai-thread \"<name>\" shutdown --port {} --reason \"stale version after update\"",
                r.get("port").and_then(|x| x.as_u64()).unwrap_or(0), r.get("port").and_then(|x| x.as_u64()).unwrap_or(0)));
        }
    }
    if drift { 1 } else { 0 }
}

fn need_server(port: u16) -> Result<(), i32> {
    if server::probe(port).is_none() {
        err(format!("no {APP} dashboard answering on port {port}"));
        hint(format!("adom-ds2sf --ai-thread \"<name>\" show   (starts it and opens the tab), or `adom-ds2sf ls`"));
        return Err(1);
    }
    Ok(())
}

fn report(r: Option<Value>) -> i32 {
    match r {
        Some(v) if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(true) => {
            ok(v.to_string());
            0
        }
        Some(v) => {
            err(v.get("error").and_then(|e| e.as_str()).unwrap_or("request refused"));
            if let Some(h) = v.get("hint").and_then(|h| h.as_str()) {
                hint(h);
            }
            1
        }
        None => { err("no response from the dashboard"); 1 }
    }
}

pub fn ui(ai_thread: Option<String>, op: crate::cli::UiOp, port_s: &str) -> i32 {
    use crate::cli::UiOp;
    let port = parse_port(port_s);
    if let Err(c) = need_server(port) { return c; }
    // Read-only verbs never need identity.
    match &op {
        UiOp::State => return report(server::get_json(port, "/state")),
        UiOp::Console => {
            return match server::get_json(port, "/console") {
                Some(v) => { for l in v.get("lines").and_then(|l| l.as_array()).cloned().unwrap_or_default() { println!("{}", l.as_str().unwrap_or("")); } 0 }
                None => { err("no response"); 1 }
            }
        }
        UiOp::Log => {
            return match server::get_json(port, "/log") {
                Some(v) => { for l in v.get("lines").and_then(|l| l.as_array()).cloned().unwrap_or_default() { println!("{}", l.as_str().unwrap_or("")); } if let Some(j) = v.get("job").filter(|j| !j.is_null()) { note(format!("running: {j}")); } 0 }
                None => { err("no response"); 1 }
            }
        }
        _ => {}
    }
    let thread = match require_thread(ai_thread, "ui <verb>") { Ok(t) => t, Err(c) => return c };
    let (path, body) = match op {
        UiOp::Toast { text, kind, sticky } => ("/ui/toast", json!({ "text": text, "kind": kind, "sticky": sticky })),
        UiOp::Click { selector, label } => ("/ui/click", json!({ "selector": selector, "label": label })),
        UiOp::Reveal { selector, label } => ("/ui/reveal", json!({ "selector": selector, "label": label })),
        UiOp::Cursor { selector, label } => ("/ui/cursor", json!({ "selector": selector, "label": label })),
        UiOp::Tab { name } => ("/ui/tab", json!({ "tab": name })),
        UiOp::Reload => ("/ui/reload", json!({})),
        UiOp::Eval { js } => ("/ui/eval", json!({ "js": js })),
        UiOp::Select { mpn } => ("/select", json!({ "mpn": mpn })),
        UiOp::Extract { mpn, force, pdf, model, symbol_only, footprint_only } => ("/extract", json!({
            "mpn": mpn, "force": force, "pdf": pdf, "model": model, "symbolOnly": symbol_only, "footprintOnly": footprint_only })),
        UiOp::State | UiOp::Console | UiOp::Log => unreachable!(),
    };
    let mut body = body;
    body["aiThread"] = json!(thread);
    report(server::post_json(port, path, &body))
}

pub fn settings(ai_thread: Option<String>, op: crate::cli::SettingsOp, port_s: &str) -> i32 {
    use crate::cli::SettingsOp;
    let port = parse_port(port_s);
    match op {
        SettingsOp::Get => {
            // Works with or without a running server (reads the same file).
            match server::get_json(port, "/settings") {
                Some(v) => { println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); 0 }
                None => { println!("{}", serde_json::to_string_pretty(&json!({ "settings": server::settings(), "version": VERSION, "_note": "no server running; showing the on-disk file" })).unwrap_or_default()); 0 }
            }
        }
        SettingsOp::Set { key, value } => {
            let thread = match require_thread(ai_thread, "settings set <key> <value>") { Ok(t) => t, Err(c) => return c };
            let v: Value = serde_json::from_str(&value).unwrap_or(Value::String(value.clone()));
            if let Err(c) = need_server(port) { return c; }
            report(server::post_json(port, "/settings", &json!({ key: v, "aiThread": thread })))
        }
    }
}

pub fn shutdown(ai_thread: Option<String>, reason: Option<String>, port_s: &str) -> i32 {
    let thread = match require_thread(ai_thread, "shutdown") { Ok(t) => t, Err(c) => return c };
    let port = parse_port(port_s);
    if let Err(c) = need_server(port) { return c; }
    let r = server::post_json(port, "/shutdown", &json!({ "aiThread": thread, "reason": reason.unwrap_or_default() }));
    report(r)
}