master
John Lauer rust: show family, progress registry (verb_times, operations), kicad_upgrade with silent install; alpha.3, 94 verbs, 18… 46c748c 2mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823
//! The operation progress registry, ported from `handlers/progress.py` and the dispatch
//! wrapper in `server.py` (the MEASURED-progress contract, wiki issue #32).
//!
//! John's rule, verbatim from the issue: every slow verb reports measured progress, never
//! "can take a couple of minutes". A shrug is not an estimate: it cannot be drawn as a
//! bar, it never narrows, and it hides the difference between slow and wedged.
//!
//! This module owns four things, all process-wide and thread-safe (the server is
//! multithreaded: a `kicad_progress` poll runs while a long verb is in flight):
//!
//! 1. A rolling, PER-MACHINE history of how long each phase actually took, split by cold
//!    vs warm start, persisted at `~/.adom/kicad-bridge-timings.json`. Estimates come from
//!    this machine's own p50, never from a hardcoded literal.
//! 2. Honest CONFIDENCE. 0 samples and no seed -> "unknown"; a seeded default or 1-2
//!    samples -> "typical"; 3 or more -> "measured".
//! 3. The LIVE frame per in-flight (caller, phase) pair (wiki #46: two callers driving one
//!    box never see each other's steps), read by `active()` / `live()`; percent and ETA are
//!    recomputed at READ time so a poller sees a bar that keeps moving, and an explicit
//!    step percent is a monotonic FLOOR, never a freeze.
//! 4. The per-verb run log (`~/.adom/kicad-verb-times.jsonl`, append-only, one generation
//!    of rotation at 4 MB): every dispatch with its outcome, which is what `kicad_status`
//!    hands a poller after ab's budget ran out (#87) and what `kicad_verb_times` aggregates.
//!
//! Phase ids are namespaced (`kicad.show_symbol`) so a UI can group histories across
//! bridges, and `blockedBy` names a human-controlled wait (a modal) so a UI can say
//! "waiting for you" instead of animating a bar through something that cannot progress.
//!
//! Integration (verbs.rs dispatch): `let mut h = progress::begin(command, || kicad_running);`
//! before the verb runs, `h.attach(&mut out)` after. `attach` logs the run, retires the
//! live frame and, for an instrumented verb, inserts the terminal `progress` block.

use std::collections::BTreeMap;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

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

pub const PREFIX: &str = "kicad";
/// Rolling window per key (fusion parity, John 2026-08-21).
const KEEP: usize = 10;
const RUNLOG_MAX_BYTES: u64 = 4 * 1024 * 1024;
/// The identity a frame carries when the request was anonymous (Python `_caller()`).
pub const ANONYMOUS: &str = "unknown";

/// Shipped defaults, used ONLY to seed a machine with no history of its own. Every one
/// of these is a measured figure from this fleet, not a guess.
fn default_seconds(phase: &str) -> Option<f64> {
    Some(match phase {
        "cold_start" => 25.0, // kicad.exe from nothing, 10.0.5
        "warm_start" => 1.0,  // KiCad already running
        "show_symbol" => 12.0,
        "show_footprint" => 20.0, // the Footprint Editor loads its libraries as it opens
        "show_3d_chip" => 25.0,
        "show_library" => 12.0,
        "show_schematic" => 15.0,
        "show_2d_board" => 15.0,
        "show_3d_board" => 30.0,
        "show_project" => 10.0,
        "install_plugin" => 6.0,
        "upgrade" => 240.0, // download + silent install
        "export" => 8.0,
        "demo" => 210.0,
        _ => return None,
    })
}

/// server.py `_PHASES`: which verbs are instrumented, and under which phase.
pub fn phase_for_verb(verb: &str) -> Option<&'static str> {
    Some(match bare(verb) {
        "launch" => "cold_start",
        "show_symbol" | "open_symbol_editor" => "show_symbol",
        "show_footprint" | "open_footprint_editor" => "show_footprint",
        "show_3d_chip" | "open_3d_viewer" => "show_3d_chip",
        "show_library" => "show_library",
        "show_schematic" | "open_schematic" => "show_schematic",
        "show_2d_board" | "open_board" => "show_2d_board",
        "show_3d_board" => "show_3d_board",
        "show_project" => "show_project",
        "install_plugin" => "install_plugin",
        "upgrade" => "upgrade",
        "export_gerber" | "export_gerbers" | "export_step" | "export_pdf" | "export_svg" | "export_part" | "make_part_project" => "export",
        "demo" => "demo",
        _ => return None,
    })
}

/// The verb name without the `kicad_` prefix (the run log and the catalog use bare names).
pub fn bare(verb: &str) -> &str {
    verb.strip_prefix("kicad_").unwrap_or(verb)
}

/// cold = nothing running yet; warm = KiCad is already up. The single most useful fact in
/// the whole block: these differ by 10-50x.
pub fn start_kind(kicad_running: bool) -> &'static str {
    if kicad_running { "warm" } else { "cold" }
}

/// Who is asking (Python `_caller()`): the forwarded thread name, else "unknown".
pub fn caller_name() -> String {
    let t = crate::ab::thread_name();
    if t.trim().is_empty() { ANONYMOUS.to_string() } else { t }
}

// ── Registry ──────────────────────────────────────────────────────────────────

#[derive(Clone, Debug)]
struct Live {
    started_at: Instant,
    start_kind: String,
    step_label: String,
    percent: Option<i64>,
    blocked_by: Option<String>,
    seq: u64,
}

struct Registry {
    /// Keyed on (caller, phase) (wiki #46).
    live: BTreeMap<(String, String), Live>,
    seq: u64,
    /// Rolling samples per "phase:kind" key, mirrored to disk on every write.
    timings: Option<BTreeMap<String, Vec<f64>>>,
    /// Test hook: where the two state files live (default `~/.adom`).
    state_dir: Option<PathBuf>,
}

static REG: Mutex<Registry> = Mutex::new(Registry { live: BTreeMap::new(), seq: 0, timings: None, state_dir: None });

fn reg() -> std::sync::MutexGuard<'static, Registry> {
    REG.lock().unwrap_or_else(|e| e.into_inner())
}

fn default_state_dir() -> PathBuf {
    let base = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from).unwrap_or_else(|| std::env::temp_dir());
    base.join(".adom")
}

/// Point the registry at another directory (tests). Drops the in-memory timings cache so
/// the next read comes from that directory.
pub fn set_state_dir(dir: Option<PathBuf>) {
    let mut r = reg();
    r.state_dir = dir;
    r.timings = None;
}

fn state_dir_of(r: &Registry) -> PathBuf {
    r.state_dir.clone().unwrap_or_else(default_state_dir)
}

fn timings_path(r: &Registry) -> PathBuf {
    state_dir_of(r).join("kicad-bridge-timings.json")
}

fn runlog_path(r: &Registry) -> PathBuf {
    state_dir_of(r).join("kicad-verb-times.jsonl")
}

fn load_timings(r: &mut Registry) {
    if r.timings.is_some() {
        return;
    }
    let mut map = BTreeMap::new();
    if let Ok(text) = std::fs::read_to_string(timings_path(r)) {
        if let Ok(Value::Object(o)) = serde_json::from_str::<Value>(&text) {
            for (k, v) in o {
                let samples: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
                map.insert(k, samples);
            }
        }
    }
    r.timings = Some(map);
}

fn save_timings(r: &Registry) {
    let Some(t) = &r.timings else { return };
    let path = timings_path(r);
    if let Some(p) = path.parent() {
        let _ = std::fs::create_dir_all(p);
    }
    let obj: Map<String, Value> = t.iter().map(|(k, v)| (k.clone(), json!(v))).collect();
    if let Ok(text) = serde_json::to_string_pretty(&Value::Object(obj)) {
        let _ = std::fs::write(path, text);
    }
}

fn key(phase: &str, start_kind: &str) -> String {
    format!("{phase}:{}", if start_kind.is_empty() { "warm" } else { start_kind })
}

fn round(v: f64, digits: i32) -> f64 {
    let f = 10f64.powi(digits);
    (v * f).round() / f
}

/// Python `_pct`: nearest-rank percentile on the sorted samples.
fn pct(samples: &[f64], q: f64) -> f64 {
    if samples.is_empty() {
        return 0.0;
    }
    let mut s = samples.to_vec();
    s.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
    let i = ((q * (s.len() as f64 - 1.0)).round() as i64).clamp(0, s.len() as i64 - 1) as usize;
    round(s[i], 2)
}

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

// ── History and estimates ─────────────────────────────────────────────────────

/// Add one real measurement. This is what makes confidence "measured".
pub fn record(phase: &str, seconds: f64, start_kind: &str) {
    if !(seconds >= 0.0) {
        return;
    }
    let mut r = reg();
    load_timings(&mut r);
    let k = key(phase, start_kind);
    let t = r.timings.as_mut().unwrap();
    let samples = t.entry(k).or_default();
    samples.push(round(seconds, 2));
    if samples.len() > KEEP {
        let n = samples.len() - KEEP;
        samples.drain(..n);
    }
    save_timings(&r);
}

fn estimate_in(r: &mut Registry, phase: &str, start_kind: &str) -> (f64, &'static str) {
    load_timings(r);
    let samples = r.timings.as_ref().unwrap().get(&key(phase, start_kind)).cloned().unwrap_or_default();
    if samples.len() >= 3 {
        return (pct(&samples, 0.5), "measured");
    }
    let default = default_seconds(phase);
    if !samples.is_empty() {
        // Blend the shipped default with what little we have measured.
        let base = default.unwrap_or(*samples.last().unwrap());
        let mean = samples.iter().sum::<f64>() / samples.len() as f64;
        return (round((mean + base) / 2.0, 2), "typical");
    }
    if let Some(d) = default {
        // A phase-SPECIFIC seeded literal is "typical" per the ladder (measured > typical
        // [1-2 samples, or a seeded literal] > unknown).
        return (d, "typical");
    }
    // No samples AND no seed: genuinely unknown. 0 so block() renders an indeterminate
    // marquee instead of borrowing the generic warm default and looking overdue at 92%.
    (0.0, "unknown")
}

/// `(estimatedSec, confidence)` for this phase ON THIS MACHINE.
pub fn estimate(phase: &str, start_kind: &str) -> (f64, &'static str) {
    let mut r = reg();
    estimate_in(&mut r, phase, start_kind)
}

/// Everything we know, for describe/status: p50 + p90 + sample counts per phase and kind.
pub fn history() -> Value {
    let mut r = reg();
    load_timings(&mut r);
    let mut out = Map::new();
    for (k, samples) in r.timings.as_ref().unwrap() {
        if samples.is_empty() {
            continue;
        }
        let (phase, kind) = k.split_once(':').unwrap_or((k.as_str(), ""));
        let entry = out.entry(phase.to_string()).or_insert_with(|| json!({}));
        entry[kind] = json!({"samples": samples.len(), "p50": pct(samples, 0.5), "p90": pct(samples, 0.9)});
    }
    Value::Object(out)
}

// ── The block ─────────────────────────────────────────────────────────────────

/// Build the progress block. `percent` may be None while genuinely unknown; the contract
/// allows it as long as stepLabel keeps moving. Adopted from fusion's timings.py: the
/// pre-overdue and overdue formulas MEET at elapsed == est0 so percent never walks
/// backwards at the crossover, and `est = elapsed * 1.25` is only ever the overdue
/// revision, never the base estimate (that alone pins a bar at 80 forever).
pub fn block(phase: &str, step_label: &str, elapsed_sec: f64, percent: Option<i64>, start_kind: &str, blocked_by: Option<&str>, done: bool) -> Value {
    let elapsed = elapsed_sec.max(0.0);
    let (est0, mut confidence) = estimate(phase, start_kind);
    let mut est = est0;
    let overdue = est0 > 0.0 && elapsed > est0;
    if overdue {
        est = round(elapsed * 1.25, 1); // revised, so eta never lies at 0
        if confidence == "measured" {
            confidence = "typical"; // past what this box ever measured
        }
        if elapsed > 2.0 * est0 {
            confidence = "unknown"; // stop claiming to know at all
        }
    }
    let mut percent = percent;
    if done {
        percent = Some(100);
        est = round(elapsed, 2);
    } else if percent.is_none() {
        percent = if est0 <= 0.0 {
            None // unknown -> indeterminate, never 0%
        } else if !overdue {
            Some((80.0 * elapsed / est0) as i64) // 0 -> 80 across the estimate
        } else {
            let over = elapsed - est0;
            Some((80.0 + 15.0 * (over / (over + est0))) as i64) // 80 -> 95, asymptotic
        };
    }
    let eta = percent.map(|_| round((est - elapsed).max(0.0), 1));
    let mut label = step_label.to_string();
    let mut out = json!({
        "phase": format!("{PREFIX}.{phase}"),
        "stepLabel": label,
        "percent": percent,
        "elapsedSec": round(elapsed, 1),
        "estimatedSec": round(est, 1),
        "etaSec": eta,
        "confidence": confidence,
        "startKind": start_kind,
    });
    if !done && est0 > 0.0 {
        out["overdue"] = json!(overdue);
        if overdue && confidence == "unknown" {
            label = format!("{step_label} (taking far longer than this machine ever has)");
            out["stepLabel"] = json!(label);
        }
    }
    if let Some(b) = blocked_by.filter(|b| !b.is_empty()) {
        out["blockedBy"] = json!(b);
        out["stepLabel"] = json!(format!("waiting for you: {b}"));
    }
    if done {
        out["measured"] = json!({"phase": format!("{PREFIX}.{phase}"), "seconds": round(elapsed, 2), "startKind": start_kind});
    }
    out
}

/// Terminal frame: carries the ACTUALS and feeds the history a new sample (only a
/// successful run is a sample; a failure's duration measures the failure, not the phase).
pub fn finish(phase: &str, elapsed_sec: f64, start_kind: &str, ok: bool, step_label: &str) -> Value {
    let elapsed = elapsed_sec.max(0.0);
    if ok {
        record(phase, elapsed, start_kind);
    }
    let mut b = block(phase, step_label, elapsed, Some(100), start_kind, None, true);
    if !ok {
        b["percent"] = Value::Null;
        b["stepLabel"] = json!(step_label);
        if let Some(o) = b.as_object_mut() {
            o.remove("measured");
        }
    }
    b
}

// ── LIVE, in-flight progress ──────────────────────────────────────────────────

/// Mark a phase as in flight, SCOPED TO THE CALLER (wiki #46).
pub fn begin_phase(phase: &str, start_kind: &str, step_label: &str, caller: &str) {
    let mut r = reg();
    r.seq += 1;
    let seq = r.seq;
    r.live.insert((caller.to_string(), phase.to_string()), Live { started_at: Instant::now(), start_kind: start_kind.to_string(), step_label: step_label.to_string(), percent: None, blocked_by: None, seq });
}

/// Advance the visible sub-step of an in-flight phase for the current caller. A handler
/// thread that carries no caller falls back to the ONLY in-flight entry for the phase,
/// and never guesses when several callers run the same phase (a wrong attribution is
/// the bug #46 is about). Percent never goes backwards.
pub fn step(phase: &str, step_label: &str, percent: Option<i64>, blocked_by: Option<&str>) {
    step_for(&caller_name(), phase, step_label, percent, blocked_by)
}

pub fn step_for(caller: &str, phase: &str, step_label: &str, percent: Option<i64>, blocked_by: Option<&str>) {
    let mut r = reg();
    let k = (caller.to_string(), phase.to_string());
    let target = if r.live.contains_key(&k) {
        Some(k)
    } else {
        let matches: Vec<(String, String)> = r.live.keys().filter(|(_, p)| p == phase).cloned().collect();
        if matches.len() == 1 { Some(matches[0].clone()) } else { None }
    };
    let Some(t) = target else { return };
    if let Some(cur) = r.live.get_mut(&t) {
        cur.step_label = step_label.to_string();
        cur.blocked_by = blocked_by.filter(|b| !b.is_empty()).map(str::to_string);
        if let Some(p) = percent {
            cur.percent = Some(p.max(cur.percent.unwrap_or(0)));
        }
    }
}

/// Retire a live frame (the caller's entry for the phase).
pub fn end_phase(phase: &str, caller: &str) {
    let mut r = reg();
    r.live.remove(&(caller.to_string(), phase.to_string()));
}

/// Every phase currently in flight, as full progress blocks, percent recomputed at READ
/// time with the step's explicit percent applied as a monotonic floor. By default a
/// caller sees ONLY ITS OWN phases (#46); `mine_only = false` is the operator view. Each
/// frame carries `caller` and `seq` regardless.
pub fn live(mine_only: bool, asker: Option<&str>) -> Vec<Value> {
    let asker = asker.map(str::to_string).unwrap_or_else(caller_name);
    let snapshot: Vec<((String, String), Live)> = reg().live.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
    let mut out = Vec::new();
    for ((who, phase), cur) in snapshot {
        if mine_only && who != asker {
            continue;
        }
        let mut b = block(&phase, &cur.step_label, cur.started_at.elapsed().as_secs_f64(), None, &cur.start_kind, cur.blocked_by.as_deref(), false);
        if let Some(floor) = cur.percent {
            b["percent"] = json!(match b["percent"].as_i64() { Some(p) => p.max(floor), None => floor });
        }
        b["seq"] = json!(cur.seq);
        b["caller"] = json!(who);
        out.push(b);
    }
    out.sort_by_key(|b| b["seq"].as_u64().unwrap_or(0));
    out
}

/// Operator view of everything in flight (`kicad_status.operations.active`).
pub fn active() -> Vec<Value> {
    live(false, None)
}

// ── Per-verb run times ────────────────────────────────────────────────────────

/// Append one verb run to the historical log AND the rolling window. Called at the
/// dispatch chokepoint for EVERY verb. Never fails loudly. `note` is the error text of a
/// failed run (#87: "did MY operation finish, and how?").
pub fn log_run(verb: &str, seconds: f64, ok: bool, note: &str, caller: Option<&str>) {
    if !(0.0..=7200.0).contains(&seconds) {
        return; // a clock jump is not a sample
    }
    let verb = bare(verb).to_string();
    record(&format!("verb:{verb}"), seconds, "warm");
    let caller = caller.map(str::to_string).unwrap_or_else(caller_name);
    let row = json!({
        "at": epoch_secs(), "verb": verb, "seconds": round(seconds, 2), "ok": ok,
        "note": note.chars().take(120).collect::<String>(),
        "caller": caller.chars().take(80).collect::<String>(),
    });
    let r = reg();
    let p = runlog_path(&r);
    if let Some(d) = p.parent() {
        let _ = std::fs::create_dir_all(d);
    }
    if let Ok(m) = std::fs::metadata(&p) {
        if m.len() > RUNLOG_MAX_BYTES {
            let _ = std::fs::rename(&p, p.with_extension("jsonl.1")); // one generation back, never unbounded
        }
    }
    if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&p) {
        let _ = writeln!(f, "{row}");
    }
}

fn read_runlog_tail(max_bytes: u64) -> Vec<Value> {
    let p = runlog_path(&reg());
    let Ok(bytes) = std::fs::read(&p) else { return Vec::new() };
    let start = bytes.len().saturating_sub(max_bytes as usize);
    let tail = String::from_utf8_lossy(&bytes[start..]);
    let mut rows = Vec::new();
    for (i, line) in tail.lines().enumerate() {
        if i == 0 && start > 0 {
            continue; // a cut line
        }
        if let Ok(v) = serde_json::from_str::<Value>(line) {
            rows.push(v);
        }
    }
    rows
}

/// The last `limit` finished runs, newest first, optionally one caller's: which verb,
/// when it finished, how long, whether it succeeded, and the error text when not.
pub fn recent(limit: usize, caller: Option<&str>) -> Vec<Value> {
    let rows = read_runlog_tail(64 * 1024);
    let mut out: Vec<Value> = rows
        .iter()
        .filter(|r| caller.map(|c| r["caller"].as_str() == Some(c)).unwrap_or(true))
        .map(|r| {
            let ok = r["ok"].as_bool().unwrap_or(false);
            json!({
                "verb": r["verb"], "finishedAt": r["at"], "seconds": r["seconds"], "ok": ok,
                "error": if ok { Value::Null } else { r.get("note").filter(|n| n.as_str().map(|s| !s.is_empty()).unwrap_or(false)).cloned().unwrap_or(Value::Null) },
                "caller": r["caller"],
            })
        })
        .collect();
    out.reverse();
    out.truncate(limit);
    out
}

/// Aggregate the whole run log: per verb count / p50 / p90 / max / last / failures,
/// sorted by p90 descending (p50 15s beside p90 400s is two code paths wearing one name).
pub fn verb_report(limit: usize) -> Value {
    let p = runlog_path(&reg());
    let Ok(text) = std::fs::read_to_string(&p) else { return json!({}) };
    let mut runs: BTreeMap<String, (Vec<f64>, u64, f64)> = BTreeMap::new();
    for line in text.lines() {
        let Ok(r) = serde_json::from_str::<Value>(line) else { continue };
        let verb = r["verb"].as_str().unwrap_or("?").to_string();
        let secs = r["seconds"].as_f64().unwrap_or(0.0);
        let e = runs.entry(verb).or_insert((Vec::new(), 0, 0.0));
        e.0.push(secs);
        if !r["ok"].as_bool().unwrap_or(false) {
            e.1 += 1;
        }
        e.2 = secs;
    }
    let mut rows: Vec<(String, Value)> = runs
        .into_iter()
        .map(|(verb, (s, fail, last))| {
            let max = s.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
            (verb, json!({"count": s.len(), "p50": pct(&s, 0.5), "p90": pct(&s, 0.9), "max": if s.is_empty() { Value::Null } else { json!(max) }, "last": last, "failures": fail}))
        })
        .collect();
    rows.sort_by(|a, b| b.1["p90"].as_f64().unwrap_or(0.0).partial_cmp(&a.1["p90"].as_f64().unwrap_or(0.0)).unwrap_or(std::cmp::Ordering::Equal));
    rows.truncate(limit);
    Value::Object(rows.into_iter().collect())
}

// ── The kicad_status `operations` block (#87) ─────────────────────────────────

pub const OPERATIONS_HINT: &str = "After a timeout: look for your verb in operations.yours.recent (ok/error, finishedAt) or operations.yours.active (still running, with stepLabel/percent). Not there yet means still running or never started: keep polling; do not resend a mutation blindly. kicad_progress is the same live view with ETAs; plugins[] below is liveness, not progress.";

/// `{active, recent, yours: {active, recent}, busy}` for `kicad_status`: every phase in
/// flight (all callers, each frame named), the last finished runs with their outcome,
/// and the asker's own slice of both.
pub fn operations(asker: Option<&str>, recent_limit: usize) -> Value {
    let me = asker.map(str::to_string).unwrap_or_else(caller_name);
    let active = live(false, Some(&me));
    let recent_all = recent(recent_limit, None);
    let mine_active: Vec<Value> = active.iter().filter(|a| a["caller"].as_str() == Some(me.as_str())).cloned().collect();
    let mine_recent: Vec<Value> = recent_all.iter().filter(|r| r["caller"].as_str() == Some(me.as_str())).cloned().collect();
    json!({
        "active": active,
        "recent": recent_all,
        "yours": {"active": mine_active, "recent": mine_recent},
        "busy": !active.is_empty(),
    })
}

// ── The dispatch handle ───────────────────────────────────────────────────────

/// One verb run as the dispatch chokepoint sees it. Created with `begin` before the verb
/// runs; `attach` (or `finish`) afterwards logs the run with its outcome, retires the
/// live frame and produces the terminal `progress` block for an instrumented verb. A
/// handle dropped without finishing (the verb panicked) logs a failed run and retires
/// the frame, so a poller never sees a bar that never completes for a call long over.
pub struct Handle {
    verb: String,
    phase: Option<String>,
    caller: String,
    start_kind: &'static str,
    t0: Instant,
    finished: bool,
}

/// Start tracking `verb` for the current caller. `kicad_running` is only consulted for an
/// instrumented verb (server.py probes the window list only when `_PHASES` has the verb),
/// so a status poll never pays for a window enumeration.
pub fn begin(verb: &str, kicad_running: impl FnOnce() -> bool) -> Handle {
    begin_as(verb, &caller_name(), kicad_running)
}

pub fn begin_as(verb: &str, caller: &str, kicad_running: impl FnOnce() -> bool) -> Handle {
    let verb = bare(verb).to_string();
    let mut phase = phase_for_verb(&verb).map(str::to_string);
    let mut kind = "warm";
    if let Some(p) = phase.as_mut() {
        kind = start_kind(kicad_running());
        if p == "cold_start" && kind == "warm" {
            *p = "warm_start".into(); // cold vs warm differ by 10-50x
        }
        begin_phase(p, kind, &format!("{verb} starting"), caller);
    }
    Handle { verb, phase, caller: caller.to_string(), start_kind: kind, t0: Instant::now(), finished: false }
}

impl Handle {
    pub fn phase(&self) -> Option<&str> {
        self.phase.as_deref()
    }
    pub fn start_kind(&self) -> &'static str {
        self.start_kind
    }
    pub fn elapsed(&self) -> Duration {
        self.t0.elapsed()
    }
    /// Advance this run's visible sub-step (no-op for an uninstrumented verb).
    pub fn step(&self, label: &str, percent: Option<i64>, blocked_by: Option<&str>) {
        if let Some(p) = &self.phase {
            step_for(&self.caller, p, label, percent, blocked_by);
        }
    }
    /// Log the run, retire the live frame, and return the terminal block (Some only for an
    /// instrumented verb). `note` is the error text of a failed run.
    pub fn finish(&mut self, ok: bool, note: &str) -> Option<Value> {
        if self.finished {
            return None;
        }
        self.finished = true;
        let secs = self.t0.elapsed().as_secs_f64();
        log_run(&self.verb, secs, ok, note, Some(&self.caller));
        let phase = self.phase.take()?;
        end_phase(&phase, &self.caller);
        let label = if ok { "done".to_string() } else { note.chars().take(80).collect() };
        Some(finish(&phase, secs, self.start_kind, ok, &label))
    }
    /// The server.py wrapper in one call: success from `out.success` (absent counts as
    /// success, like Python's `is not False`), note from `out.error` / `out.errorCode`, and
    /// `progress` inserted only when the verb did not set one itself.
    pub fn attach(&mut self, out: &mut Value) {
        let ok = out.get("success").and_then(Value::as_bool) != Some(false);
        let note = if ok { String::new() } else { out.get("error").and_then(Value::as_str).or_else(|| out.get("errorCode").and_then(Value::as_str)).unwrap_or("failed").to_string() };
        if let Some(block) = self.finish(ok, &note) {
            if let Some(o) = out.as_object_mut() {
                o.entry("progress").or_insert(block);
            }
        }
    }
}

impl Drop for Handle {
    fn drop(&mut self) {
        if !self.finished {
            let _ = self.finish(false, "handler raised");
        }
    }
}

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

    /// The registry is process-wide; tests that touch the state files take this lock and
    /// point the registry at their own directory.
    static TEST_LOCK: Mutex<()> = Mutex::new(());

    fn scratch() -> (std::sync::MutexGuard<'static, ()>, PathBuf) {
        let g = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let dir = crate::install::unique_temp_dir("kicad-progress-test-");
        std::fs::create_dir_all(&dir).unwrap();
        set_state_dir(Some(dir.clone()));
        (g, dir)
    }

    #[test]
    fn phases_and_start_kind_follow_server_py() {
        assert_eq!(phase_for_verb("kicad_show_symbol"), Some("show_symbol"));
        assert_eq!(phase_for_verb("open_board"), Some("show_2d_board"));
        assert_eq!(phase_for_verb("export_gerbers"), Some("export"));
        assert_eq!(phase_for_verb("kicad_status"), None);
        assert_eq!(start_kind(true), "warm");
        assert_eq!(start_kind(false), "cold");
        assert_eq!(bare("kicad_progress"), "progress");
        let _s = scratch();
        let mut probed = false;
        let h = begin_as("kicad_status", "t", || { probed = true; true });
        assert!(h.phase().is_none());
        drop(h);
        assert!(!probed, "an uninstrumented verb must never pay for the window probe");
        let h = begin_as("kicad_launch", "t", || true);
        assert_eq!(h.phase(), Some("warm_start"));
        assert_eq!(h.start_kind(), "warm");
        let h2 = begin_as("kicad_launch", "t2", || false);
        assert_eq!(h2.phase(), Some("cold_start"));
    }

    #[test]
    fn confidence_ladder_unknown_typical_measured() {
        let (_g, _d) = scratch();
        assert_eq!(estimate("never_heard_of", "warm"), (0.0, "unknown"));
        assert_eq!(estimate("show_symbol", "warm"), (12.0, "typical"));
        record("show_symbol", 4.0, "warm");
        let (e, c) = estimate("show_symbol", "warm");
        assert_eq!(c, "typical");
        assert_eq!(e, 8.0); // (4 + 12) / 2
        record("show_symbol", 6.0, "warm");
        record("show_symbol", 5.0, "warm");
        assert_eq!(estimate("show_symbol", "warm"), (5.0, "measured"));
        assert_eq!(estimate("show_symbol", "cold"), (12.0, "typical"), "cold and warm are separate histories");
        let h = history();
        assert_eq!(h["show_symbol"]["warm"]["samples"], json!(3));
        assert_eq!(h["show_symbol"]["warm"]["p50"], json!(5.0));
        // Rolling window keeps the last KEEP samples.
        for i in 0..20 {
            record("show_symbol", 10.0 + i as f64, "warm");
        }
        assert_eq!(history()["show_symbol"]["warm"]["samples"], json!(KEEP));
        // Persisted: a fresh cache reads the same numbers back.
        set_state_dir(Some(_d.clone()));
        assert_eq!(history()["show_symbol"]["warm"]["samples"], json!(KEEP));
        assert_eq!(pct(&[3.0, 1.0, 2.0], 0.5), 2.0);
        assert_eq!(pct(&[], 0.5), 0.0);
    }

    #[test]
    fn block_math_meets_at_the_crossover_and_never_retreats() {
        let (_g, _d) = scratch();
        // Seeded phase: show_2d_board = 15 s typical.
        let before = block("show_2d_board", "loading", 14.99, None, "warm", None, false);
        let after = block("show_2d_board", "loading", 15.01, None, "warm", None, false);
        assert_eq!(before["percent"], json!(79));
        assert_eq!(after["percent"], json!(80));
        assert_eq!(before["overdue"], json!(false));
        assert_eq!(after["overdue"], json!(true));
        assert_eq!(after["estimatedSec"], json!(18.8));
        assert_eq!(after["confidence"], json!("typical"));
        let far = block("show_2d_board", "loading", 40.0, None, "warm", None, false);
        assert_eq!(far["confidence"], json!("unknown"));
        assert!(far["stepLabel"].as_str().unwrap().contains("taking far longer"));
        assert!(far["percent"].as_i64().unwrap() > 80 && far["percent"].as_i64().unwrap() < 95);
        // Unknown phase: indeterminate, never 0%.
        let u = block("mystery", "x", 5.0, None, "warm", None, false);
        assert_eq!(u["percent"], Value::Null);
        assert_eq!(u["etaSec"], Value::Null);
        assert_eq!(u["confidence"], json!("unknown"));
        assert!(u.get("overdue").is_none());
        // blockedBy rewrites the label.
        let b = block("show_2d_board", "loading", 1.0, Some(10), "warm", Some("Save changes?"), false);
        assert_eq!(b["blockedBy"], json!("Save changes?"));
        assert_eq!(b["stepLabel"], json!("waiting for you: Save changes?"));
        assert_eq!(b["percent"], json!(10));
        assert_eq!(b["phase"], json!("kicad.show_2d_board"));
        // Terminal frames.
        let f = finish("show_2d_board", 7.3, "warm", true, "done");
        assert_eq!(f["percent"], json!(100));
        assert_eq!(f["measured"]["seconds"], json!(7.3));
        assert_eq!(f["estimatedSec"], json!(7.3));
        let f = finish("show_2d_board", 7.3, "warm", false, "editor exited");
        assert_eq!(f["percent"], Value::Null);
        assert_eq!(f["stepLabel"], json!("editor exited"));
        assert!(f.get("measured").is_none());
        assert_eq!(history()["show_2d_board"]["warm"]["samples"], json!(1), "only the successful run is a sample");
    }

    #[test]
    fn live_frames_are_caller_scoped_and_percent_is_a_floor() {
        let (_g, _d) = scratch();
        let h = begin_as("kicad_show_symbol", "alice", || true);
        let h2 = begin_as("kicad_show_symbol", "bob", || true);
        h.step("navigating to R", Some(40), None);
        h.step("still navigating", Some(20), None); // must not retreat
        // A handler thread with no caller and TWO callers on the phase: never guess.
        step_for("unknown", "show_symbol", "wrong attribution", Some(99), None);
        let mine = live(true, Some("alice"));
        assert_eq!(mine.len(), 1);
        assert_eq!(mine[0]["caller"], json!("alice"));
        assert_eq!(mine[0]["stepLabel"], json!("still navigating"));
        assert_eq!(mine[0]["percent"], json!(40));
        assert_eq!(mine[0]["startKind"], json!("warm"));
        let bobs = live(true, Some("bob"));
        assert_eq!(bobs[0]["stepLabel"], json!("show_symbol starting"));
        assert!(active().iter().filter(|f| f["phase"] == json!("kicad.show_symbol")).count() >= 2);
        drop(h2);
        // Now bob is gone: the anonymous fallback finds the single entry.
        step_for("unknown", "show_symbol", "one left", None, Some("Error"));
        let mine = live(true, Some("alice"));
        assert_eq!(mine[0]["blockedBy"], json!("Error"));
        assert_eq!(mine[0]["stepLabel"], json!("waiting for you: Error"));
        let ops = operations(Some("alice"), 10);
        assert_eq!(ops["busy"], json!(true));
        assert_eq!(ops["yours"]["active"].as_array().unwrap().len(), 1);
        drop(h);
        assert!(live(true, Some("alice")).is_empty());
    }

    #[test]
    fn handle_logs_runs_and_attaches_the_terminal_frame() {
        let (_g, _d) = scratch();
        let mut h = begin_as("kicad_show_footprint", "carol", || false);
        let mut out = json!({"success": true, "hwnd": 5});
        h.attach(&mut out);
        assert_eq!(out["progress"]["phase"], json!("kicad.show_footprint"));
        assert_eq!(out["progress"]["percent"], json!(100));
        assert_eq!(out["progress"]["startKind"], json!("cold"));
        assert!(out["progress"]["measured"].is_object());
        assert!(live(false, None).iter().all(|f| f["caller"] != json!("carol")));
        // A verb that set its own progress keeps it (setdefault).
        let mut h = begin_as("kicad_show_footprint", "carol", || true);
        let mut out = json!({"success": false, "error": "navigation failed", "progress": {"mine": true}});
        h.attach(&mut out);
        assert_eq!(out["progress"], json!({"mine": true}));
        // Uninstrumented verbs log a run but get no block.
        let mut h = begin_as("kicad_status", "carol", || true);
        let mut out = json!({"success": true});
        h.attach(&mut out);
        assert!(out.get("progress").is_none());
        // A dropped (panicked) handle logs a failure.
        drop(begin_as("kicad_show_symbol", "carol", || true));
        let rec = recent(10, Some("carol"));
        assert_eq!(rec.len(), 4);
        assert_eq!(rec[0]["verb"], json!("show_symbol"));
        assert_eq!(rec[0]["ok"], json!(false));
        assert_eq!(rec[0]["error"], json!("handler raised"));
        assert_eq!(rec[1]["verb"], json!("status"));
        assert_eq!(rec[1]["error"], Value::Null);
        assert_eq!(rec[2]["error"], json!("navigation failed"));
        assert_eq!(rec[3]["ok"], json!(true));
        assert!(recent(10, Some("nobody")).is_empty());
        let rep = verb_report(40);
        assert_eq!(rep["show_footprint"]["count"], json!(2));
        assert_eq!(rep["show_footprint"]["failures"], json!(1));
        assert!(rep["show_footprint"]["last"].is_number());
        assert_eq!(rep["status"]["failures"], json!(0));
        let ops = operations(Some("carol"), 2);
        assert_eq!(ops["recent"].as_array().unwrap().len(), 2);
        assert_eq!(ops["yours"]["recent"].as_array().unwrap().len(), 2);
        assert_eq!(ops["busy"], json!(false));
        // Out-of-range durations are not samples.
        log_run("x", -1.0, true, "", Some("carol"));
        log_run("x", 9000.0, true, "", Some("carol"));
        assert!(verb_report(40).get("x").is_none());
    }
}