main
Drew Owens 0.9.9 source: user-presence guard; adversary pass 45 (unreadable log unverifiable, latch keyed by pid+creation); manife… e772fef 19d ago
//! HAS THIS SOLIDWORKS SESSION EVER HELD A DOCUMENT?
//!
//! MEASURED 2026-10-01 on Drew's machine (pid 20144, bridge-launched, attributed,
//! hosted 0.9.2): `swx_stop` rung (a) — UserControl=false, ExitApp, every COM
//! reference released — was DECLINED three times, at ~50 s, ~2.5 min and ~8 min
//! old: 0 documents, no dialog, census readable, only the main frame visible, an
//! empty auto-recover folder. The session had NEVER had a document open. Then, on
//! the SAME session: one scratch part created (`swx_new_document`), closed
//! (`swx_reset_workspace`), and rung (a) quit in 3.4 s. That reconciles the two
//! September observations — 09-08's "ExitApp declined at zero documents" was a
//! never-had-a-document session, 09-17's "not declined at zero documents" were
//! sessions that had held documents. So the rule is about session HISTORY, not
//! the current document count, and the quit ladder needs to know it.
//!
//! Recorded per session — (pid, creation time) — in `session-history.json` in the
//! data root: TRUE when the bridge itself created or opened a document in it, or
//! when a document count > 0 persists across two polls of the STARTED session
//! (never from a count read while starting — live 2026-10-01, pid 29108, such a
//! read recorded a session no one had opened a document in). ADVISORY since
//! 0.9.6: the quit ladder's priming no longer reads it. `None` (UNKNOWN) when
//! the bridge did not watch the session from its start: it attached to one it
//! did not launch, so a document may have come and gone unseen.

use serde_json::{json, Value};

fn path() -> std::path::PathBuf {
    crate::scratch::root().join("session-history.json")
}

fn key(pid: u32, created: Option<u64>) -> String {
    format!("{pid}:{}", created.map(|c| c.to_string()).unwrap_or_else(|| "?".into()))
}

fn load() -> serde_json::Map<String, Value> {
    std::fs::read_to_string(path())
        .ok()
        .and_then(|t| serde_json::from_str::<Value>(&t).ok())
        .and_then(|v| v.as_object().cloned())
        .unwrap_or_default()
}

fn save(m: serde_json::Map<String, Value>) {
    let mut m = m;
    while m.len() > 20 {
        let oldest = m
            .iter()
            .min_by_key(|(_, v)| v.get("atMs").and_then(|a| a.as_u64()).unwrap_or(0))
            .map(|(k, _)| k.clone());
        match oldest {
            Some(o) => {
                m.remove(&o);
            }
            None => break,
        }
    }
    let _ = crate::launch::write_atomic(&path(), &Value::Object(m).to_string());
}

/// Record that this session has held a document BECAUSE THE BRIDGE ITSELF
/// created or opened one in it — the only single event that is proof.
pub fn mark(pid: u32, created: Option<u64>) {
    set_true(pid, created, "the bridge itself created or opened a document in this session");
}

fn set_true(pid: u32, created: Option<u64>, basis: &str) {
    let mut m = load();
    let k = key(pid, created);
    let mut entry = m.get(&k).cloned().unwrap_or_else(|| json!({}));
    if entry.get("everHadDocument").and_then(|v| v.as_bool()) == Some(true) {
        return;
    }
    if let Value::Object(e) = &mut entry {
        e.insert("everHadDocument".into(), json!(true));
        e.insert("basis".into(), json!(basis));
        e.insert("atMs".into(), json!(crate::launchos::epoch_ms() as u64));
    }
    m.insert(k, entry);
    save(m);
}

/// Two observations count as two polls only if they are this far apart.
const DISTINCT_POLL_MS: u64 = 1000;

/// WHAT A POLL SAW: a document count read on a session the bridge has VERIFIED
/// ready (callers never pass a count read while starting). Recorded with its
/// time and source (the last eight are kept). It sets `everHadDocument` only
/// when a count > 0 PERSISTS across two distinct polls with no zero between them
/// — live 2026-10-01 (pid 29108), a single count read near startup recorded a
/// session nobody had opened a document in.
pub fn observe(pid: u32, created: Option<u64>, count: u64, source: &str) {
    let mut m = load();
    let k = key(pid, created);
    let now = crate::launchos::epoch_ms() as u64;
    let mut entry = m.get(&k).cloned().unwrap_or_else(|| json!({}));
    let mut obs: Vec<Value> =
        entry.get("observations").and_then(|v| v.as_array()).cloned().unwrap_or_default();
    obs.push(json!({"count": count, "atMs": now, "source": source}));
    while obs.len() > 8 {
        obs.remove(0);
    }
    // PERSISTED: an earlier observation with a count > 0, at least
    // DISTINCT_POLL_MS older than this one, and no zero-count observation since.
    let mut persisted = false;
    if count > 0 {
        for o in obs.iter().rev().skip(1) {
            if o.get("count").and_then(|c| c.as_u64()).unwrap_or(0) == 0 {
                break;
            }
            let at = o.get("atMs").and_then(|a| a.as_u64()).unwrap_or(now);
            if now.saturating_sub(at) >= DISTINCT_POLL_MS {
                persisted = true;
                break;
            }
        }
    }
    if let Value::Object(e) = &mut entry {
        e.insert("observations".into(), json!(obs));
        if !e.contains_key("atMs") {
            e.insert("atMs".into(), json!(now));
        }
    }
    m.insert(k, entry);
    save(m);
    if persisted {
        set_true(pid, created, "a document count > 0 on two distinct polls of the started session");
    }
}

/// `Some(true)`: this session has held a document. `Some(false)`: NONE SEEN BY
/// THIS BRIDGE — it launched the session and no poll since has seen a document
/// (a non-zero `GetDocumentCount` at any readiness poll, or a verb reply showing
/// one, sets it true). `None`: unknown.
pub fn ever_had_document(pid: u32, created: Option<u64>) -> Option<bool> {
    if load().get(&key(pid, created)).and_then(|v| v.get("everHadDocument")).and_then(|v| v.as_bool())
        == Some(true)
    {
        return Some(true);
    }
    match crate::launch::attributed_solidworks() {
        Some((p, c)) if p == pid && Some(c) == created => Some(false),
        _ => None,
    }
}

/// The verbs that bring a document into a session.
pub const ADDS_DOCUMENTS: &[&str] = &[
    "swx_new_document",
    "swx_open",
    "swx_import",
    "swx_add_component",
    "swx_show_part",
    "swx_show_assembly",
    "swx_show_drawing",
];

/// After a verb: if its reply shows a document open in the running session,
/// record it. Asks the host which session that is only when there is something
/// to record.
pub fn note_reply(ctx: &crate::verbs::Ctx, command: &str, reply: &Value) {
    if reply.get("success").and_then(|v| v.as_bool()) == Some(false) {
        return;
    }
    let made_one = ADDS_DOCUMENTS.contains(&command);
    let saw_one = ["documentCount", "count", "openDocuments"]
        .iter()
        .any(|k| reply.get(*k).and_then(|v| v.as_u64()).unwrap_or(0) > 0);
    if !(made_one || saw_one) {
        return;
    }
    let Ok(state) = ctx.call("startup_state", json!({}), 20) else { return };
    let Some(pid) = state.get("processId").and_then(|v| v.as_u64()).map(|p| p as u32) else { return };
    let created = crate::launchos::os()
        .processes()
        .ok()
        .and_then(|ps| ps.iter().find(|p| p.pid == pid).and_then(|p| p.created_at))
        .or_else(|| state.get("processCreatedAt").and_then(|v| v.as_u64()));
    if made_one {
        mark(pid, created);
    } else if crate::readiness::known_ready(pid, created) {
        // A COUNT IN A REPLY is a poll like any other: evidence only on a started
        // session, and only once it persists.
        let n = ["documentCount", "count", "openDocuments"]
            .iter()
            .filter_map(|k| reply.get(*k).and_then(|v| v.as_u64()))
            .max()
            .unwrap_or(0);
        observe(pid, created, n, command);
    }
}

/// THE SESSION'S HISTORY, for `swx_status` — so a caller can predict whether a
/// quit will need priming. `everHadDocument` is true, false or null, and `basis`
/// says which evidence decided it.
pub fn describe(pid: u32, created: Option<u64>) -> Value {
    let ever = ever_had_document(pid, created);
    let rec = load().get(&key(pid, created)).cloned().unwrap_or(Value::Null);
    let recorded_basis = rec.get("basis").and_then(|v| v.as_str()).map(String::from);
    let basis = match ever {
        Some(true) => recorded_basis.as_deref().unwrap_or("a document was seen open in this session"),
        Some(false) => "none seen by this bridge: it launched this session, it has not created or opened a \
                        document in it, and no document count > 0 has persisted across two polls of the \
                        started session — a document opened and closed between two polls would not be seen",
        None => "the bridge did not watch this session from its start, so a document may have come and gone unseen",
    };
    json!({
        "everHadDocument": ever,
        "basis": basis,
        "processId": pid,
        "processCreatedAt": created,
        // ADVISORY ONLY (0.9.6): no decision reads this — priming triggers on the
        // measured decline itself.
        "advisory": true,
        "observations": rec.get("observations").cloned().unwrap_or_else(|| json!([])),
        "note": "MEASURED 2026-10-01: a session that has never held a document quit without help at \
                 ~25 s old but DECLINED ExitApp at 50 s, 130 s, 150 s and 8 min old; swx_stop primes \
                 an EMPTY session whose ExitApp was declined (userAuthorized, no blocking dialog) and \
                 asks once more — whatever this history says, which is advisory.",
    })
}