main
John Lauer 0.2.28 source: one thread per request, named agent_busy; battery aborts on a blocked bridge c4d9eb4 1mo ago
//! File-drop IPC to the resident DelphiScript agent (protocol identical to the Python bridge):
//!   request.txt   lines `id=<rid>`, `command=<cmd>`, `k=v`... (CRLF), written atomically via .tmp+rename
//!   request.payload  optional multi-line body (statements for eval, specs, ...)
//!   response_<rid>.json  the agent's reply; `data.ok` decides success
//!   hb.txt  heartbeat the loop refreshes every tick
//!
//! The workspace is PER INSTANCE (`agent/<key>/`): two Altiums polling one inbox means whichever
//! notices first eats the other's request and answers it from the wrong board (measured
//! 2026-09-01 running Designer + Designer Develop for two AI threads). No selected instance =
//! the legacy single-instance folder, so one-Altium users see no change.
use crate::instance;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime};

/// Two different questions, two thresholds (measured 2026-08-29):
/// responsive = hb < 3 s (idle-polling right now); alive = hb < 60 s (the loop exists but may be
/// INSIDE a long RunProcess and cannot heartbeat until it returns). Treating "busy" as "dead" and
/// starting a second loop raises "Another script executing now" on the user's screen.
pub const ALIVE_SEC: u64 = 60;
pub const RESPONSIVE_SEC: u64 = 3;

pub fn cache_dir() -> PathBuf {
    let local = std::env::var("LOCALAPPDATA").unwrap_or_else(|_| r"C:\Users\Public".into());
    PathBuf::from(local).join("Adom Bridge").join("bridges-cache").join("altium")
}

fn agent_root() -> PathBuf {
    cache_dir().join("agent")
}

pub fn workspace_dir() -> PathBuf {
    let root = agent_root();
    let d = match instance::selected().and_then(|i| i.get("key").and_then(|k| k.as_str()).map(str::to_string)) {
        Some(key) => root.join(key),
        None => root,
    };
    let _ = std::fs::create_dir_all(&d);
    d
}

fn hb_age(ws: &std::path::Path) -> Option<Duration> {
    std::fs::metadata(ws.join("hb.txt"))
        .and_then(|m| m.modified())
        .ok()
        .and_then(|t| SystemTime::now().duration_since(t).ok())
}

pub fn is_alive() -> bool {
    hb_age(&workspace_dir()).map(|d| d < Duration::from_secs(ALIVE_SEC)).unwrap_or(false)
}

pub fn is_responsive() -> bool {
    hb_age(&workspace_dir()).map(|d| d < Duration::from_secs(RESPONSIVE_SEC)).unwrap_or(false)
}

/// Heartbeat parsed: first line = tick, then k=v (gen=, phase counters...).
pub fn heartbeat() -> Option<Value> {
    let ws = workspace_dir();
    let raw = std::fs::read_to_string(ws.join("hb.txt")).ok()?;
    let age = hb_age(&ws)?;
    let mut out = json!({"ageSec": (age.as_millis() as f64) / 1000.0});
    let mut lines = raw.lines();
    if let Some(first) = lines.next() {
        if let Ok(t) = first.trim().parse::<u64>() {
            out["tick"] = json!(t);
        }
    }
    for l in lines {
        if let Some((k, v)) = l.split_once('=') {
            out[k.trim()] = json!(v.trim());
        }
    }
    Some(out)
}

/// Is ANY agent loop alive in ANY workspace (legacy root or any per-instance subfolder)?
/// A `-R` script lane must refuse if so: Altium raises "Another script executing now" for the
/// second script REGARDLESS of which workspace the first one polls. Returns the workspace path
/// of the live loop so the refusal can name it.
pub fn any_agent_alive() -> Option<(PathBuf, f64)> {
    live_workspaces().into_iter().next()
}

/// EVERY workspace with a live heartbeat — the legacy root AND every per-instance subdir —
/// each with its heartbeat age. The freeze in #6 happened because a loop bootstrapped into the
/// legacy root (no instance was resolved at the time) while `is_alive()` — which reads only the
/// SELECTED workspace — reported "not resident", so `altium_close` skipped its agent teardown and
/// the polite WM_CLOSE went out over a live loop. Teardown and status must scan the UNION, never
/// one workspace: a loop anywhere is a loop that can wedge the shutdown.
pub fn live_workspaces() -> Vec<(PathBuf, f64)> {
    let root = agent_root();
    let mut candidates = vec![root.clone()];
    if let Ok(rd) = std::fs::read_dir(&root) {
        for e in rd.flatten() {
            if e.path().is_dir() {
                candidates.push(e.path());
            }
        }
    }
    let mut live = Vec::new();
    for ws in candidates {
        if let Some(age) = hb_age(&ws) {
            if age < Duration::from_secs(ALIVE_SEC) {
                live.push((ws, (age.as_millis() as f64) / 1000.0));
            }
        }
    }
    live
}

fn rid() -> String {
    let n = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
    format!("{:x}", n & 0xffff_ffff_ffff)
}

pub fn call(command: &str, params: &[(&str, String)], payload: Option<&str>, timeout: Duration) -> Value {
    if !is_alive() {
        // A dead agent with Altium still showing the agent's script is a script PARKED ON A
        // BREAKPOINT: Altium's debugger breaks on any raised exception, even one the agent's own
        // Try/Except handled, and Altium Develop does it with no error box. The command that raised
        // already answered, so nobody noticed; the NEXT verb (this one) would now start a second
        // script into a parked engine and block until ab's HTTP timeout, and so would every verb after
        // it (the 30-verb cascade the battery found, 2026-09-20). Free the engine first.
        let mut freed = Value::Null;
        if crate::health::is_running() && is_agent_script_title(&main_title()) {
            freed = crate::lifecycle::recover(&format!("auto: agent dead and the engine parked on the agent script before '{command}'"));
            crate::dialogs::note_event(json!({"title": "(silent script halt)", "category": "engine_parked_cleared", "pressed": "EditScript:Stop", "ok": true,
                "note": format!("the agent had stopped on a breakpoint without an error box; cleared before running '{command}'")}));
        }
        // parity with the Python bridge: bootstrap on demand, so a cold box needs no ceremony
        let started = crate::lifecycle::start(Duration::from_secs(45));
        if !freed.is_null() {
            let mut r = if started.get("success").and_then(|b| b.as_bool()) == Some(true) {
                call_in(&workspace_dir(), command, params, payload, timeout)
            } else { started.clone() };
            r["enginePreviouslyParked"] = json!(true);
            r["_parkedNote"] = json!("Before this verb the agent was found stopped on a script breakpoint with no error box (an earlier command raised an exception Altium's debugger broke on). The bridge cleared it and restarted the agent. altium_dialog_log shows when.");
            if started.get("success").and_then(|b| b.as_bool()) == Some(true) { return r; }
        }
        if started.get("success").and_then(|b| b.as_bool()) != Some(true) {
            let mut out = started;
            if let Some(o) = out.as_object_mut() {
                o.entry("errorCode").or_insert(json!("agent_not_running"));
                o.insert("_hint".into(), json!("the resident agent could not be bootstrapped — see this reply's fields; altium_agent_recover if the engine is halted behind a modal"));
            }
            return out;
        }
    }
    call_in(&workspace_dir(), command, params, payload, timeout)
}

/// The file-drop round trip against an EXPLICIT workspace. Instance-exempt verbs (state,
/// diagnostics) have no selection, so `workspace_dir()` points at the legacy root and goes blind
/// to a per-instance loop (adom/altium-bridge#8); they resolve a live workspace via
/// `live_workspaces()` and call here directly.
/// The agent command currently posted (the agent serves one at a time): named in agent_busy.
static IN_FLIGHT: std::sync::Mutex<Option<(String, Instant)>> = std::sync::Mutex::new(None);

pub fn call_in(ws: &std::path::Path, command: &str, params: &[(&str, String)], payload: Option<&str>, timeout: Duration) -> Value {
    let ws = ws.to_path_buf();
    let id = rid();
    let mut lines = vec![format!("id={id}"), format!("command={command}")];
    for (k, v) in params {
        if v.contains('\n') || v.contains('\r') {
            return json!({"success": false, "error": format!("param {k} must be single-line; use payload")});
        }
        lines.push(format!("{k}={v}"));
    }
    let inbox = ws.join("request.txt");
    let t0 = Instant::now();
    while inbox.exists() && t0.elapsed() < timeout {
        std::thread::sleep(Duration::from_millis(5));
    }
    let t_inbox = t0.elapsed().as_millis() as u64;
    if inbox.exists() {
        let holder = IN_FLIGHT.lock().ok().and_then(|g| g.clone());
        let (what, secs) = holder.map(|(c, at)| (c, at.elapsed().as_secs())).unwrap_or_else(|| ("(unknown)".into(), 0));
        return json!({"success": false, "errorCode": "agent_busy", "busyWith": what, "busyForSec": secs,
            "error": format!("the Altium agent is still running '{what}' ({secs} s); '{command}' was not started"),
            "_hint": "The agent runs ONE command at a time inside Altium. Wait and retry, or if busyForSec is far beyond what that command should take: altium_check_dialogs (a box may be blocking it), then altium_agent_recover."});
    }
    if let Ok(mut g) = IN_FLIGHT.lock() { *g = Some((command.to_string(), Instant::now())); }
    if let Some(p) = payload {
        let tmp = ws.join("request.payload.tmp");
        if std::fs::write(&tmp, p).is_ok() {
            let _ = std::fs::rename(&tmp, ws.join("request.payload"));
        }
    }
    let tmp = ws.join("request.txt.tmp");
    if let Err(e) = std::fs::write(&tmp, format!("{}\r\n", lines.join("\r\n"))) {
        return json!({"success": false, "error": format!("cannot write request: {e}")});
    }
    if let Err(e) = std::fs::rename(&tmp, &inbox) {
        return json!({"success": false, "error": format!("cannot post request: {e}")});
    }
    let t_post = t0.elapsed().as_millis() as u64;
    let posted_unix_ms = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|x| x.as_millis() as u64).unwrap_or(0);
    let mut next_halt_check = Duration::from_millis(400);
    let resp = ws.join(format!("response_{id}.json"));
    let mut t_seen: Option<u64> = None;
    let mut parse_retries = 0u32;
    while t0.elapsed() < timeout {
        if resp.exists() {
            if t_seen.is_none() { t_seen = Some(t0.elapsed().as_millis() as u64); }
            if let Ok(raw) = std::fs::read_to_string(&resp) {
                if let Ok(mut v) = serde_json::from_str::<Value>(&raw) {
                    let _ = std::fs::remove_file(&resp);
                    let ok = v.get("data").and_then(|d| d.get("ok")).and_then(|b| b.as_bool()).unwrap_or(true);
                    if let Some(o) = v.as_object_mut() {
                        o.insert("success".into(), json!(ok));
                        o.insert("roundtripMs".into(), json!(t0.elapsed().as_millis() as u64));
                        o.insert("_timing".into(), json!({"inboxWaitMs": t_inbox, "postedAtMs": t_post, "responseSeenAtMs": t_seen, "parseRetries": parse_retries, "totalMs": t0.elapsed().as_millis() as u64}));
                    }
                    return v;
                }
            }
            parse_retries += 1;
            std::thread::sleep(Duration::from_millis(5));
            continue;
        }
        // The engine halted on a script runtime error: the reaper pressed the Error box, the
        // script now sits on a breakpoint and will NEVER answer. Do not burn the timeout and do
        // not leave Altium parked in its debugger: stop the script now and say what happened.
        if t0.elapsed() >= next_halt_check {
            next_halt_check = t0.elapsed() + Duration::from_millis(400);
            // Variant 2 (script run from INSIDE the agent, e.g. eval): Altium asks "Undeclared
            // identifier ... Continue execution?" in an 'X2' box with OK + Cancel. It is OURS (it
            // only exists while our command runs), the reaper rightly never touches a two-button
            // box, and the agent loop is blocked behind it. OK = continue: the loop survives.
            for d in crate::dialogs::scan() {
                let title = d.get("title").and_then(|t| t.as_str()).unwrap_or("").trim().to_string();
                let labels: Vec<String> = d.get("buttons").and_then(|b| b.as_array())
                    .map(|a| a.iter().filter_map(|x| x.as_str()).map(|s| s.trim().to_lowercase()).collect()).unwrap_or_default();
                let hwnd = d.get("hwnd").and_then(|h| h.as_i64());
                if title != "X2" || labels != ["ok", "cancel"] || hwnd.is_none() { continue; }
                let hwnd = hwnd.unwrap();
                let shot = crate::ab::call("desktop_screenshot_base64", json!({"hwnd": hwnd}), &[], 10);
                let img = shot.get("image").or_else(|| shot.get("base64")).cloned().unwrap_or(Value::Null);
                let pressed = crate::dialogs::press(hwnd, "OK").get("success").and_then(|b| b.as_bool()).unwrap_or(false);
                crate::dialogs::note_event(json!({"title": "X2", "category": "script_error_continue", "pressed": "OK", "ok": pressed,
                    "note": format!("script runtime error inside agent command '{command}': photographed, answered OK (continue) so the agent loop survives")}));
                let settle = Instant::now();
                while settle.elapsed() < Duration::from_secs(3) && !resp.exists() { std::thread::sleep(Duration::from_millis(20)); }
                let _ = std::fs::remove_file(&resp);
                return json!({"success": false, "errorCode": "agent_script_error", "command": command,
                    "error": format!("the script run by '{command}' raised a runtime error (Altium asked 'Continue execution?'; its text is in errorShot)"),
                    "errorShot": img, "answered": "OK", "answeredOk": pressed, "agentAlive": is_alive(),
                    "_hint": "READ errorShot (PNG of Altium's box, e.g. 'Undeclared identifier: X at Line:N'). The bridge answered OK so the agent loop is still resident; fix the script text and send it again."});
            }
            if let Some(ev) = crate::dialogs::script_error_since(posted_unix_ms) {
                let recovered = crate::lifecycle::recover(&format!("auto: script Error box during agent command '{command}'"));
                return json!({"success": false, "errorCode": "agent_script_error", "command": command,
                    "error": format!("the agent script raised a runtime error while running '{command}' (Altium showed an 'Error' box; its text is in errorShot)"),
                    "errorShot": ev.get("shot").cloned().unwrap_or(Value::Null),
                    "autoRecovered": recovered.get("success").and_then(|b| b.as_bool()).unwrap_or(false),
                    "recoverSteps": recovered.get("steps").cloned().unwrap_or(Value::Null),
                    "agentAlive": is_alive(),
                    "_hint": "READ errorShot (a PNG of Altium's error box, e.g. 'Undeclared identifier: X' = that API does not exist on this Altium build). The bridge already sent EditScript:Stop, so Altium is NOT parked on a breakpoint and the next verb restarts the agent. Do not retry the same command: it will halt again. Report it on adom/altium-bridge with the errorShot."});
            }
        }
        std::thread::sleep(Duration::from_millis(2));
    }
    json!({"success": false, "errorCode": "agent_timeout", "error": format!("agent did not answer within {}s", timeout.as_secs()),
           "agentAlive": is_alive(), "_hint": "a slow Altium process, or the engine halted on a modal — altium_check_dialogs / altium_agent_recover"})
}

/// A call that must NOT bootstrap the agent (status probes). Answers agent_not_running instead.
pub fn call_no_autostart(command: &str, params: &[(&str, String)], timeout: Duration) -> Value {
    if !is_alive() {
        return json!({"success": false, "errorCode": "agent_not_running"});
    }
    call(command, params, None, timeout)
}

/// Title of Altium's main window ("" when there is none).
fn main_title() -> String {
    instance::main_window().and_then(|w| w.get("title").and_then(|t| t.as_str()).map(str::to_string)).unwrap_or_default()
}

/// True when the focused document is the resident agent's own script: the state Altium puts its
/// window in when that script breaks in the debugger.
pub fn is_agent_script_title(title: &str) -> bool {
    let t = title.to_lowercase();
    t.starts_with("adomagent_") && (t.contains(".prjscr") || t.contains(".pas"))
}

#[cfg(test)]
mod halt_tests {
    use super::*;

    #[test]
    fn agent_script_title_is_recognised() {
        assert!(is_agent_script_title("AdomAgent_00fc18631e.PrjScr - Altium Designer Develop"));
        assert!(is_agent_script_title("AdomAgent_9fe87fbf7c.pas - Altium Designer (26.9.1) - Trial"));
        assert!(!is_agent_script_title("battery.PcbDoc - Altium Designer Develop"));
        assert!(!is_agent_script_title("Home Page - Altium Designer Develop"));
        assert!(!is_agent_script_title(""));
    }
}