app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
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(®());
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(®());
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, ¬e) {
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());
}
}