master
Drew Owens A busy add-in is not a missing add-in: never restart Fusion for it 8915d3d 15d ago
//! Phase 3: `fusion_readiness`, the launch observer, ported as a PURE computation over probed
//! inputs. The probes (Toolhelp, the add-in HTTP status, ab's window rows, APS, prefs, the
//! timings store) are gathered by the exe; this module turns them into the exact result the
//! Python handler produces, hint text included (`readiness_hints.json` is derived from the
//! Python goldens, never hand-typed). Side effects (seat auto-resolve, crash janitor, launch
//! self-heal, auto-update) are NOT here: they are returned as `Actions` for the caller to
//! run when it owns readiness, and the pref writes come back as `prefs_after`.
//!
//! AI hint: `tests/golden/readiness.json` was generated by running the Python handler with
//! every helper stubbed per scenario. A difference from it is a bug here until proven
//! otherwise; regenerate the goldens only when server.py's handler changes on purpose.

use serde_json::{json, Map, Value};

/// Python's `%r` of a str (single-quoted unless it holds a single quote and no double).
fn py_repr_str(s: &str) -> String { if s.contains('\'') && !s.contains('"') { format!("\"{s}\"") } else { format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'")) } }

const HINTS: &str = include_str!("readiness_hints.json");
fn hint(key: &str) -> String {
    let h: Value = serde_json::from_str(HINTS).expect("readiness_hints.json");
    h[key].as_str().unwrap_or("").to_string()
}

/// What the exe probed, the same facts the Python handler gathers itself.
#[derive(Debug, Clone, Default)]
pub struct Inputs {
    pub installed_raw: bool,
    /// installer process seen OR a launcher dir with no complete app anywhere
    pub streaming: bool,
    pub running: bool,
    /// the add-in's /status version when it answered, None when silent
    pub addin_version: Option<String>,
    pub expected_addin_version: String,
    pub needs_signin: bool,
    pub licensing_dialog: bool,
    pub updating: bool,
    pub aps_configured: bool,
    pub aps_signed_in: bool,
    pub mcp_port_open: bool,
    pub auto_update_enabled: bool,
    /// `get_app_state` round-trip through the add-in succeeded (only asked when ready)
    pub roundtrip_ok: bool,
    pub kicad_ingest: bool,
    pub fusion_user: Option<Value>,
    pub modeling_orientation: Option<String>,
    pub bridge_version: String,
    pub prefs: Map<String, Value>,
    pub now: f64,
    pub now_iso: String,
    /// `timings.summary()["launch"]` and `timings.stats("launch","cold")`
    pub timings_launch: Value,
    pub launch_stats: Value,
    /// A caller-supplied progress block for tests; production builds one from timings.
    pub progress_override: Option<Value>,
    pub addin_start_failed: Option<Value>,
    pub crash_dialogs_cleared: usize,
    /// The owner ran the silent update click before computing: the button it clicked.
    pub update_applied_button: Option<String>,
    /// Fusion is offering an update right now (the sidecar's `_LAST_UPDATE_OFFER`).
    pub update_offered: bool,
    /// Why a not-ready add-in is loaded and working rather than missing (its /status says busy, or
    /// it is starved silent with one of our requests in flight or after answering in this Fusion).
    /// A busy add-in is not a launch: the launch observer neither starts nor heals over it.
    pub addin_busy: Option<Value>,
}

/// The side effects the Python handler would have taken, for the owner to run.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
pub struct Actions {
    #[serde(rename = "resolveSeatDialog")] pub resolve_seat_dialog: bool,
    #[serde(rename = "applyUpdateSilently")] pub apply_update_silently: bool,
    #[serde(rename = "healLaunch")] pub heal_launch: Option<u64>,
    #[serde(rename = "recordLaunchSeconds")] pub record_launch_seconds: Option<f64>,
}

pub struct Outcome { pub result: Value, pub prefs_after: Map<String, Value>, pub actions: Actions }

fn pct(sorted: &[f64], q: f64) -> Option<f64> {
    if sorted.is_empty() { return None; }
    let i = (q * (sorted.len() as f64 - 1.0)).round_ties_even() as i64;
    Some(sorted[i.clamp(0, sorted.len() as i64 - 1) as usize])
}

/// `timings.summary()` over the store's JSON ({"phase|kind": {samples, lastAt}}).
pub fn timings_summary(store: &Value) -> Value {
    let mut out = Map::new();
    if let Some(m) = store.as_object() {
        for (k, row) in m {
            let (phase, kind) = match k.find('|') { Some(i) => (&k[..i], &k[i + 1..]), None => (k.as_str(), "") };
            let mut s: Vec<f64> = row["samples"].as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();
            if s.is_empty() { continue; }
            s.sort_by(|a, b| a.partial_cmp(b).unwrap());
            let entry = out.entry(phase.to_string()).or_insert_with(|| json!({}));
            entry[kind] = json!({"p50": pct(&s, 0.5), "p90": pct(&s, 0.9), "count": s.len(), "lastAt": row["lastAt"]});
        }
    }
    Value::Object(out)
}

/// `timings.stats(phase, kind)`.
pub fn timings_stats(store: &Value, phase: &str, kind: &str) -> Value {
    let mut s: Vec<f64> = store[format!("{phase}|{kind}")]["samples"].as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();
    s.sort_by(|a, b| a.partial_cmp(b).unwrap());
    json!({"p50": pct(&s, 0.5), "p90": pct(&s, 0.9), "count": s.len()})
}

/// `timings.progress_block` for the launch phase (percent derived from elapsed / estimate).
pub fn progress_block(stats: &Value, started: f64, now: f64, label: &str, fallback: f64) -> Value {
    let elapsed = (now - started).max(0.0);
    let (count, p50) = (stats["count"].as_u64().unwrap_or(0), stats["p50"].as_f64());
    let (est0, mut conf) = match (count, p50) { (c, Some(p)) if c >= 2 && p > 0.0 => (p, "measured"), (1, Some(p)) if p > 0.0 => (p, "typical"), _ => (fallback, "typical") };
    let mut est = est0;
    let overdue = est0 > 0.0 && elapsed > est0;
    if overdue {
        est = elapsed * 1.25;
        if conf == "measured" { conf = "typical"; }
        if elapsed > 2.0 * est0 { conf = "unknown"; }
    }
    let mut percent: i64 = if est0 <= 0.0 { 0 } else if !overdue { (80.0 * elapsed / est0) as i64 } else { let over = elapsed - est0; (80.0 + 15.0 * (over / (over + est0))) as i64 };
    percent = percent.clamp(0, if est > 0.0 { 99 } else { 0 });
    let eta = if est > 0.0 { Some(((est - elapsed).round() as i64).max(0)) } else { None };
    json!({"phase": "launch", "stepLabel": label, "percent": percent, "elapsedSec": elapsed.round() as i64,
           "estimatedSec": if est > 0.0 { json!(est.round() as i64) } else { Value::Null }, "etaSec": eta,
           "confidence": if est > 0.0 { conf } else { "unknown" }, "startKind": "cold", "overdue": overdue})
}

pub fn compute(i: &Inputs) -> Outcome {
    let mut prefs = i.prefs.clone();
    let mut actions = Actions::default();
    let (installed, installing) = if i.streaming && !i.running { (false, true) } else { (i.installed_raw, !i.installed_raw && i.streaming) };
    let running = if i.installed_raw { i.running } else { false };
    let addin_ok = i.addin_version.is_some();
    let needs_signin = running && i.needs_signin;
    let mut ready = installed && running && addin_ok && !needs_signin;
    let licensing = running && i.licensing_dialog;
    if licensing { actions.resolve_seat_dialog = true; ready = false; }
    let updating = if installed && !ready { i.updating } else { false };
    let mut stale = Map::new();
    if addin_ok {
        let reported = i.addin_version.clone().unwrap_or_default();
        let is_stale = reported != "unknown" && i.expected_addin_version != "unknown" && reported != i.expected_addin_version;
        stale.insert("addinVersion".into(), json!(reported));
        stale.insert("expectedAddinVersion".into(), json!(i.expected_addin_version));
        stale.insert("addinStale".into(), json!(is_stale));
        if is_stale { stale.insert("_staleHint".into(), json!(hint("stale_template").replace("{reported}", &reported).replace("{expected}", &i.expected_addin_version))); }
    }
    let mut hint_text = if updating && !ready { hint("updating") }
        else if needs_signin { hint("needs_signin") }
        else if ready && stale["addinStale"] == json!(true) { stale["_staleHint"].as_str().unwrap_or("").to_string() }
        else if ready {
            actions.apply_update_silently = true;
            let mut msg = if i.mcp_port_open { hint("mcp_on") } else { hint("mcp_off") };
            if let Some(b) = &i.update_applied_button { msg.push_str(&hint("upd_applied_template").replace("{button}", &py_repr_str(b))); }
            else if !i.auto_update_enabled { msg.push_str(&hint("upd_off")); }
            let base = if i.aps_signed_in { hint("ready_signed") } else if i.aps_configured { hint("ready_configured") } else { hint("ready_unconfigured") };
            base + &msg
        }
        else if installed && running && licensing { hint("licensing") }
        else if installed && running { hint("addin_silent") }
        else if installed { hint("installed_not_running") }
        else if installing { hint("installing") } else { hint("not_installed") };
    let mut note: Option<String> = None;
    if ready && !i.roundtrip_ok { ready = false; note = Some(hint("roundtrip_note")); }
    if let Some(n) = &note { hint_text = format!("{hint_text}{}{n}", hint("roundtrip_note_prefix")); }
    // a loaded add-in that is working is not a launch in progress, and never a reason to restart
    let busy = running && !ready && !needs_signin && !licensing && i.addin_busy.is_some();
    if busy { hint_text = hint("addin_busy"); }
    let orient_note = if i.modeling_orientation.as_deref() == Some("yup") {
        " ⚠ Default Modeling Orientation is Y-UP on this account, but Adom is a Z-UP environment (a fresh Autodesk account defaults to Y-up). Set it with fusion_set_preference {\"modelingOrientation\":\"zup\"} - no restart needed." } else { "" };
    let mut result = Map::new();
    result.insert("success".into(), json!(true));
    result.insert("hostApp".into(), json!("Fusion 360"));
    result.insert("installed".into(), json!(installed));
    result.insert("installing".into(), json!(installing));
    result.insert("running".into(), json!(running));
    result.insert("ready".into(), json!(ready));
    result.insert("kicadIngest".into(), json!(i.kicad_ingest));
    result.insert("fusionUser".into(), i.fusion_user.clone().unwrap_or(Value::Null));
    result.insert("modelingOrientation".into(), i.modeling_orientation.clone().map(Value::String).unwrap_or(Value::Null));
    result.insert("bridgeVersion".into(), json!(i.bridge_version));
    for (k, v) in &stale { result.insert(k.clone(), v.clone()); }
    result.insert("_hint".into(), json!(format!("{hint_text}{orient_note}")));
    // the launch observer
    result.insert("timings".into(), i.timings_launch.clone());
    if busy { result.insert("addinBusy".into(), i.addin_busy.clone().unwrap_or(Value::Null)); }
    let now = i.now;
    let mut lsa = prefs.get("launchStartedAt").and_then(|v| v.as_f64());
    if busy && lsa.is_some() { prefs.insert("launchStartedAt".into(), Value::Null); lsa = None; }
    if let Some(s) = lsa { if !ready && now - s > 900.0 { prefs.insert("launchStartedAt".into(), json!(now)); prefs.insert("launchHealedAt".into(), Value::Null); lsa = Some(now); } }
    if lsa.is_none() && running && !ready && !busy { prefs.insert("launchStartedAt".into(), json!(now)); lsa = Some(now); }
    if !ready && running && i.crash_dialogs_cleared > 0 {
        result.insert("crashReportCleared".into(), json!(i.crash_dialogs_cleared));
        result.insert("fusionCrashed".into(), json!(true));
        prefs.insert("launchHealedAt".into(), Value::Null); prefs.insert("launchStartedAt".into(), json!(now)); lsa = Some(now);
    }
    let healed = prefs.get("launchHealedAt").map(|v| !v.is_null()).unwrap_or(false);
    if !ready && running && !busy && !needs_signin && !licensing && !updating && lsa.is_some() && !healed {
        let p90 = i.launch_stats["p90"].as_f64().unwrap_or(0.0);
        let overdue_by = (2.0 * p90).max(180.0);
        if now - lsa.unwrap() > overdue_by {
            prefs.insert("launchHealedAt".into(), json!(now));
            result.insert("addinRestartTriggered".into(), json!(true));
            let attempt = prefs.get("launchHealAttempts").and_then(|v| v.as_u64()).unwrap_or(0) + 1;
            if attempt > 4 {
                result.insert("addinRestartExhausted".into(), json!(true));
                result.insert("_hint".into(), json!(format!("Fusion has been restarted {} times and the add-in still does not serve. This is no longer a slow launch: screenshot the Fusion hwnd and read screenshots[] for a blocking dialog, or check whether Fusion is crashing at startup (fusionCrashed / crash_report).", attempt - 1)));
            } else {
                prefs.insert("launchHealAttempts".into(), json!(attempt));
                result.insert("addinRestartAttempt".into(), json!(attempt));
                actions.heal_launch = Some(attempt);
            }
        }
    }
    if ready {
        prefs.insert("launchHealedAt".into(), Value::Null);
        prefs.insert("launchHealAttempts".into(), json!(0));
        if let Some(s) = lsa {
            let el = now - s;
            if (3.0..=3600.0).contains(&el) { actions.record_launch_seconds = Some(el); }
            prefs.insert("launchStartedAt".into(), Value::Null);
        }
    } else if let Some(s) = lsa {
        if now - s < 900.0 {
            let label = if needs_signin { "Signing in to Autodesk" } else if licensing { "Clearing the Autodesk seat dialog" } else if updating { "Applying a Fusion auto-update" } else if running { "Fusion is up - waiting for the Adom add-in to serve" } else { "Fusion is starting" };
            let mut pb = match &i.progress_override { Some(v) => { let mut v = v.clone(); v["stepLabel"] = json!(label); v }, None => progress_block(&i.launch_stats, s, now, label, 150.0) };
            if pb["overdue"] == json!(true) && pb["confidence"] == json!("unknown") {
                pb["stepLabel"] = json!(format!("{label} - this is longer than this machine has ever taken; restarting Fusion, since the add-in only loads at launch"));
            }
            result.insert("progress".into(), pb);
            result.insert("statusVerb".into(), json!("fusion_readiness"));
        }
    }
    if updating { result.insert("updating".into(), json!(true)); result.insert("statusVerb".into(), json!("fusion_readiness")); }
    if needs_signin { result.insert("needsSignin".into(), json!(true)); result.insert("statusVerb".into(), json!("fusion_readiness")); }
    if ready && running { prefs.insert("lastReadyAt".into(), json!(i.now_iso)); }
    if running && !needs_signin && ready { prefs.insert("lastSignedInAt".into(), json!(i.now_iso)); }
    result.insert("lastReadyAt".into(), prefs.get("lastReadyAt").cloned().unwrap_or(Value::Null));
    result.insert("lastSignedInAt".into(), prefs.get("lastSignedInAt").cloned().unwrap_or(Value::Null));
    if licensing { result.insert("licensingDialog".into(), json!(true)); result.insert("statusVerb".into(), json!("fusion_readiness")); }
    if i.update_offered {
        result.insert("fusionUpdateOffered".into(), json!(true));
        let handled = i.update_applied_button.is_some();
        result.insert("fusionUpdateHandled".into(), json!(handled));
        result.insert("fusionUpdateHint".into(), json!(if handled { hint("fusion_update_hint_handled") } else { hint("fusion_update_hint_pending_template").replace("{state}", if !i.auto_update_enabled { "opted out" } else { "pending" }) }));
    }
    if let Some(asf) = &i.addin_start_failed {
        result.insert("addinStartFailed".into(), asf.clone());
        let h = format!("{} {}", asf["_hint"].as_str().unwrap_or(""), result["_hint"].as_str().unwrap_or(""));
        result.insert("_hint".into(), json!(h));
    }
    Outcome { result: Value::Object(result), prefs_after: prefs, actions }
}