app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
John Lauer
Publish 0.2.58
e8180ab
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249
//! The live half: everything that touches the desktop goes through the Adom Bridge CLI
//! (`adom-bridge --ai-thread ... --target ... <verb> <json>`), so the same etiquette, approval
//! gates and activity log apply as for any AI thread. Capture recipe, landing, measurement,
//! validation.
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::{json, Value};
pub mod altium;
pub mod eda;
pub mod fusion;
pub mod landing;
pub mod recorder;
pub use landing::Landing;
/// Pulls of one clip before the stop gives up and flags it `pullFailed`: the first and two retries.
pub const PULL_ATTEMPTS: usize = 3;
// Waiting cannot repair an explicit identity/configuration refusal. In particular,
// never conceal the requested/open board identities behind a generic no_revision.
fn retryable_read(reply: &Value) -> bool {
!matches!(reply["errorCode"].as_str(), Some("board_mismatch" | "invalid_socket" | "invalid_path" | "api_server_off" | "unsupported_kicad" | "no_pcb_frame" | "permission_denied"))
}
/// Only IDs returned by successful flow operations establish ownership. Names,
/// area and absence from the original board do not establish ownership.
fn owned_zones_to_replace(live: &Value, planned: &[Value], owned: &[String]) -> Result<Vec<String>, String> {
let rows=live["zones"].as_array().ok_or("zone readback has no zones array")?;
let mut replace=Vec::new();
for row in rows {
let id=row["id"].as_str().ok_or("zone readback has no item ID")?;
if row["type"] == "teardrop" { continue; }
if owned.iter().any(|x|x==id) { replace.push(id.to_string()); continue; }
let name=row["name"].as_str().unwrap_or("");
if !name.is_empty() && planned.iter().any(|p|p["name"].as_str()==Some(name)) {
return Err(format!("planned zone {name:?} conflicts with unowned live ID {id}; preserve it and choose a distinct flow zone name, or reconcile it explicitly through the native bridge"));
}
}
Ok(replace)
}
fn read_zone_state_with(
refill: bool,
mut call: impl FnMut(bool) -> Value,
mut wait: impl FnMut(),
) -> Value {
let mut reply = call(refill);
if !retryable_read(&reply) { return reply; }
if !refill && Bridge::ok(&reply) && reply["filledAreaByLayerMm2"].is_object() && reply["revision"].is_string() && reply["revisionUsable"] != false { return reply; }
// Discard even old bridges' synchronous-looking refill reply. Mutation is
// requested only once. Repeated delayed snapshots are observed stability,
// not a native completion event (KiCad exposes none in this API).
let mut previous: Option<Value> = None;
for _ in 0..15 {
wait();
reply = call(false);
if !retryable_read(&reply) { return reply; }
if Bridge::ok(&reply) && reply["filledAreaByLayerMm2"].is_object() && reply["revision"].is_string() && reply["revisionUsable"] != false {
if !refill || previous.as_ref().map(|p| p["revision"] == reply["revision"] && p["filledAreaByLayerMm2"] == reply["filledAreaByLayerMm2"]).unwrap_or(false) {
reply["readbackEvidence"] = json!(if refill { "two delayed matching native snapshots after refill request" } else { "native snapshot" });
return reply;
}
previous = Some(reply.clone());
} else { previous = None; }
}
json!({"success":false,"errorCode":"zone_readback_unsettled","lastReply":reply,"message":"No usable stable zone snapshot; inspect native state. Refill was not replayed."})
}
/// A reply's fields, also when ab wraps them as a JSON string in `output` or under `data`.
fn reply_fields(reply: &Value) -> Value {
if let Some(out) = reply.get("output").and_then(|o| o.as_str()) {
if let Ok(v) = serde_json::from_str::<Value>(out) { if v.is_object() { return v; } }
}
reply.get("data").filter(|d| d.is_object()).cloned().unwrap_or_else(|| reply.clone())
}
/// Megabytes the caller can still write, from a `desktop_disk_free` reply (availableBytes is
/// quota-aware; freeBytes is the volume's raw free space and is used only when it is absent).
pub fn disk_free_mb(reply: &Value) -> Option<u64> {
let read = |v: &Value| v.get("availableBytes").or_else(|| v.get("freeBytes")).and_then(|b| b.as_u64());
read(reply).or_else(|| read(&reply_fields(reply))).map(|b| b / 1_048_576)
}
/// The recording drive's free space through the native verb. LEGACY FALLBACK: only when the box's
/// Adom Bridge is older than 2.1.127 and answers `unknown_verb` does this ask the drive through a
/// human-gated `shell_execute` PowerShell query (a refusal or no answer gives None, and the disk
/// check falls back to the run drive). Remove the fallback once no supported box runs ab < 2.1.127.
fn remote_free_mb_with(mut call: impl FnMut(&str, &Value) -> Value) -> Option<u64> {
let r = call("desktop_disk_free", &json!({}));
if let Some(mb) = disk_free_mb(&r) { return Some(mb); }
if r["errorCode"].as_str() != Some("unknown_verb") { return None; }
let r = call("shell_execute", &json!({"command": "powershell -NoProfile -Command \"(Get-PSDrive -Name ($env:TEMP.Substring(0,1))).Free\"", "timeoutSeconds": 20, "reason": "aiflow checks the free space on the recording drive before it starts a step's clip (legacy Adom Bridge without desktop_disk_free)"}));
let out = r.get("stdout").or_else(|| r.get("output")).and_then(|v| v.as_str()).unwrap_or("");
out.trim().lines().last().and_then(|l| l.trim().parse::<u64>().ok()).map(|b| b / 1_048_576)
}
/// Whether a box's Adom Bridge predates pull_file's configurable whole-pull budget (2.1.175), the
/// only case where the PowerShell part splitter is still used for a clip pull_file could not land.
/// Since then pull_file streams 1 MiB binary frames, verifies sha256 and takes timeoutSeconds, so
/// splitting duplicates it. An unknown version is not treated as legacy.
pub fn legacy_pull_needs_split(ab_version: Option<&str>) -> bool {
let Some(v) = ab_version else { return false };
let parts: Vec<u64> = v.trim().trim_start_matches('v').split(['.', '-', '+']).take(3).map_while(|p| p.parse().ok()).collect();
if parts.len() < 3 { return false; }
(parts[0], parts[1], parts[2]) < (2, 1, 175)
}
/// What `kicad_model_check` says beyond resolution counts. `missing` lists model references whose
/// file does not exist, read from the complete `modelBindings` (KiCad Bridge 1.0.22+), else from the
/// capped `footprintsWithWarnings` of older bridges. `hidden` names the 3D viewer classes that hide
/// bodies on this board (viewer flag false with footprints of that class carrying a model) and every
/// model the bridge marks hidden. File resolution is not render proof; `evidence` says what was read.
#[derive(Debug, Default, PartialEq)]
pub struct ModelCheck {
pub missing: Vec<String>,
pub hidden: Vec<String>,
pub bindings_complete: bool,
pub evidence: String,
}
pub fn model_check_summary(d: &Value) -> ModelCheck {
let d = if d.get("counts").is_some() || d.get("modelBindings").is_some() { d.clone() } else { reply_fields(d) };
let mut out = ModelCheck { bindings_complete: d["bindingsComplete"].as_bool() == Some(true), evidence: d["evidenceSource"].as_str().unwrap_or("unknown").to_string(), ..Default::default() };
if let Some(bindings) = d["modelBindings"].as_array() {
for b in bindings {
let r = b["reference"].as_str().unwrap_or("");
let fp = b["footprint"].as_str().unwrap_or("");
let models = b["models"].as_array().cloned().unwrap_or_default();
let gone: Vec<String> = models.iter().filter(|m| m["exists"].as_bool() != Some(true)).filter_map(|m| m["reference"].as_str().map(str::to_string)).collect();
if !gone.is_empty() { out.missing.push(format!("{fp} ({r}): {}", gone.join(", "))); }
if models.iter().any(|m| m["hidden"].as_bool() == Some(true)) { out.hidden.push(format!("{r}: model marked hidden in the footprint")); }
}
} else {
for f in d["footprintsWithWarnings"].as_array().cloned().unwrap_or_default() {
let fp = f["footprint"].as_str().unwrap_or("");
let refs = f["references"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default();
let gone: Vec<String> = f["models"].as_array().cloned().unwrap_or_default().iter().filter(|m| m["exists"].as_bool() != Some(true)).filter_map(|m| m["reference"].as_str().map(str::to_string)).collect();
if !gone.is_empty() { out.missing.push(format!("{fp}{}: {}", if refs.is_empty() { String::new() } else { format!(" ({refs})") }, gone.join(", "))); }
}
}
if let (Some(flags), Some(by_class)) = (d["viewer"]["flags"].as_object(), d["footprintsWithModelByViewerClass"].as_object()) {
for (class, n) in by_class {
let n = n.as_u64().unwrap_or(0);
if n > 0 && flags.get(class).and_then(|f| f.as_bool()) == Some(false) {
out.hidden.push(format!("3D viewer hides class {class} ({n} footprint(s) with a model)"));
}
}
}
out
}
/// What a clip stop brought back: the desktop path, where the pull puts it, and whether it landed.
#[derive(Clone, Debug)]
pub struct ClipStop {
pub remote: Option<String>,
pub local: Option<PathBuf>,
pub pulled: bool,
pub attempts: usize,
pub reply: Value,
}
impl ClipStop {
/// Why the stop brought no file back (issue #98): the recorder no longer knows the take, it
/// failed to finalise, or it was still finalising when the bounded wait ran out.
pub fn failure(&self) -> Option<String> { self.reply["aiflowStopError"].as_str().map(str::to_string) }
/// True when a later stop may still collect the take (it was finalising, or the call timed out).
pub fn pending(&self) -> bool { self.reply["aiflowStopPending"] == true }
}
/// Peak bit rate of every window recording aiflow starts (issue #98): a 4K window at the recorder's
/// default VBR made 55 Mbit/s and 4.29 GB in ten minutes. The peak bounds the size (12 Mbit/s is at
/// most 90 MB a minute); a still window still costs almost nothing. Override with
/// AIFLOW_CLIP_MAX_BITRATE (bits per second).
pub const WINDOW_MAX_BITRATE: u64 = 12_000_000;
/// The hard length of a window recording (`maxDurationMs`): the recorder stops the take itself and
/// keeps it registered for the stop to collect.
pub const WINDOW_MAX_MS: u64 = 3_600_000;
/// Seconds between `desktop_record_status` polls while a stopped take finalises.
pub const FINALISE_POLL_SECS: u64 = 10;
/// Issue #135: did a KiCad window (the editor, its project manager or splash) take the foreground
/// from the human? Not when KiCad was already in front before the launch: then the human chose it.
pub fn took_foreground(editor: i64, before: Option<&Value>, fg: &Value) -> bool {
let kicad = |w: &Value| w["hwnd"].as_i64() == Some(editor) || w["title"].as_str().is_some_and(|t| t.contains("PCB Editor") || t.starts_with("KiCad") || t.to_ascii_lowercase().starts_with("pcbnew"));
kicad(fg) && !before.is_some_and(|b| kicad(b))
}
/// The constant bit rate that keeps a take of `max_ms` under `max_mb` (95 % of it, for the container),
/// never above `peak` and never under 1 Mbit/s.
pub fn sized_bitrate(max_ms: u64, max_mb: u64, peak: u64) -> u64 {
let secs = (max_ms / 1000).max(1);
(max_mb * 8_000_000 * 95 / 100 / secs).min(peak).max(1_000_000)
}
#[cfg(test)]
mod sized_bitrate_tests {
#[test]
fn a_fields_take_fits_its_size_cap() {
let b = super::sized_bitrate(15 * 60 * 1000, 1024, super::WINDOW_MAX_BITRATE);
assert!(b < super::WINDOW_MAX_BITRATE && b > 8_000_000, "{b}");
assert!(b * 900 / 8 <= 1024 * 1_000_000);
assert_eq!(super::sized_bitrate(60_000, 100_000, 12_000_000), 12_000_000);
assert_eq!(super::sized_bitrate(3_600_000, 10, 12_000_000), 1_000_000);
}
}
fn window_max_bitrate() -> u64 {
std::env::var("AIFLOW_CLIP_MAX_BITRATE").ok().and_then(|v| v.trim().parse().ok()).filter(|v| *v >= 1_000_000).unwrap_or(WINDOW_MAX_BITRATE)
}
/// How long a stop waits for a finalising take before it flags the clip and moves on (default 300 s,
/// AIFLOW_CLIP_FINALISE_SECONDS). `recut`, `finish` and `deliver` stop it again later.
pub fn finalise_budget_secs() -> u64 {
std::env::var("AIFLOW_CLIP_FINALISE_SECONDS").ok().and_then(|v| v.trim().parse().ok()).unwrap_or(300)
}
/// The recorder no longer has this take registered ("No recording with id ...").
pub fn recording_unknown(reply: &Value) -> bool {
let f = reply_fields(reply);
if matches!(f["errorCode"].as_str().or(reply["errorCode"].as_str()), Some("recording_not_found" | "unknown_recording" | "no_recording" | "not_found")) { return true; }
let text = reply.to_string().to_ascii_lowercase();
text.contains("no recording with id") || text.contains("no such recording")
}
fn take_path(r: &Value) -> Option<String> {
let f = reply_fields(r);
r.get("filePath").or_else(|| r.get("path")).or_else(|| f.get("filePath")).or_else(|| f.get("path"))
.or_else(|| f.get("data").and_then(|d| d.get("path")))
.and_then(|v| v.as_str()).filter(|p| !p.is_empty()).map(|s| s.replace('\\', "/"))
}
fn take_state(r: &Value) -> (String, bool) {
let f = reply_fields(r);
(f["state"].as_str().or(r["state"].as_str()).unwrap_or("").to_ascii_lowercase(), f["stillRunning"] == true || r["stillRunning"] == true)
}
/// Stop one take and wait, bounded, for its file (issue #98). Ok(desktop path), or Err((why, pending))
/// where `pending` says a later stop may still collect it. `polls` status checks at most, with
/// `wait` between them; an unknown take is answered at once, never slept on.
pub fn stop_take(call: &mut dyn FnMut(&str, &Value) -> Value, wait: &mut dyn FnMut(), rid: &str, polls: usize) -> (Value, Result<String, (String, bool)>) {
let mut r = call("desktop_record_stop", &json!({"recordingId": rid}));
for _ in 0..2 {
let (state, running) = take_state(&r);
let finalising = state == "finalising" || state == "finalizing" || (running && state != "recording");
if !finalising { if let Some(p) = take_path(&r) { return (r, Ok(p)); } }
if recording_unknown(&r) {
return (r, Err((format!("Adom Bridge no longer knows recording {rid} (\"No recording with id\"): the take was collected or lost on the desktop, so this clip cannot be pulled"), false)));
}
if r["errorCode"] == "aiflow_timeout" {
return (r, Err((format!("desktop_record_stop for {rid} did not answer in time; the clip is flagged and a later stop collects it"), true)));
}
if !finalising {
let said = r["error"].as_str().or(r["message"].as_str()).or(r["errorCode"].as_str()).map(str::to_string).unwrap_or_else(|| r.to_string().chars().take(200).collect());
return (r, Err((format!("the recorder's stop of {rid} gave no file: {said}"), true)));
}
let mut finalised = false;
for _ in 0..polls {
wait();
let st = call("desktop_record_status", &json!({"recordingId": rid}));
if recording_unknown(&st) {
return (r, Err((format!("recording {rid} was finalising, then Adom Bridge stopped listing it (\"No recording with id\"): the clip cannot be pulled"), false)));
}
let (s, running) = take_state(&st);
if s == "failed" {
let f = reply_fields(&st);
return (r, Err((format!("the recorder failed to finalise {rid}: {}", f["finaliseError"].as_str().or(f["error"].as_str()).unwrap_or("no reason given")), false)));
}
if matches!(s.as_str(), "finalised" | "finalized" | "stopped") && !running { finalised = true; break; }
}
if !finalised {
return (r, Err((format!("recording {rid} was still finalising after {polls} status checks; the clip is flagged pullFailed and `adom-aiflow recut` (or finish, deliver) collects it later"), true)));
}
r = call("desktop_record_stop", &json!({"recordingId": rid}));
}
(r, Err((format!("the recorder finalised {rid} but its stop named no file"), true)))
}
/// Where pull_file puts a desktop file: `save_to` plus the file's own name.
pub fn landed_path(remote: &str, save_to: &str) -> PathBuf {
let name = remote.rsplit(['/', '\\']).next().unwrap_or(remote);
Path::new(save_to).join(name)
}
/// A file that exists and holds at least one byte.
pub fn nonempty(p: &Path) -> bool {
std::fs::metadata(p).map(|m| m.is_file() && m.len() > 0).unwrap_or(false)
}
fn pull_until_landed(local: &Path, attempts: usize, mut pull: impl FnMut(), mut wait: impl FnMut()) -> (bool, usize) {
for k in 1..=attempts.max(1) {
pull();
if nonempty(local) { return (true, k); }
if k < attempts { wait(); }
}
// issue #145: a failed pull can leave an empty file behind; a clip that did not land is missing, not an empty clip
if std::fs::metadata(local).map(|m| m.is_file() && m.len() == 0).unwrap_or(false) { let _ = std::fs::remove_file(local); }
(false, attempts.max(1))
}
/// Opt-in endpoint for native read/validate and zone-measurement calls.
/// Zone measurement includes its one refill request and subsequent readbacks.
/// Never redirect placement, routing, zone-edit, or window-control calls.
fn read_socket_args(verb: &str, args: &Value, socket: Option<&str>) -> Value {
let mut result = args.clone();
if matches!(verb, "kicad_routing_state" | "kicad_routing_validate" |
"kicad_placement_state" | "kicad_placement_validate" | "kicad_zone_state") {
if let (Some(object), Some(socket)) = (result.as_object_mut(), socket) {
if !socket.trim().is_empty() && !object.contains_key("socketPath") {
object.insert("socketPath".into(), Value::String(socket.into()));
}
}
}
result
}
/// The live (IPC) verbs a task-owned, isolated KiCad instance answers, reads and edits alike.
const LIVE_IPC_VERBS: &[&str] = &["kicad_routing_state", "kicad_routing_validate", "kicad_placement_state",
"kicad_placement_validate", "kicad_zone_state", "kicad_move_footprint", "kicad_route_net", "kicad_remove_route",
"kicad_apply_tracks", "kicad_apply_zone_plan", "kicad_add_zone", "kicad_select_net", "kicad_select_clear", "kicad_zoom_to"];
/// Issue #159: opt-in endpoint for EVERY live IPC verb, reads and edits (`ADOM_AIFLOW_KICAD_SOCKET`). For a
/// task-owned KiCad launched with its own API pipe (a private TEMP/TMP) while another session's KiCad owns
/// the default pipe; window, capture and file verbs are never redirected. An explicit socketPath in the call
/// wins. `ADOM_AIFLOW_KICAD_READ_SOCKET` keeps its read-only meaning.
fn live_socket_args(verb: &str, args: &Value, socket: Option<&str>) -> Value {
let mut result = args.clone();
if LIVE_IPC_VERBS.contains(&verb) {
if let (Some(object), Some(socket)) = (result.as_object_mut(), socket) {
if !socket.trim().is_empty() && !object.contains_key("socketPath") {
object.insert("socketPath".into(), Value::String(socket.into()));
}
}
}
result
}
fn socket_args(verb: &str, args: &Value) -> Value {
let live = std::env::var("ADOM_AIFLOW_KICAD_SOCKET").ok();
let args = live_socket_args(verb, args, live.as_deref());
let read = std::env::var("ADOM_AIFLOW_KICAD_READ_SOCKET").ok();
read_socket_args(verb, &args, read.as_deref())
}
/// The PCB editor's window in a `kicad_window_info` reply: the editor whose `kind` is `pcb_editor`.
/// The macOS KiCad bridge (0.11.x) names no kind at all (`{app, hwnd, pid, rect, title}`), so there,
/// and only when no editor carries a kind, it is the first window titled `<board> — PCB Editor`.
/// Before this, a Mac run fell back to the last window it had filmed, which
/// after `tour fields` or `tour 3d` is the closed Fields or 3D Viewer window: the net walkthrough
/// then framed and screenshotted nothing, and the next step's clip did not start.
pub fn pcb_editor_of(reply: &Value) -> Option<i64> { pcb_editor_named(reply, "") }
/// The PCB editor of the board `stem` (its title names the stem as a whole name, `eda::names_board`);
/// with no stem, the first PCB editor (`pcb_editor_of`). Issue #155: another project's PCB editor open
/// on the same desktop is never returned for the run's board.
pub fn pcb_editor_named(reply: &Value, stem: &str) -> Option<i64> {
let d = reply.get("data").cloned().unwrap_or(reply.clone());
let eds = d.get("editors").and_then(|e| e.as_array())?;
let hwnd = |w: &Value| w.get("hwnd").and_then(|h| h.as_i64());
let title = |w: &Value| w.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();
let ours = |w: &Value| stem.is_empty() || eda::names_board(&title(w), stem);
if eds.iter().any(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor")) {
return eds.iter().find(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor") && ours(w)).and_then(hwnd);
}
// a bridge that names kinds and lists no PCB editor has none open: unchanged
if eds.iter().any(|w| w.get("kind").map_or(false, |k| !k.is_null())) {
return None;
}
eds.iter().find(|w| titled_pcb_editor(&title(w)) && ours(w)).and_then(hwnd)
}
/// The title of window `hwnd` in a `kicad_window_info` reply (issue #155: a step's clip names the
/// editor it films, so a wrong window shows at once).
pub fn editor_title(reply: &Value, hwnd: i64) -> Option<String> {
let d = reply.get("data").cloned().unwrap_or(reply.clone());
d.get("editors")?.as_array()?.iter().find(|w| w.get("hwnd").and_then(|h| h.as_i64()) == Some(hwnd))
.and_then(|w| w.get("title").and_then(|t| t.as_str())).map(str::to_string)
}
/// "scratch_strip — PCB Editor" or "*board — PCB Editor" (unsaved), never the bare
/// "PCB Editor" strip the macOS bridge also lists, nor the "3D Viewer".
fn titled_pcb_editor(title: &str) -> bool {
title.trim().strip_suffix("PCB Editor").map_or(false, |head| {
!head.trim_end_matches(|c: char| c.is_whitespace() || matches!(c, '—' | '–' | '-' | '|')).trim_start_matches('*').trim().is_empty()
})
}
#[derive(Clone)]
pub struct Bridge {
pub ai_thread: String,
pub target: String,
}
impl Bridge {
pub fn call(&self, verb: &str, args: &Value) -> Value {
// every board operation goes to the run's EDA backend (eda.rs; docs/eda-backends.md): the flow's
// verbs keep their historical kicad_* names, and the backend answers them in its own tool
if verb.starts_with("kicad_") {
let backend = eda::for_call(args);
return eda::dispatch(&*backend, &|v: &str, a: &Value| self.raw_call(v, a), verb, args);
}
self.raw_call(verb, args)
}
/// One adom-bridge verb with a hard wall-clock limit on the CLI process (issue #98): past it the
/// process is killed and the reply is `errorCode: aiflow_timeout`.
pub fn raw_call_bounded(&self, verb: &str, args: &Value, limit: std::time::Duration) -> Value {
use std::io::Read;
let args = socket_args(verb, args);
let child = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()])
.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).spawn();
let mut child = match child { Ok(c) => c, Err(e) => return json!({"status": "error", "error": format!("adom-bridge not runnable: {e}")}) };
let mut out = child.stdout.take();
let reader = std::thread::spawn(move || { let mut t = String::new(); if let Some(o) = out.as_mut() { let _ = o.read_to_string(&mut t); } t });
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if start.elapsed() < limit => std::thread::sleep(std::time::Duration::from_millis(200)),
_ => { let _ = child.kill(); let _ = child.wait(); return json!({"success": false, "errorCode": "aiflow_timeout", "error": format!("adom-bridge {verb} gave no answer in {} s; aiflow stopped waiting", limit.as_secs())}); }
}
}
let text = reader.join().unwrap_or_default();
serde_json::from_str(&text).unwrap_or_else(|_| json!({"status": "parse_error", "raw": text.chars().take(400).collect::<String>()}))
}
/// One adom-bridge verb, as is.
pub fn raw_call(&self, verb: &str, args: &Value) -> Value {
let args = socket_args(verb, args);
let out = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()]).output();
match out {
Ok(o) => {
let text = String::from_utf8_lossy(&o.stdout);
serde_json::from_str(&text).unwrap_or_else(|_| json!({"status": "parse_error", "raw": text.chars().take(400).collect::<String>()}))
}
Err(e) => json!({"status": "error", "error": format!("adom-bridge not runnable: {e}")}),
}
}
pub fn ok(v: &Value) -> bool {
if v.as_object().map(|o| o.iter().any(|(k,v)| k.starts_with("postCommit") && !v.is_null())).unwrap_or(false) {
return false;
}
v.get("success").and_then(|s| s.as_bool()) == Some(true) || (v.get("status").and_then(|s| s.as_str()) == Some("ok") && v.get("success").is_none())
}
/// The live board's revision. KiCad answers nothing while its UI thread fills zones after a
/// commit, and the bridge may report itself busy while a previous verb's refill finishes; the
/// read is retried for up to about two minutes. The last reply is kept for the log.
pub fn revision(&self, board: &str) -> Option<String> {
self.revision_with_reply(board).0
}
pub fn revision_with_reply(&self, board: &str) -> (Option<String>, Value) {
let mut last = Value::Null;
for attempt in 0..20 {
let r = self.call("kicad_routing_state", &json!({"filePath": board}));
if let Some(rev) = r.get("revision").and_then(|r| r.as_str()) {
return (Some(rev.to_string()), r);
}
eprintln!("revision read {}: {} {}", attempt + 1, r["errorCode"].as_str().or(r["status"].as_str()).unwrap_or("?"), r["error"].as_str().or(r["message"].as_str()).unwrap_or("").chars().take(120).collect::<String>());
last = r;
if !retryable_read(&last) { break; }
std::thread::sleep(std::time::Duration::from_secs(6));
}
(None, last)
}
// -- capture recipe -------------------------------------------------------------------
/// Open the run's board in KiCad; returns the editor's hwnd and what was done to the window.
/// `raise` (screen `allowed`, a test box): in front, maximised. Otherwise (issues #132, #135) the
/// board opens without focus or maximise, and a KiCad window Windows put in front anyway (a newly
/// launched process gets the foreground) goes to the bottom, the human's window back in front.
pub fn open_board(&self, remote_board: &str, reason: &str, raise: bool) -> Result<(i64, String), Value> {
if !raise {
let before = self.foreground();
let r = self.call("kicad_open_board", &json!({"filePath": remote_board, "foreground": false}));
let Some(hwnd) = r.get("hwnd").and_then(|h| h.as_i64()) else { return Err(r) };
let mut done: Vec<String> = self.keep_behind(hwnd, before.as_ref()).into_iter().collect();
std::thread::sleep(std::time::Duration::from_secs(40));
// KiCad can come forward again when the board finishes loading
done.extend(self.keep_behind(hwnd, before.as_ref()));
self.call("kicad_send_key", &json!({"hwnd": hwnd, "menu": "Zoom to Fit"}));
std::thread::sleep(std::time::Duration::from_secs(3));
let what = if done.is_empty() { "the human's window stayed in front".to_string() } else { done.join("; ") };
return Ok((hwnd, format!("opened in the background, not maximised (screen hands-off), zoomed to fit; {what}")));
}
let r = self.call("kicad_open_board", &json!({"filePath": remote_board, "foreground": true, "foregroundReason": reason}));
let Some(hwnd) = r.get("hwnd").and_then(|h| h.as_i64()) else { return Err(r) };
self.call("desktop_ui_window", &json!({"hwnd": hwnd, "action": "maximize", "reason": "aiflow capture: fill the monitor"}));
std::thread::sleep(std::time::Duration::from_secs(40));
self.call("desktop_bring_to_front", &json!({"hwnd": hwnd, "reason": "aiflow capture: keep the PCB editor on screen on the test box"}));
std::thread::sleep(std::time::Duration::from_secs(2));
self.call("kicad_send_key", &json!({"hwnd": hwnd, "key": "ctrl+home"}));
std::thread::sleep(std::time::Duration::from_secs(3));
Ok((hwnd, "maximised, zoomed to fit".into()))
}
/// The OS foreground window: {hwnd, pid, title}, None when there is none or it cannot be read.
pub fn foreground(&self) -> Option<Value> {
let r = self.raw_call("desktop_get_foreground", &json!({}));
let d = r.get("data").cloned().unwrap_or(r);
d["hwnd"].as_i64().filter(|h| *h != 0).map(|_| d)
}
/// Issue #135: when a KiCad window took the foreground that `before` held (Windows gives a newly
/// launched process's first window the foreground), send it to the bottom without activating
/// anything, and when it is still in front put the human's previous window back. Says what it did.
pub fn keep_behind(&self, editor: i64, before: Option<&Value>) -> Option<String> {
let fg = self.foreground()?;
let h = fg["hwnd"].as_i64()?;
if !took_foreground(editor, before, &fg) { return None; }
let title = fg["title"].as_str().unwrap_or("").to_string();
let r = self.raw_call("desktop_send_to_bottom", &json!({"hwnd": h, "force": true, "reason": "aiflow screen hands-off: KiCad was launched in front of the human's window"}));
let mut did = format!("KiCad came up in front (hwnd {h} {title:?}); sent it to the bottom{}", if r["sentToBack"].as_bool() == Some(false) || r["data"]["sentToBack"].as_bool() == Some(false) { " (the bridge said it did not move)" } else { "" });
let prev = before.and_then(|b| b["hwnd"].as_i64());
if let (Some(p), Some(now)) = (prev, self.foreground().and_then(|f| f["hwnd"].as_i64())) {
if now == h || now == editor {
let back = self.raw_call("desktop_bring_to_front", &json!({"hwnd": p, "reason": "aiflow screen hands-off: give the human back the window they had before KiCad launched"}));
did.push_str(&format!(", and put the previous window (hwnd {p}) back in front{}", if back["success"].as_bool() == Some(false) { " (that failed)" } else { "" }));
}
}
Some(did)
}
/// Record ONE window (Windows Graphics Capture, background-capturable): the take survives
/// anything another thread or an updater puts in front of KiCad on a shared test box, and it
/// never needs the foreground, so nobody at the box is disturbed.
pub fn record_window_start(&self, hwnd: i64, reason: &str) -> Result<String, Value> {
// a hard cap on every recording: no clip may outlive an hour, whatever the AI forgets
self.record_window_start_capped(hwnd, reason, WINDOW_MAX_MS)
}
/// `record_window_start` with its own length cap; every window take also gets the peak bit rate
/// cap (WINDOW_MAX_BITRATE, issue #98).
pub fn record_window_start_capped(&self, hwnd: i64, reason: &str, max_ms: u64) -> Result<String, Value> {
let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason, "maxDurationMs": max_ms.min(WINDOW_MAX_MS), "maxBitrate": window_max_bitrate()}));
r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)
}
/// `record_window_start_capped` with a hard size cap as well (issue #180): the take is recorded at a
/// constant rate (`bitrate`, CBR) that fits `max_mb` in `max_ms`, at most the usual peak, so its file
/// can never pass `max_mb` whatever the window shows.
pub fn record_window_start_sized(&self, hwnd: i64, reason: &str, max_ms: u64, max_mb: u64) -> Result<String, Value> {
let max_ms = max_ms.min(WINDOW_MAX_MS);
let bitrate = sized_bitrate(max_ms, max_mb, window_max_bitrate());
let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason, "maxDurationMs": max_ms, "bitrate": bitrate, "maxBitrate": bitrate}));
r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)
}
/// A background screenshot of one window (owned popups included), copied into `save_dir`
/// under `name`; returns the local path.
pub fn screenshot_window(&self, hwnd: i64, save_dir: &str, name: &str) -> Option<String> {
let r = self.call("desktop_screenshot_window", &json!({"hwnd": hwnd}));
let d = r.get("data").cloned().unwrap_or(r.clone());
let shots = d.get("screenshots").and_then(|s| s.as_array()).cloned().unwrap_or_else(|| vec![d.clone()]);
let local = shots.iter().find_map(|s| s.get("localPath").or_else(|| s.get("savedTo")).and_then(|v| v.as_str()).map(str::to_string))?;
let dest = format!("{save_dir}/{name}.png");
std::fs::copy(&local, &dest).ok()?;
Some(dest)
}
/// Hydrogen's main window on the box (the human's workspace: chat, webviews).
pub fn hydrogen_hwnd(&self) -> Option<i64> { fusion::hydrogen_hwnd(&|v, a| self.raw_call(v, a)) }
/// Bring one window to the front, named in the reason.
pub fn bring_front(&self, hwnd: Option<i64>, what: &str) -> bool {
match hwnd { Some(h) => self.raw_call("desktop_bring_to_front", &serde_json::json!({"hwnd": h, "reason": format!("show {what} for this step")}))["success"].as_bool().unwrap_or(true), None => false }
}
/// The PCB editor of the run's board (its title names the board's file stem); with no board set,
/// the first PCB editor. Another project's PCB editor on the same desktop is never returned (#155).
pub fn pcb_editor_hwnd(&self) -> Option<i64> {
pcb_editor_named(&self.call("kicad_window_info", &json!({})), &eda::run_kicad_stem())
}
pub fn record_start(&self, reason: &str) -> Result<String, Value> {
let r = self.call("desktop_record_start", &json!({"monitor": 0, "audio": false, "fps": 30, "reason": reason, "maxDurationMs": 3_600_000}));
r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)
}
/// Stop a recording and pull its file into `save_to`, verified: the local copy must exist and
/// be non-empty, or the pull is tried again (PULL_ATTEMPTS in all). The desktop cleans its
/// recordings folder, so a clip not pulled here may be gone later; `pulled` says which it is.
/// Every wait is bounded (issue #98): the stop and status calls have a process time limit, a
/// finalising take is polled for at most finalise_budget_secs(), and a take the recorder no
/// longer knows comes back at once with `failure()` set (the caller flags it pullFailed).
pub fn record_stop(&self, recording_id: &str, save_to: &str) -> ClipStop {
let polls = (finalise_budget_secs() / FINALISE_POLL_SECS).max(1) as usize;
let (mut r, got) = stop_take(
&mut |v: &str, a: &Value| self.raw_call_bounded(v, a, std::time::Duration::from_secs(120)),
&mut || std::thread::sleep(std::time::Duration::from_secs(FINALISE_POLL_SECS)),
recording_id, polls);
match got {
Ok(path) => {
let (pulled, attempts) = self.pull_verified(&path, save_to, PULL_ATTEMPTS);
ClipStop { local: Some(landed_path(&path, save_to)), remote: Some(path), pulled, attempts, reply: r }
}
Err((why, pending)) => {
if !r.is_object() { r = json!({"reply": r}); }
r["aiflowStopError"] = json!(why);
r["aiflowStopPending"] = json!(pending);
ClipStop { remote: None, local: None, pulled: false, attempts: 0, reply: r }
}
}
}
/// pull_file one desktop file into `save_to` until its local copy exists and is non-empty,
/// at most `attempts` pulls. Returns (landed, pulls made).
pub fn pull_verified(&self, remote: &str, save_to: &str, attempts: usize) -> (bool, usize) {
let local = landed_path(remote, save_to);
// pull_file streams and sha256-verifies any size; a multi-GB clip over the mesh relay needs
// the whole-pull budget (default 3600 s since ab 2.1.175, passed explicitly here)
let (landed, made) = pull_until_landed(&local, attempts,
|| { self.raw_call_bounded("pull_file", &json!({"filePaths": [remote], "saveTo": save_to, "timeoutSeconds": 3600}), std::time::Duration::from_secs(3600 + 120)); },
|| std::thread::sleep(std::time::Duration::from_secs(3)));
if landed { return (true, made); }
// LEGACY FALLBACK for ab < 2.1.175 only (fixed pull budget): split on the desktop
let ab_version = self.raw_call("status", &json!({}))["version"].as_str().map(str::to_string);
if !legacy_pull_needs_split(ab_version.as_deref()) { return (false, made); }
(self.pull_in_parts(remote, save_to), made + 1)
}
/// LEGACY (ab < 2.1.175): split a Windows file into 200 MB parts beside it with a human-gated
/// PowerShell shell_execute, pull the parts, join them into the landed path and keep it only
/// when its size matches the original. The parts are removed on both sides.
pub fn pull_in_parts(&self, remote: &str, save_to: &str) -> bool {
let win = remote.replace('/', "\\");
let dir = format!("{win}.parts");
let script = format!("$src='{win}'; $dir='{dir}'; New-Item -ItemType Directory -Force -Path $dir | Out-Null; $fs=[System.IO.File]::OpenRead($src); $len=$fs.Length; $buf=New-Object byte[] (200MB); $i=0; while(($n=$fs.Read($buf,0,$buf.Length)) -gt 0){{ $o=[System.IO.File]::Create(('{{0}}\\part{{1:D3}}' -f $dir,$i)); $o.Write($buf,0,$n); $o.Close(); $i++ }}; $fs.Close(); \"parts=$i size=$len\"");
let enc: String = { let u: Vec<u8> = script.encode_utf16().flat_map(|c| c.to_le_bytes()).collect(); base64_encode(&u) };
let r = self.call("shell_execute", &json!({"command": format!("powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand {enc}"), "timeoutSeconds": 900, "reason": "aiflow: split a large clip into parts so it can be pulled"}));
let out = r["stdout"].as_str().unwrap_or("").to_string();
let grab = |k: &str| out.split(&format!("{k}=")).nth(1).and_then(|x| x.split_whitespace().next()).and_then(|x| x.parse::<u64>().ok());
let (Some(parts), Some(size)) = (grab("parts"), grab("size")) else { return false };
let local = landed_path(remote, save_to);
let tmp = std::path::Path::new(save_to).join(format!(".{}.parts", std::path::Path::new(&local).file_name().and_then(|n| n.to_str()).unwrap_or("clip")));
let _ = std::fs::create_dir_all(&tmp);
let tmp_s = tmp.display().to_string();
for i in 0..parts {
let part = format!("{}/part{i:03}", dir.replace("\\", "/"));
let lp = tmp.join(format!("part{i:03}"));
let ok = pull_until_landed(&lp, 3, || { self.call("pull_file", &json!({"filePaths": [part.clone()], "saveTo": tmp_s.clone()})); }, || std::thread::sleep(std::time::Duration::from_secs(3))).0;
if !ok { return false; }
}
let joined = (|| -> std::io::Result<u64> {
let mut out = std::fs::File::create(&local)?;
for i in 0..parts { let mut f = std::fs::File::open(tmp.join(format!("part{i:03}")))?; std::io::copy(&mut f, &mut out)?; }
Ok(out.metadata()?.len())
})();
let _ = std::fs::remove_dir_all(&tmp);
let _ = self.call("shell_execute", &json!({"command": format!("rmdir /s /q \"{dir}\""), "timeoutSeconds": 60, "reason": "aiflow: remove the clip parts"}));
match joined { Ok(n) if n == size => true, _ => { let _ = std::fs::remove_file(&local); false } }
}
// -- landing ------------------------------------------------------------------------------
/// One kicad_move_footprint per PART, one undo step each, paused between, so the window
/// recorder catches every part landing in its spot instead of the whole board changing in a
/// single frame. Returns (replies, accepted, stopped-at).
pub fn land_moves(&self, board: &str, moves: &Value, pause_ms: u64, progress: impl FnMut(usize, usize, &str, &Value)) -> (Vec<Value>, usize, Option<usize>) {
self.land_moves_allowing(board, moves, pause_ms, &[], progress)
}
/// `land_moves`, with `allow_outside` naming the parts whose body may overhang the outline on
/// purpose (an edge connector, issue #95): their move, and only theirs, carries `allowOutside: true`.
/// A refusal that names no part (`offendingRefs` null) is given the part being moved.
pub fn land_moves_allowing(&self, board: &str, moves: &Value, pause_ms: u64, allow_outside: &[String], mut progress: impl FnMut(usize, usize, &str, &Value)) -> (Vec<Value>, usize, Option<usize>) {
let items: Vec<(String, Value)> = moves.as_object().map(|o| o.iter().map(|(r, m)| (r.clone(), m.clone())).collect()).unwrap_or_default();
let (revision, reply) = self.revision_with_reply(board);
let mut rev = match revision { Some(r) => r, None => return (vec![reply], 0, Some(0)) };
let mut replies = Vec::new();
let mut ok = 0;
for (i, (reference, m)) in items.iter().enumerate() {
let mut r = self.call("kicad_move_footprint", &move_args(board, &rev, reference, m, allow_outside.contains(reference)));
name_offender(&mut r, reference);
progress(i + 1, items.len(), reference, &r);
if !Bridge::ok(&r) {
replies.push(r);
return (replies, ok, Some(i));
}
ok += 1;
match r.get("revision").and_then(|v| v.as_str()) {
Some(nr) => rev = nr.to_string(),
None => if let Some(nr) = self.revision(board) { rev = nr },
}
replies.push(r);
std::thread::sleep(std::time::Duration::from_millis(pause_ms));
}
(replies, ok, None)
}
/// Replay a plan ONE TRACE AT A TIME: an entry with several paths becomes one kicad_route_net
/// per path, so the camera sees each trace drawn instead of a whole net appearing at once.
/// Resumable: a trace already on the live board (same net, same points) is skipped, `from`
/// (1-based) skips the traces before it, and a reply lost to the transport is answered by
/// reading the board: a trace that landed counts, one that did not is sent once more.
/// `precheck` may refuse a trace before anything lands.
pub fn land_plan(&self, board: &str, entries: &[Value], from: usize, pause_ms: u64,
precheck: impl FnMut(usize, &Value, &landing::LiveCopper) -> Option<Value>,
progress: impl FnMut(usize, usize, &Value, &Value)) -> Landing {
let traces = landing::expand_traces(entries);
landing::land_traces_with(board, &traces, from,
|verb, args| self.call(verb, args),
|| self.revision_with_reply(board),
precheck,
|| std::thread::sleep(std::time::Duration::from_millis(pause_ms)),
progress)
}
/// The live board's copper and pads (one kicad_routing_state read, retried like `revision`).
pub fn live_copper(&self, board: &str) -> Option<landing::LiveCopper> {
let (rev, state) = self.revision_with_reply(board);
rev.map(|_| landing::LiveCopper::from_state(&state))
}
/// Take copper items back as one native undo step (kicad_remove_route at the live revision).
pub fn remove_items(&self, board: &str, item_ids: &[String]) -> Value {
let (rev, reply) = self.revision_with_reply(board);
let Some(rev) = rev else { return reply };
self.call("kicad_remove_route", &json!({"filePath": board, "expectedRevision": rev, "itemIds": item_ids}))
}
/// One native transaction owns reconciliation, DRC, equality and Undo.
/// Requires KiCad Bridge 1.0.23+; never fall back to partial delete/create.
pub fn land_zones(&self, board: &str, zones: &[Value], _pause_ms: u64, owned_ids: &[String]) -> Vec<(String, Value)> {
let live = self.zone_state(board, false);
if !Bridge::ok(&live) { return vec![("zone-readback".into(), live)]; }
if let Err(reason) = owned_zones_to_replace(&live, zones, owned_ids) {
return vec![("zone-ownership".into(), json!({"success":false,"errorCode":"zone_ownership_conflict","message":reason}))];
}
let Some(revision) = live["revision"].as_str() else {
return vec![("zone-readback".into(), json!({"success":false,"errorCode":"no_revision","reply":live}))];
};
let reply = self.call("kicad_apply_zone_plan", &json!({
"filePath":board, "expectedRevision":revision,
"ownedItemIds":owned_ids, "zones":zones, "refill":false
}));
// A committed error is not permission to replay; the ledger retains it.
vec![("native-zone-plan".into(), reply)]
}
/// Free megabytes on the test box's recording drive: Adom Bridge's native, read-only,
/// ungated `desktop_disk_free` (ab 2.1.127+), whose default path is the recorder's output folder.
pub fn remote_free_mb(&self) -> Option<u64> {
remote_free_mb_with(|verb, args| self.raw_call(verb, args))
}
/// Filled copper per layer. A refill of many zones can outlast the bridge's IPC wait
/// (ipc_timeout while KiCad's UI thread fills); the fill keeps running, so wait and read again.
pub fn zone_state(&self, board: &str, refill: bool) -> Value {
read_zone_state_with(refill,
|fill| self.call("kicad_zone_state", &json!({"filePath":board,"refill":fill})),
|| std::thread::sleep(std::time::Duration::from_secs(6)))
}
/// Vias on the live board as (net, x, y).
pub fn live_vias(&self, board: &str) -> Vec<(String, f64, f64)> {
let r = self.call("kicad_routing_state", &json!({"filePath": board}));
r.get("vias").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| Some((v.get("net_name")?.as_str()?.to_string(), v["position"]["x"].as_f64()?, v["position"]["y"].as_f64()?))).collect()).unwrap_or_default()
}
pub fn validate(&self, board: &str) -> Value {
self.call("kicad_routing_validate", &json!({"filePath": board}))
}
pub fn status(&self) -> Value {
self.call("kicad_status", &json!({}))
}
}
#[cfg(test)]
mod tests {
#[test]
fn refill_ack_and_transient_snapshots_are_never_measurements() {
let mut replies = vec![
json!({"success":true,"refillRequested":true,"revisionUsable":false}),
json!({"success":true,"revision":"old","filledAreaByLayerMm2":{"F.Cu":1}}),
json!({"success":false,"errorCode":"busy"}),
json!({"success":true,"revision":"new","filledAreaByLayerMm2":{"F.Cu":2}}),
json!({"success":true,"revision":"new","filledAreaByLayerMm2":{"F.Cu":2}}),
].into_iter();
let mut calls = Vec::new();
let r = super::read_zone_state_with(true, |fill| {calls.push(fill);replies.next().unwrap()}, || {});
assert_eq!(r["revision"], "new");
assert_eq!(calls, vec![true,false,false,false,false]);
}
#[test]
fn committed_failure_is_not_success() {
assert!(!super::Bridge::ok(&json!({"success":true,"committed":true,"postCommitDeletionError":"read failed"})));
assert!(super::Bridge::ok(&json!({"success":true,"committed":true})));
}
use serde_json::json;
#[test]
fn zone_ownership_is_never_inferred_from_name_or_board_age() {
let live=json!({"zones":[{"id":"foreign","name":"manual","type":"copper"},{"id":"ours","name":"power","type":"copper"}]});
let plan=vec![json!({"name":"power","polygon":[[0,0],[1,0],[1,1],[0,1]]})];
assert_eq!(super::owned_zones_to_replace(&live,&plan,&["ours".into()]).unwrap(),vec!["ours"]);
assert!(super::owned_zones_to_replace(&live,&plan,&[]).unwrap_err().contains("unowned"));
assert!(super::owned_zones_to_replace(&live,&[],&[]).unwrap().is_empty());
}
#[test]
fn equal_area_is_not_equivalent_zone_geometry() {
let live=json!({"zones":[{"id":"ours","name":"power","type":"copper","outlineAreaMm2":1.0}]});
for polygon in [json!([[0,0],[1,0],[1,1],[0,1]]),json!([[10,10],[11,10],[11,11],[10,11]])] {
let plan=vec![json!({"name":"power","polygon":polygon})];
assert_eq!(super::owned_zones_to_replace(&live,&plan,&["ours".into()]).unwrap(),vec!["ours"]);
}
assert!(super::owned_zones_to_replace(&json!({"success":false}),&[],&[]).is_err());
}
#[test]
fn permanent_identity_refusal_is_not_retried() {
for code in ["board_mismatch", "invalid_socket", "api_server_off", "no_pcb_frame"] {
assert!(!super::retryable_read(&json!({"errorCode":code})));
}
for code in ["ipc_timeout", "busy", "ipc_unavailable"] {
assert!(super::retryable_read(&json!({"errorCode":code})));
}
}
/// The rule behind "one trace at a time": an entry with several paths becomes one call per
/// path, an entry with one path stays one call, so a plan's call count is its path count.
fn expand(entries: &[serde_json::Value]) -> Vec<serde_json::Value> {
entries.iter().flat_map(|e| match e.get("paths").and_then(|p| p.as_array()) {
Some(ps) if ps.len() > 1 => ps.iter().map(|one| { let mut c = e.clone(); c["paths"] = json!([one]); c }).collect::<Vec<_>>(),
_ => vec![e.clone()],
}).collect()
}
#[test]
fn one_call_per_trace() {
let entries = vec![
json!({"net": "+VBAT", "width": 1.0, "paths": [["A", [1.0, 1.0], [2.0, 2.0]], ["B", [3.0, 3.0], [4.0, 4.0]], ["C", [5.0, 5.0], [6.0, 6.0]]]}),
json!({"net": "/SW", "width": 0.2, "paths": [["D", [7.0, 7.0], [8.0, 8.0]]]}),
];
let traces = expand(&entries);
assert_eq!(traces.len(), 4, "two entries with three and one paths must land as four traces");
for t in &traces {
assert_eq!(t["paths"].as_array().unwrap().len(), 1, "every landed call carries exactly one trace");
}
assert_eq!(traces[0]["net"], "+VBAT");
assert_eq!(traces[3]["net"], "/SW");
assert_eq!(traces[1]["width"], 1.0, "the net's width rides every one of its traces");
}
}
#[cfg(test)]
mod pull_tests {
use super::*;
#[test]
fn a_launched_kicad_in_front_is_sent_back_only_when_it_took_the_human_s_window() {
let human = json!({"hwnd": 11, "title": "board.kicad_pcb - Adom Hydrogen"});
assert!(took_foreground(42, Some(&human), &json!({"hwnd": 42, "title": "esc — PCB Editor"})));
assert!(took_foreground(42, Some(&human), &json!({"hwnd": 77, "title": "KiCad 10.0"})));
assert!(took_foreground(42, None, &json!({"hwnd": 42, "title": ""})));
assert!(!took_foreground(42, Some(&human), &human));
// the human had KiCad in front already: their choice, left alone
assert!(!took_foreground(42, Some(&json!({"hwnd": 42, "title": "esc — PCB Editor"})), &json!({"hwnd": 42, "title": "esc — PCB Editor"})));
}
fn scratch(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("aiflow-pull-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_pull_counts_only_when_a_nonempty_file_landed() {
let d = scratch("retry");
let local = landed_path("C:/Users/x/AppData/Local/Temp/adom-bridge-recordings/window-1.mp4", &d.display().to_string());
assert_eq!(local, d.join("window-1.mp4"));
assert_eq!(landed_path("C:\\rec\\w.mp4", "/run"), Path::new("/run/w.mp4"));
// the first pull leaves an empty file, the second lands it
let mut n = 0;
let (ok, tries) = pull_until_landed(&local, PULL_ATTEMPTS, || { n += 1; std::fs::write(&local, if n == 1 { &b""[..] } else { &b"mp4"[..] }).unwrap(); }, || {});
assert_eq!((ok, tries), (true, 2));
// issue #145: every pull leaves an empty file: it is missing, and no empty file stays behind
let empty = d.join("empty.mp4");
let (ok, _) = pull_until_landed(&empty, PULL_ATTEMPTS, || { std::fs::write(&empty, b"").unwrap(); }, || {});
assert!(!ok);
assert!(!empty.exists(), "a clip that did not land must not leave a 0-byte file");
// nothing ever arrives: three pulls, then give up
let never = d.join("never.mp4");
let mut pulls = 0;
let (ok, tries) = pull_until_landed(&never, PULL_ATTEMPTS, || pulls += 1, || {});
assert_eq!((ok, tries, pulls), (false, 3, 3));
let _ = std::fs::remove_dir_all(&d);
}
}
/// Standard base64 (for PowerShell -EncodedCommand), no external crate.
fn base64_encode(b: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::with_capacity((b.len() + 2) / 3 * 4);
for c in b.chunks(3) {
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
s.push(T[(n >> 18) as usize & 63] as char); s.push(T[(n >> 12) as usize & 63] as char);
s.push(if c.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });
s.push(if c.len() > 2 { T[n as usize & 63] as char } else { '=' });
}
s
}
#[cfg(test)]
mod pull_parts_tests {
#[test]
fn base64_matches_the_standard() {
let u: Vec<u8> = "ab\u{e9}cd".encode_utf16().flat_map(|c| c.to_le_bytes()).collect();
assert_eq!(super::base64_encode(&u), "YQBiAOkAYwBkAA==");
assert_eq!(super::base64_encode(b"M"), "TQ==");
assert_eq!(super::base64_encode(b"Ma"), "TWE=");
}
}
#[cfg(test)]
mod read_socket_tests {
use super::*;
#[test]
fn scoped_reads_preserve_identity_and_explicit_socket() {
let a=json!({"filePath":"exact-board","expectedRevision":"r1"});
for verb in ["kicad_routing_state","kicad_routing_validate","kicad_placement_state","kicad_placement_validate"] {
let b=read_socket_args(verb,&a,Some("ipc://isolated/api.sock"));
assert_eq!(b["socketPath"],"ipc://isolated/api.sock");
assert_eq!(b["filePath"],a["filePath"]);
assert_eq!(b["expectedRevision"],a["expectedRevision"]);
}
let explicit=json!({"socketPath":"ipc://explicit/api.sock"});
assert_eq!(read_socket_args("kicad_placement_state",&explicit,Some("other")),explicit);
}
#[test]
fn zone_refill_and_readbacks_use_the_same_selected_endpoint() {
for refill in [true, false] {
let args = json!({"filePath":"exact-board", "refill":refill});
let routed = read_socket_args("kicad_zone_state", &args, Some("ipc://isolated/api.sock"));
assert_eq!(routed["socketPath"], "ipc://isolated/api.sock");
assert_eq!(routed["filePath"], args["filePath"]);
assert_eq!(routed["refill"], refill);
}
let explicit = json!({"filePath":"exact-board", "refill":false, "socketPath":"ipc://explicit/api.sock"});
assert_eq!(read_socket_args("kicad_zone_state", &explicit, Some("other")), explicit);
}
#[test]
fn absent_empty_and_unrelated_calls_are_unchanged() {
let a=json!({"filePath":"board"});
for socket in [None,Some(""),Some(" ")] {
assert_eq!(read_socket_args("kicad_placement_state",&a,socket),a);
}
for verb in ["kicad_move_footprint","kicad_open_board","kicad_apply_tracks","kicad_apply_zone_plan","kicad_add_zone","fusion_routing_state","desktop_record_window_start"] {
assert_eq!(read_socket_args(verb,&a,Some("ipc://isolated/api.sock")),a);
}
assert_eq!(read_socket_args("kicad_placement_state",&Value::Null,Some("x")),Value::Null);
}
}
#[cfg(test)]
mod native_preflight_tests {
use serde_json::{json, Value};
#[test]
fn disk_free_uses_the_native_verb_and_prefers_available_bytes() {
// the shape ab 2.1.182 returned on AdomLapper: fields at top level and again in `output`
let reply = json!({"success":true,"availableBytes":5_856_219_136u64,"freeBytes":6_000_000_000u64,"output":"{\"availableBytes\":5856219136}"});
assert_eq!(super::disk_free_mb(&reply), Some(5584));
assert_eq!(super::disk_free_mb(&json!({"output":"{\"freeBytes\":2097152}"})), Some(2));
let mut verbs = Vec::new();
let mb = super::remote_free_mb_with(|v, _| { verbs.push(v.to_string()); reply.clone() });
assert_eq!((mb, verbs), (Some(5584), vec!["desktop_disk_free".to_string()]));
}
#[test]
fn shell_fallback_only_on_a_bridge_without_the_verb() {
let mut verbs = Vec::new();
let mb = super::remote_free_mb_with(|v, _| { verbs.push(v.to_string()); if v == "desktop_disk_free" { json!({"errorCode":"unknown_verb"}) } else { json!({"stdout":"3145728\r\n"}) } });
assert_eq!((mb, verbs.len()), (Some(3), 2));
assert_eq!(verbs[1], "shell_execute");
// any other refusal of the native verb is not a reason to shell out
let mut verbs = Vec::new();
let mb = super::remote_free_mb_with(|v, _| { verbs.push(v.to_string()); json!({"errorCode":"target_not_found"}) });
assert_eq!((mb, verbs.len()), (None, 1));
}
#[test]
fn part_splitter_is_legacy_only() {
assert!(super::legacy_pull_needs_split(Some("2.1.174")));
assert!(super::legacy_pull_needs_split(Some("1.8.172")));
assert!(!super::legacy_pull_needs_split(Some("2.1.175")));
assert!(!super::legacy_pull_needs_split(Some("2.1.182")));
assert!(!super::legacy_pull_needs_split(Some("3.0.0-beta")));
assert!(!super::legacy_pull_needs_split(None));
assert!(!super::legacy_pull_needs_split(Some("garbage")));
}
fn binding(r: &str, exists: bool, hidden: bool) -> Value {
json!({"reference": r, "footprint": "Lib:Fp", "models": [{"reference": format!("${{X}}/{r}.step"), "exists": exists, "hidden": hidden}]})
}
#[test]
fn model_check_reads_complete_bindings_and_viewer_classes() {
// J1 of kicad-bridge#101: the file resolves, but the viewer hides not-in-POS bodies
let d = json!({"counts":{"footprints":3},"bindingsComplete":true,"evidenceSource":"saved-board-file",
"modelBindings":[binding("J1", true, false), binding("U1", false, false), binding("R1", true, true), {"reference":"TP1","models":[],"noModelExpected":true}],
"footprintsWithWarnings":[],
"viewer":{"flags":{"smd":true,"notInPosFile":false,"dnp":false}},
"footprintsWithModelByViewerClass":{"smd":3,"notInPosFile":1,"dnp":0}});
let s = super::model_check_summary(&d);
assert_eq!(s.missing, vec!["Lib:Fp (U1): ${X}/U1.step".to_string()]);
assert!(s.hidden.iter().any(|h| h.contains("notInPosFile (1")));
assert!(!s.hidden.iter().any(|h| h.contains("dnp")), "a hidden class with no modelled footprints is not reported");
assert!(s.hidden.iter().any(|h| h.starts_with("R1:")));
assert!(s.bindings_complete);
assert_eq!(s.evidence, "saved-board-file");
}
#[test]
fn model_check_falls_back_to_warnings_on_older_bridges() {
let d = json!({"data":{"counts":{},"footprintsWithWarnings":[{"footprint":"Lib:Fp","references":["U2","U3"],"models":[{"reference":"a.step","exists":false},{"reference":"b.step","exists":true}]}]}});
let s = super::model_check_summary(&d);
assert_eq!(s.missing, vec!["Lib:Fp (U2,U3): a.step".to_string()]);
assert!(s.hidden.is_empty());
assert!(!s.bindings_complete);
assert_eq!(s.evidence, "unknown");
}
}
/// The kicad_move_footprint arguments for one part; `allow_outside` adds `allowOutside: true`.
pub fn move_args(board: &str, rev: &str, reference: &str, m: &Value, allow_outside: bool) -> Value {
let mut one = json!({"ref": reference, "x": m["x"], "y": m["y"], "rotation": m.get("rotation").cloned().unwrap_or(json!(0))});
// issue #142: the rotation that keeps a Fusion bottom part on the bottom (land_moves::fusion_sides)
if m["eagleRot"].is_string() { one["eagleRot"] = m["eagleRot"].clone(); }
let refs = json!([one]);
let mut a = json!({"filePath": board, "expectedRevision": rev, "refs": refs});
if allow_outside { a["allowOutside"] = json!(true); }
a
}
/// A move refusal with no `offendingRefs` names the part aiflow was moving (issue #95: an
/// `outside_outline` came back with `offendingRefs: null`).
pub fn name_offender(reply: &mut Value, reference: &str) {
if !Bridge::ok(reply) {
let empty = reply.get("offendingRefs").map(|v| v.is_null() || v.as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(true);
if empty && reply.is_object() { reply["offendingRefs"] = json!([reference]); reply["offendingRefsFrom"] = json!("adom-aiflow: the part being moved"); }
}
}
#[cfg(test)]
mod native_preflight_live {
/// Read-only acceptance on a real box: AIFLOW_LIVE_TARGET=AdomLapper cargo test -p aiflow-bridge -- --ignored native_preflight_live
#[test]
#[ignore = "needs a live Adom Bridge target (AIFLOW_LIVE_TARGET) and optionally AIFLOW_LIVE_BOARD"]
fn native_preflight_live() {
let target = std::env::var("AIFLOW_LIVE_TARGET").expect("AIFLOW_LIVE_TARGET");
let br = super::Bridge { ai_thread: "aiflow".into(), target };
let mb = br.remote_free_mb().expect("desktop_disk_free answered");
eprintln!("recording drive free: {mb} MB");
assert!(mb > 0);
let ver = br.raw_call("status", &serde_json::json!({}))["version"].as_str().map(str::to_string);
eprintln!("ab {ver:?}, legacy split: {}", super::legacy_pull_needs_split(ver.as_deref()));
if let Ok(board) = std::env::var("AIFLOW_LIVE_BOARD") {
let v = br.raw_call("kicad_model_check", &serde_json::json!({"filePath": board}));
let s = super::model_check_summary(&v);
eprintln!("{s:?}");
assert!(s.bindings_complete);
}
}
}
#[cfg(test)]
mod clip_stop_tests {
use super::*;
fn run(replies: Vec<Value>, polls: usize) -> (Result<String, (String, bool)>, Vec<String>, usize) {
let mut q = replies.into_iter();
let mut verbs = Vec::new();
let mut waits = 0;
let (_, got) = stop_take(&mut |v: &str, _a: &Value| { verbs.push(v.to_string()); q.next().unwrap_or(json!({"success": false, "error": "no more replies"})) }, &mut || waits += 1, "rec-1", polls);
(got, verbs, waits)
}
#[test]
fn a_stop_with_a_file_returns_it() {
let (got, verbs, waits) = run(vec![json!({"success": true, "filePath": "C:\\t\\rec-1.mp4"})], 5);
assert_eq!(got.unwrap(), "C:/t/rec-1.mp4");
assert_eq!((verbs.len(), waits), (1, 0));
let wrapped = json!({"success": true, "output": "{\"data\":{\"path\":\"C:/t/a.mp4\"}}"});
assert_eq!(run(vec![wrapped], 5).0.unwrap(), "C:/t/a.mp4");
}
#[test]
fn an_unknown_recording_fails_at_once_without_sleeping() {
let (got, verbs, waits) = run(vec![json!({"success": false, "error": "No recording with id rec-1"})], 1000);
let (why, pending) = got.unwrap_err();
assert!(why.contains("No recording with id") && !pending, "{why}");
assert_eq!((verbs.len(), waits), (1, 0));
}
#[test]
fn a_finalising_take_is_polled_then_collected() {
let (got, verbs, waits) = run(vec![
json!({"success": true, "state": "finalising", "stillRunning": true, "bytes": 10}),
json!({"success": true, "state": "finalising", "stillRunning": true}),
json!({"success": true, "state": "finalised", "stillRunning": false}),
json!({"success": true, "filePath": "C:/t/rec-1.mp4"}),
], 5);
assert_eq!(got.unwrap(), "C:/t/rec-1.mp4");
assert_eq!(verbs, ["desktop_record_stop", "desktop_record_status", "desktop_record_status", "desktop_record_stop"]);
assert_eq!(waits, 2);
}
#[test]
fn the_finalise_wait_is_bounded_and_a_vanished_take_is_not_slept_on() {
let fin = json!({"success": true, "state": "finalising", "stillRunning": true});
let (got, verbs, waits) = run(vec![fin.clone(); 10], 3);
let (why, pending) = got.unwrap_err();
assert!(why.contains("still finalising") && pending, "{why}");
assert_eq!((verbs.len(), waits), (4, 3));
let (got, _, waits) = run(vec![fin.clone(), json!({"success": false, "errorCode": "recording_not_found", "error": "No recording with id rec-1"})], 100);
let (why, pending) = got.unwrap_err();
assert!(why.contains("stopped listing") && !pending, "{why}");
assert_eq!(waits, 1);
let (got, _, _) = run(vec![fin, json!({"success": true, "state": "failed", "finaliseError": "sink error"})], 100);
assert!(got.unwrap_err().0.contains("sink error"));
}
#[test]
fn a_timed_out_stop_is_pending() {
let (got, _, waits) = run(vec![json!({"success": false, "errorCode": "aiflow_timeout"})], 100);
assert!(got.unwrap_err().1);
assert_eq!(waits, 0);
}
}
#[cfg(test)]
mod edge_overhang_tests {
use serde_json::json;
#[test]
fn allow_outside_rides_only_the_named_parts_move_and_a_bare_refusal_names_the_part() {
let m = json!({"x": 18.5, "y": 5.0, "rotation": 90});
let a = super::move_args("C:/b.kicad_pcb", "r1", "J13", &m, true);
assert_eq!(a["allowOutside"], true);
assert_eq!(a["refs"][0]["ref"], "J13");
assert_eq!(a["refs"][0]["rotation"], 90);
assert!(super::move_args("C:/b.kicad_pcb", "r1", "U1", &m, false).get("allowOutside").is_none());
// issue #95: outside_outline with offendingRefs null
let mut r = json!({"success": false, "errorCode": "outside_outline", "offendingRefs": null});
super::name_offender(&mut r, "J13");
assert_eq!(r["offendingRefs"], json!(["J13"]));
// a refusal that names its parts, and a success, are left as they are
let mut named = json!({"success": false, "errorCode": "courtyard_overlap", "offendingRefs": ["U2"]});
super::name_offender(&mut named, "J13");
assert_eq!(named["offendingRefs"], json!(["U2"]));
let mut fine = json!({"success": true, "revision": "r2"});
super::name_offender(&mut fine, "J13");
assert!(fine.get("offendingRefs").is_none());
}
}
#[cfg(test)]
mod pcb_editor_tests {
use super::*;
// kicad_window_info on the Mac (KiCad bridge 0.11.3, KiCad 10.0.7) with the 3D Viewer open, as the
// Rev C strip run read it on 2026-11-02 (.aiflow-run-revc-edge-board/stop/kicad-window-info-3d-viewer-open.json)
fn mac_reply() -> Value {
json!({"data": {"autoDismissed": [], "editors": [
{"app": "PCB Editor", "hwnd": 2893, "pid": 52411, "rect": {"height": 720, "width": 1280}, "title": "3D Viewer"},
{"app": "PCB Editor", "hwnd": 2858, "pid": 52411, "rect": {"height": 44, "width": 1470}, "title": "PCB Editor"},
{"app": "PCB Editor", "hwnd": 2846, "pid": 52411, "rect": {"height": 923, "width": 1470}, "title": "edge_board — PCB Editor"}],
"hasModalDialogs": false, "modalDialogs": [], "projectManager": null},
"output": "3 editor(s), 0 dialog(s)", "status": "ok", "success": true})
}
#[test]
fn the_mac_editor_is_found_by_its_title() {
assert_eq!(pcb_editor_of(&mac_reply()), Some(2846));
// unsaved board: KiCad marks the title with a star
let star = json!({"data": {"editors": [{"hwnd": 7, "title": "PCB Editor"}, {"hwnd": 8, "title": "*board — PCB Editor"}]}});
assert_eq!(pcb_editor_of(&star), Some(8));
}
#[test]
fn never_the_viewer_or_the_bare_strip() {
let only_viewer = json!({"data": {"editors": [{"hwnd": 2893, "title": "3D Viewer"}, {"hwnd": 2858, "title": "PCB Editor"}]}});
assert_eq!(pcb_editor_of(&only_viewer), None);
assert!(!titled_pcb_editor("PCB Editor"));
assert!(!titled_pcb_editor(" — PCB Editor"));
assert!(!titled_pcb_editor("3D Viewer"));
assert!(!titled_pcb_editor("board — Schematic Editor"));
assert!(titled_pcb_editor("scratch_strip — PCB Editor"));
}
#[test]
fn a_bridge_that_names_kinds_is_read_as_before() {
// the Windows bridge: kind decides, the title is never used
let win = json!({"data": {"editors": [
{"kind": "schematic_editor", "hwnd": 1, "title": "board — Schematic Editor"},
{"kind": "pcb_editor", "hwnd": 2, "title": "board — PCB Editor"}]}});
assert_eq!(pcb_editor_of(&win), Some(2));
// kinds named and no PCB editor among them: none, even with a PCB-like title
let none = json!({"data": {"editors": [{"kind": "3d_viewer", "hwnd": 3, "title": "board — PCB Editor"}]}});
assert_eq!(pcb_editor_of(&none), None);
// the reply without a data wrapper, and no editors at all
assert_eq!(pcb_editor_of(&json!({"editors": [{"kind": "pcb_editor", "hwnd": 9}]})), Some(9));
assert_eq!(pcb_editor_of(&json!({"status": "error"})), None);
assert_eq!(pcb_editor_of(&json!({"data": {"editors": []}})), None);
}
/// Issue #155: the run's own editor by its board's name, never another project's.
#[test]
fn the_runs_editor_is_found_by_its_board_name() {
let win = json!({"data": {"editors": [
{"kind": "pcb_editor", "hwnd": 461626, "title": "other-esc — PCB Editor"},
{"kind": "pcb_editor", "hwnd": 1905110, "title": "esc-2s6s — PCB Editor"}]}});
assert_eq!(pcb_editor_named(&win, "esc-2s6s"), Some(1905110));
assert_eq!(pcb_editor_named(&win, "esc"), None);
assert_eq!(pcb_editor_named(&win, ""), Some(461626));
assert_eq!(pcb_editor_named(&mac_reply(), "edge_board"), Some(2846));
assert_eq!(pcb_editor_named(&mac_reply(), "led-strip-500"), None);
assert_eq!(editor_title(&win, 1905110).as_deref(), Some("esc-2s6s — PCB Editor"));
assert_eq!(editor_title(&win, 5), None);
}
}
#[cfg(test)]
mod live_socket_tests {
use super::*;
/// Issue #159 (patch by Noah): the isolated editor's pipe reaches every live verb, never a window verb.
#[test]
fn live_socket_reaches_edits_and_reads_but_never_windows() {
let args = json!({"filePath": "C:/b.kicad_pcb"});
for verb in ["kicad_move_footprint", "kicad_route_net", "kicad_apply_zone_plan", "kicad_routing_state", "kicad_zone_state", "kicad_select_net", "kicad_zoom_to"] {
assert_eq!(live_socket_args(verb, &args, Some("ipc://iso/api.sock"))["socketPath"], "ipc://iso/api.sock", "{verb}");
}
for verb in ["kicad_open_board", "kicad_send_key", "kicad_window_info", "kicad_screenshot_all", "kicad_run_drc", "desktop_screenshot_window"] {
assert!(live_socket_args(verb, &args, Some("ipc://iso/api.sock")).get("socketPath").is_none(), "{verb}");
}
let explicit = json!({"filePath": "C:/b.kicad_pcb", "socketPath": "mine"});
assert_eq!(live_socket_args("kicad_move_footprint", &explicit, Some("other")), explicit);
assert_eq!(live_socket_args("kicad_move_footprint", &args, None), args);
assert_eq!(live_socket_args("kicad_move_footprint", &args, Some(" ")), args);
// the read socket still answers only reads
assert!(read_socket_args("kicad_move_footprint", &args, Some("r")).get("socketPath").is_none());
assert_eq!(read_socket_args("kicad_routing_state", &args, Some("r"))["socketPath"], "r");
}
}