← Commit history
rust/crates/kicad-bridge/src/groups.rs+1
@@ -24,6 +24,7 @@ pub static GROUPS: &[Group] = &[     Group { name: "install", verbs: || crate::verbs_install::VERBS, dispatch: crate::verbs_install::dispatch },     Group { name: "routing", verbs: || crate::verbs_routing::VERBS, dispatch: crate::verbs_routing::dispatch },     Group { name: "windows", verbs: || crate::verbs_windows::VERBS, dispatch: crate::verbs_windows::dispatch },+    Group { name: "show", verbs: || crate::verbs_show::VERBS, dispatch: crate::verbs_show::dispatch }, ];  pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {
rust/crates/kicad-bridge/src/main.rs+90−61
@@ -19,8 +19,11 @@ mod verbs_misc; mod verbs_netlist; mod verbs_routing; mod verbs_schematic;+mod verbs_show; mod verbs_windows; +use std::sync::atomic::{AtomicU64, Ordering};+use std::sync::Arc; use std::time::Instant;  use serde::Deserialize;@@ -82,81 +85,107 @@ fn json_response(body: Value, code: u16) -> Response<std::io::Cursor<Vec<u8>>> {         .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap()) } +/// Requests are served by a small pool: ab polls /status every 15 s and the AI polls+/// kicad_progress while a long verb runs, and neither may wait behind it (the Python bridge+/// was a ThreadingHTTPServer for the same reason). Serialisation of the verbs that need it+/// happens on util::GUI_LOCK inside dispatch.+const WORKERS: usize = 6;+ fn main() {     let port = parse_port_arg().unwrap_or(0);     let host = std::env::var("ADOM_BIND_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());     let started = Instant::now();-    let mut request_count: u64 = 0;-    let mut verb_count: u64 = 0;+    let request_count = Arc::new(AtomicU64::new(0));+    let verb_count = Arc::new(AtomicU64::new(0));      let server = Server::http((host.as_str(), port))         .unwrap_or_else(|e| panic!("[kicad-bridge] bind {host}:{port} failed: {e}"));     let actual_port = server.server_addr().to_ip().map(|a| a.port()).unwrap_or(port);-    eprintln!("[kicad-bridge] v{VERSION} listening on {host}:{actual_port}");+    eprintln!("[kicad-bridge] v{VERSION} listening on {host}:{actual_port} ({WORKERS} workers)");      kicad_platform::native().init_process();-    let mut state = util::State::new();+    let server = Arc::new(server);+    let mut handles = Vec::new();+    for _ in 0..WORKERS {+        let server = Arc::clone(&server);+        let request_count = Arc::clone(&request_count);+        let verb_count = Arc::clone(&verb_count);+        handles.push(std::thread::spawn(move || loop {+            let req = match server.recv() {+                Ok(r) => r,+                Err(e) => {+                    eprintln!("[kicad-bridge] recv failed: {e}");+                    break;+                }+            };+            let n = request_count.fetch_add(1, Ordering::Relaxed) + 1;+            handle(req, started, n, &verb_count);+        }));+    }+    for h in handles {+        let _ = h.join();+    }+} -    for mut req in server.incoming_requests() {-        request_count += 1;-        let uptime = started.elapsed().as_secs();-        let (body, code) = match (req.method(), req.url()) {-            (Method::Get, "/status") | (Method::Get, "/health") => {-                let info = state.kicad_info();-                let led = if info.installed { "green" } else { "yellow" };-                (-                    json!({-                        "ok": true,-                        "bridge": "kicad",-                        "version": VERSION,-                        "runtime": "native",-                        "uptimeSec": uptime,-                        "requestCount": request_count,-                        "led": led,-                        "summary": if info.installed { format!("KiCad {}", info.version) } else { "KiCad not found".into() },-                        "tooltip": format!("kicad-bridge v{VERSION} (native)\nup {uptime}s, {request_count} requests\n{}", info.summary()),-                    }),-                    200,-                )+fn handle(mut req: tiny_http::Request, started: Instant, request_count: u64, verb_count: &AtomicU64) {+    let uptime = started.elapsed().as_secs();+    let mut state = util::State::new();+    let (body, code) = match (req.method(), req.url()) {+        (Method::Get, "/status") | (Method::Get, "/health") => {+            let info = state.kicad_info();+            let led = if info.installed { "green" } else { "yellow" };+            (+                json!({+                    "ok": true,+                    "bridge": "kicad",+                    "version": VERSION,+                    "runtime": "native",+                    "uptimeSec": uptime,+                    "requestCount": request_count,+                    "led": led,+                    "summary": if info.installed { format!("KiCad {}", info.version) } else { "KiCad not found".into() },+                    "tooltip": format!("kicad-bridge v{VERSION} (native)\nup {uptime}s, {request_count} requests\n{}", info.summary()),+                }),+                200,+            )+        }+        (Method::Post, "/command") => {+            let caller = Caller {+                thread: header_value(&req, "X-Adom-Caller-Thread").unwrap_or_else(|| "?".into()),+                container: header_value(&req, "X-Adom-Caller-Container").unwrap_or_else(|| "?".into()),+                reason: header_value(&req, "X-Adom-Caller-Reason").unwrap_or_default(),+            };+            let mut raw = String::new();+            let _ = req.as_reader().read_to_string(&mut raw);+            if raw.trim().is_empty() {+                raw = "{}".into();             }-            (Method::Post, "/command") => {-                let caller = Caller {-                    thread: header_value(&req, "X-Adom-Caller-Thread").unwrap_or_else(|| "?".into()),-                    container: header_value(&req, "X-Adom-Caller-Container").unwrap_or_else(|| "?".into()),-                    reason: header_value(&req, "X-Adom-Caller-Reason").unwrap_or_default(),-                };-                let mut raw = String::new();-                let _ = req.as_reader().read_to_string(&mut raw);-                if raw.trim().is_empty() {-                    raw = "{}".into();-                }-                match serde_json::from_str::<Command>(&raw) {-                    Err(e) => (-                        json!({"success": false, "error": format!("bad JSON: {e}"), "_reportIssues": report_issues_line()}),-                        400,-                    ),-                    Ok(cmd) => {-                        verb_count += 1;-                        eprintln!(-                            "[kicad-bridge] {} <- thread={} container={} reason={}",-                            cmd.command, caller.thread, caller.container, caller.reason-                        );-                        let t0 = Instant::now();-                        let mut out = verbs::dispatch(&mut state, &cmd.command, &cmd.args, &caller);-                        if let Some(o) = out.as_object_mut() {-                            o.insert("elapsedMs".into(), json!(t0.elapsed().as_millis() as u64));-                            o.entry("bridgeVersion").or_insert(json!(VERSION));-                            let failed = !o.get("success").and_then(Value::as_bool).unwrap_or(false);-                            if failed || verb_count == 1 {-                                o.insert("_reportIssues".into(), json!(report_issues_line()));-                            }+            match serde_json::from_str::<Command>(&raw) {+                Err(e) => (+                    json!({"success": false, "error": format!("bad JSON: {e}"), "_reportIssues": report_issues_line()}),+                    400,+                ),+                Ok(cmd) => {+                    let nth = verb_count.fetch_add(1, Ordering::Relaxed) + 1;+                    eprintln!(+                        "[kicad-bridge] {} <- thread={} container={} reason={}",+                        cmd.command, caller.thread, caller.container, caller.reason+                    );+                    let t0 = Instant::now();+                    let mut out = verbs::dispatch(&mut state, &cmd.command, &cmd.args, &caller);+                    if let Some(o) = out.as_object_mut() {+                        o.insert("elapsedMs".into(), json!(t0.elapsed().as_millis() as u64));+                        o.entry("bridgeVersion").or_insert(json!(VERSION));+                        let failed = !o.get("success").and_then(Value::as_bool).unwrap_or(false);+                        if failed || nth == 1 {+                            o.insert("_reportIssues".into(), json!(report_issues_line()));                         }-                        (out, 200)                     }+                    (out, 200)                 }             }-            _ => (json!({"success": false, "error": "not found"}), 404),-        };-        let _ = req.respond(json_response(body, code));-    }+        }+        _ => (json!({"success": false, "error": "not found"}), 404),+    };+    let _ = req.respond(json_response(body, code)); }
rust/crates/kicad-bridge/src/util.rs+20−8
@@ -7,30 +7,42 @@ use serde_json::{json, Value};  use kicad_core::detect::{self, KicadInfo}; -pub struct State {-    info: Option<(std::time::Instant, KicadInfo)>,-}+/// Per-request handle. The detection cache behind it is process-wide (one probe per+/// 60 s no matter how many worker threads serve requests), so a fresh State per request+/// costs nothing and every verb group keeps its `&mut State` signature.+pub struct State;++static DETECT_CACHE: std::sync::Mutex<Option<(std::time::Instant, KicadInfo)>> = std::sync::Mutex::new(None);  impl State {     pub fn new() -> Self {-        State { info: None }+        State     }     /// Detection is cached for 60 s; a verb that needs fresh truth calls refresh().     pub fn kicad_info(&mut self) -> KicadInfo {-        if let Some((t, ref i)) = self.info {-            if t.elapsed() < Duration::from_secs(60) {-                return i.clone();+        if let Ok(g) = DETECT_CACHE.lock() {+            if let Some((t, ref i)) = *g {+                if t.elapsed() < Duration::from_secs(60) {+                    return i.clone();+                }             }         }         self.refresh()     }     pub fn refresh(&mut self) -> KicadInfo {         let i = detect::detect();-        self.info = Some((std::time::Instant::now(), i.clone()));+        if let Ok(mut g) = DETECT_CACHE.lock() {+            *g = Some((std::time::Instant::now(), i.clone()));+        }         i     } } +/// Window, IPC and show verbs serialise on this lock (they share KiCad's UI thread and the+/// etiquette ledger); everything else runs in parallel so status and progress polls never+/// wait behind a long open or a routing commit.+pub static GUI_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());+ pub fn fail(code: &str, msg: impl Into<String>, hint: impl Into<String>) -> Value {     json!({"success": false, "errorCode": code, "error": msg.into(), "_hint": hint.into()}) }
rust/crates/kicad-bridge/src/verbs.rs+11−1
@@ -15,12 +15,22 @@ use crate::Caller; pub fn dispatch(state: &mut State, command_in: &str, args: &Value, _caller: &Caller) -> Value {     // ab forwards the bare name (it strips the `kicad_` prefix it routed on); the CLI and     // hand callers send the full one. Accept both, like the Python bridge.-    let full: String = if command_in.starts_with("kicad_") { command_in.to_string() } else { format!("kicad_{command_in}") };+    let mut full: String = if command_in.starts_with("kicad_") { command_in.to_string() } else { format!("kicad_{command_in}") };+    if full == "kicad_export_gerbers" {+        full = "kicad_export_gerber".into(); // alias kept from the Python bridge+    }     let command = full.as_str();     let Some(verb) = catalog::find(command) else {         let names: Vec<&str> = catalog::all().iter().map(|v| v.name).collect();         return fail("unknown_verb", format!("Unknown command: {command}"), format!("Valid commands: {}", names.join(", ")));     };+    // One KiCad UI thread, one etiquette ledger: window, IPC and show verbs run one at a+    // time. Read-only and file verbs do not take the lock (a poisoned lock is still usable:+    // a verb that panicked mid-way must not wedge the bridge).+    let _serial = match verb.mechanism {+        catalog::Mechanism::Window | catalog::Mechanism::Ipc => Some(GUI_LOCK.lock().unwrap_or_else(|e| e.into_inner())),+        _ => None,+    };     // The ab callback client forwards this request's caller identity (headers first, args.caller as fallback).     kicad_core::ab::set_caller(kicad_core::ab::caller_from(args, &_caller.thread, &_caller.container, &_caller.reason));     // Verb groups ported later live in their own files; each returns Some when it owns the verb.
rust/crates/kicad-bridge/src/verbs_misc.rs+79
@@ -64,10 +64,46 @@ pub static VERBS: &[Verb] = &[             "the native build has no launch verb yet, so it only reads logs the Python bridge's kicad_launch wrote to <LOCALAPPDATA>/Adom Bridge/kicad-logs; available:false is the honest answer on a box that never launched through Python",         ],     },+    Verb {+        name: "kicad_bridge_status",+        summary: "Retired: the in-KiCad Python plugin (reverse bridge) is not part of the native bridge. Reports that, plus what replaced each of its jobs.",+        mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+        input: "{}", example: "kicad_bridge_status {}",+        hint: "Nothing to deploy or probe: editor opening uses the native menu walk and UI Automation, board state uses the IPC API, library rescans reload the tree in place.",+        related: &["kicad_open_editors", "kicad_routing_state", "kicad_rescan_libraries"], pitfalls: &["KiCad 11 removes the SWIG scripting the old plugin ran on; nothing in this bridge depends on it."],+    },+    Verb {+        name: "kicad_bridge_call",+        summary: "Retired: there is no in-KiCad plugin to call. Use the verb that does the job directly.",+        mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+        input: "{\"method\": \"...\"}", example: "kicad_bridge_call {\"method\":\"ping\"}",+        hint: "Returns errorCode plugin_retired with the replacement verb for the method you asked for.",+        related: &["kicad_routing_state", "kicad_open_footprint_editor", "kicad_open_3d_viewer"], pitfalls: &[],+    },+    Verb {+        name: "kicad_install_plugin",+        summary: "Retired: the native bridge installs no Python plugin into KiCad. Returns success with nothing to do so old scripts keep working.",+        mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+        input: "{}", example: "kicad_install_plugin {}",+        hint: "kicad_uninstall removes a plugin left behind by the Python bridge.",+        related: &["kicad_uninstall"], pitfalls: &[],+    },+    Verb {+        name: "kicad_plugin_diagnose",+        summary: "Retired: reports that no plugin is expected and whether a leftover Python plugin payload is still on disk.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 10,+        input: "{}", example: "kicad_plugin_diagnose {}",+        hint: "A leftover payload is harmless; kicad_uninstall removes it.",+        related: &["kicad_uninstall"], pitfalls: &[],+    }, ];  pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {     Some(match command {+        "kicad_bridge_status" => retired("bridge_status", None),+        "kicad_bridge_call" => retired("bridge_call", args.get("method").and_then(Value::as_str)),+        "kicad_install_plugin" => json!({"success": true, "installed": false, "retired": true, "output": "nothing to install: the native bridge has no in-KiCad plugin", "_hint": "Editor opening, board state and rescans no longer need a plugin. kicad_uninstall removes any payload the Python bridge left behind."}),+        "kicad_plugin_diagnose" => plugin_diagnose(),         "kicad_ipc_api" => ipc_api(args),         "kicad_export_molecule" => export_molecule(state, args),         "kicad_errors" => errors(args),@@ -129,3 +165,46 @@ fn log_tail(args: &Value) -> Value {     }     v }++fn retired(what: &str, method: Option<&str>) -> Value {+    let replacement = match method.unwrap_or("") {+        m if m.contains("menu") || m.contains("wm_command") => "kicad_open_footprint_editor, kicad_open_symbol_editor or kicad_open_3d_viewer (native menu walk)",+        m if m.contains("symbol") => "kicad_open_symbol_editor",+        m if m.contains("footprint") || m.contains("tree") || m.contains("refresh") => "kicad_open_footprint_editor or kicad_rescan_libraries",+        m if m.contains("board") || m.contains("net") || m.contains("track") => "kicad_routing_state and the routing verbs (IPC API)",+        _ => "the verb that does the job directly (see kicad_describe)",+    };+    json!({+        "success": false,+        "errorCode": "plugin_retired",+        "retired": true,+        "plugin": {"deployed": false, "expected": false, "alive": 0, "plugins": []},+        "error": format!("kicad_{what}: the in-KiCad Python plugin is retired in the native bridge"),+        "replacement": replacement,+        "_hint": format!("Use {replacement}. KiCad 11 removes the SWIG scripting the plugin ran on; nothing here depends on it."),+    })+}++fn plugin_diagnose() -> Value {+    let plat = kicad_platform::native();+    let mut leftovers: Vec<String> = Vec::new();+    if let Some(root) = plat.user_root() {+        if let Ok(rd) = std::fs::read_dir(&root) {+            for e in rd.flatten() {+                let site = e.path().join("3rdparty").join("Python311").join("site-packages");+                for f in ["adom_bridge.py", "usercustomize.py"] {+                    let p = site.join(f);+                    if p.is_file() { leftovers.push(p.to_string_lossy().replace('\\', "/")); }+                }+            }+        }+    }+    json!({+        "success": true,+        "retired": true,+        "pluginExpected": false,+        "leftoverPayload": leftovers,+        "output": if leftovers.is_empty() { "no plugin expected, none on disk".to_string() } else { format!("{} leftover payload file(s) from the Python bridge; harmless, kicad_uninstall removes them", leftovers.len()) },+        "_hint": "The native bridge drives KiCad through the IPC API, the native menu bar and UI Automation; the SWIG plugin is not used.",+    })+}
rust/crates/kicad-bridge/src/verbs_show.rsadded+11
@@ -0,0 +1,11 @@+//! Verb group "show". Placeholder until phase 3b lands.+use serde_json::Value;++use crate::catalog::Verb;+use crate::util::State;++pub static VERBS: &[Verb] = &[];++pub fn dispatch(_state: &mut State, _command: &str, _args: &Value) -> Option<Value> {+    None+}
rust/crates/kicad-core/src/install_kicad.rsadded+1
@@ -0,0 +1 @@+//! Placeholder: KiCad silent install and upgrade, filled in by phase 3b.
rust/crates/kicad-core/src/lib.rs+2
@@ -20,3 +20,5 @@ pub mod ipc; pub mod uninstall; pub mod ab; pub mod windows_model;+pub mod progress;+pub mod install_kicad;
rust/crates/kicad-core/src/progress.rsadded+1
@@ -0,0 +1 @@+//! Placeholder: operation progress registry, filled in by phase 3b.
skills/kicad-bridge-test/run_verb_tests.py+5−5
@@ -135,11 +135,11 @@ def matrix(w: dict) -> dict:         "sch_place_symbol":  dict(phase=2, args={"filePath": S, "libId": "Device:R", "reference": "R_VERBTEST",                                                  "at": [200, 150], "value": "1k"}, timeout=150,                                   note="fixture copy only; the .bak is removed with the fixture dir"),-        "sch_wire":          dict(phase=2, args={"filePath": S, "points": [[200, 146.19], [200, 140]]}, timeout=150),-        "sch_label":         dict(phase=2, args={"filePath": S, "text": "VERBTEST_NET", "at": [200, 140], "kind": "local"}, timeout=150),-        "sch_set_property":  dict(phase=2, args={"filePath": S, "reference": "R_VERBTEST", "name": "Value", "value": "2k2"}, timeout=150),-        "sch_move":          dict(phase=2, args={"filePath": S, "reference": "R_VERBTEST", "to": [210, 150]}, timeout=150),-        "sch_delete":        dict(phase=2, args={"__special": "sch_delete"}, timeout=150,+        "sch_wire":          dict(phase=2.2, args={"filePath": S, "points": [[200, 146.19], [200, 140]]}, timeout=150),+        "sch_label":         dict(phase=2.2, args={"filePath": S, "text": "VERBTEST_NET", "at": [200, 140], "kind": "local"}, timeout=150),+        "sch_set_property":  dict(phase=2.3, args={"filePath": S, "reference": "R_VERBTEST", "name": "Value", "value": "2k2"}, timeout=150),+        "sch_move":          dict(phase=2.4, args={"filePath": S, "reference": "R_VERBTEST", "to": [210, 150]}, timeout=150),+        "sch_delete":        dict(phase=2.5, args={"__special": "sch_delete"}, timeout=150,                                   note="deletes the R_VERBTEST symbol placed above, by the uuid sch_place_symbol returned"),         "lint_library":      dict(phase=2, args={"filePath": w["symlib"]}, timeout=120,                                   note="Adom.kicad_sym installed in phase 4a; runs after it"),