← Commit history

runner: cover autoroute, model_check and the six sch_* verbs; ipc: no_pcb_frame for the one-API-server-per-machine case

John Lauer ·2a8b8d75d9 ·28d ago ·parent 17598e0
2 files changed +33
rust/crates/kicad-core/src/ipc.rs+11
@@ -83,6 +83,16 @@ fn map_err(e: KiCadError) -> RoutingError {         KiCadError::TransportSend { .. } | KiCadError::TransportReceive { .. } | KiCadError::TransportClosed | KiCadError::BlockingRuntimeClosed | KiCadError::RuntimeJoin(_) => RoutingError::new("ipc_unavailable", e.to_string()),         KiCadError::BoardNotOpen => RoutingError::new("no_board_open", "No board is open in the PCB editor").with("openBoards", json!([])),         KiCadError::AmbiguousBoardSelection { boards } => RoutingError::new("board_mismatch", "This IPC endpoint exposes more than one board").with("openBoards", json!(boards)),+        // KiCad runs ONE API server per machine (the first KiCad process takes api.sock; a+        // later standalone pcbnew.exe gets none, measured on arav-rog 2026-09-11). When that+        // first process is the project manager with no PCB frame, board commands come back+        // AS_UNHANDLED. Name the situation instead of a bare status code.+        KiCadError::ApiStatus { code, message } if code == "AS_UNHANDLED" => RoutingError::new(+            "no_pcb_frame",+            "The KiCad process that owns the API socket has no PCB editor frame (a project manager alone, or the board is open in a second, standalone pcbnew.exe that got no API server)",+        )+        .with("apiStatus", json!({"code": code, "message": message}))+        .with("_hint", json!("KiCad serves one API socket per machine and the first KiCad process wins. Either close the project manager and open the board with kicad_open_board (standalone pcbnew then owns the socket), or open the board from within the project manager (kicad_launch with the project, then the PCB Editor button) so the frame lives in the process that has the socket. Then read kicad_routing_state again.")),         other => RoutingError::new("ipc_error", other.to_string()),     } }@@ -930,6 +940,7 @@ mod tests {         assert_eq!(map_err(KiCadError::BoardNotOpen).code, "no_board_open");         assert_eq!(map_err(KiCadError::SocketUnavailable { socket_uri: "ipc:///x".into() }).code, "ipc_unavailable");         assert_eq!(map_err(KiCadError::ApiStatus { code: "AS_BAD_REQUEST".into(), message: "nope".into() }).code, "ipc_error");+        assert_eq!(map_err(KiCadError::ApiStatus { code: "AS_UNHANDLED".into(), message: "no handler".into() }).code, "no_pcb_frame");         assert_eq!(timeout_of(&json!({})), Duration::from_millis(5000));         assert_eq!(timeout_of(&json!({"ipcTimeoutMs": 10})), Duration::from_millis(1000));         assert_eq!(timeout_of(&json!({"ipcTimeoutMs": 999999})), Duration::from_millis(60000));
skills/kicad-bridge-test/run_verb_tests.py+22
@@ -126,6 +126,21 @@ def matrix(w: dict) -> dict:         "run_erc":           dict(phase=2, args={"filePath": S}, timeout=200),         "lint_board":        dict(phase=2, args={"filePath": B}, timeout=200),         "lint_schematic":    dict(phase=2, args={"filePath": S}, timeout=200),+        # ---- phase 2b: native-build verbs (Rust port). All read-only or on the fixture copy.+        "model_check":       dict(phase=2, args={"boardPath": B}, timeout=120,+                                  note="file diagnostics only; renderVerified is always false here"),+        "autoroute":         dict(phase=2, args={"filePath": B, "engine": "ai", "dryRun": True}, timeout=120,+                                  note="ai engine returns a plan request and mutates nothing"),+        # schematic edit in place, on the fixture copy: each write keeps a .bak beside the file+        "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,+                                  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"),         "export_gerber":     dict(phase=2, args={"filePath": B, "outputDir": OUT + "/gerbers"}, timeout=220),@@ -463,6 +478,13 @@ def main():                 results[verb] = {"result": "FAIL", "reason": "extract_netlist reported no nets to trace"}                 return             args = {"filePath": w["sch"], "netName": real}+        elif special == "sch_delete":+            placed = (results.get("sch_place_symbol") or {}).get("response") or {}+            uid = placed.get("uuid")+            if not uid:+                results[verb] = {"result": "SKIP", "reason": "sch_place_symbol returned no uuid to delete"}+                return+            args = {"filePath": w["sch"], "uuid": uid}         elif special == "install_symbol":             import base64 as _b64             args = {"fileName": "ADOM_VERBTEST.kicad_sym",