← Commit history

0.2.12 source: install phases ledger, remembered receipt, altium_install_receipt, lastInstall on status

Colby Knox ·f99a826616 ·1mo ago ·parent c09e709
4 files changed +118−3
bridge-rs/src/library.rs+95−1
@@ -5,7 +5,8 @@ use crate::support::{self, s, s_or, timeout_of}; use crate::{ab, agent, shot}; use serde_json::{json, Value};-use std::time::Duration;+use std::sync::Mutex;+use std::time::{Duration, Instant};  /// `_lib_call`: gate, optionally open a library first, then one agent command. fn lib_call(args: &Value, cmd: &str, params: Vec<(&str, String)>, open_path: Option<&str>, tdef: u64) -> Value {@@ -466,7 +467,68 @@ fn valid_lib_name(name: &str) -> bool { /// Unpack a staged zip (<Lib>.SchLib + <Lib>.PcbLib [+ .IntLib], regardless of a wrapping /// folder), install into the user's Installed Libraries, optionally return a proof frame. /// Hardened for untrusted zips: size caps, zip-slip refusal.+/// Per-step wall clock of one bundle install, so a slow run names its step (the+/// 2026-09-08 report: an install outlived ab's 60 s budget with nothing saying+/// where the time went).+struct Phases { last: Instant, items: Vec<(String, u64)> }+impl Phases {+    fn start() -> Self { Phases { last: Instant::now(), items: Vec::new() } }+    fn mark(&mut self, name: &str) {+        self.items.push((name.to_string(), self.last.elapsed().as_millis() as u64));+        self.last = Instant::now();+    }+    fn to_json(&self) -> Value {+        let mut m = serde_json::Map::new();+        for (k, v) in &self.items { m.insert(k.clone(), json!(v)); }+        Value::Object(m)+    }+}++/// The last bundle install's receipt, proof frame included. ab drops a verb that+/// outlives its budget while this bridge, which serves one verb at a time,+/// finishes it anyway; a client that got "timed out" polls altium_status (the+/// light summary) until lastInstall carries its installId, then reads the full+/// receipt back through altium_install_receipt.+static LAST_INSTALL: Mutex<Option<Value>> = Mutex::new(None);+fn remember_install(receipt: &Value) {+    if let Ok(mut g) = LAST_INSTALL.lock() { *g = Some(receipt.clone()); }+}+pub fn last_install() -> Value {+    LAST_INSTALL.lock().ok().and_then(|g| g.clone()).unwrap_or(Value::Null)+}+/// The receipt without its heavy fields, for the status verb every poll reads.+pub fn last_install_summary() -> Value {+    let mut v = last_install();+    if let Some(o) = v.as_object_mut() {+        for k in ["thumbnailDataUrl", "shot", "components", "installed"] { o.remove(k); }+    }+    v+}+pub fn install_receipt(_args: &Value) -> Value {+    let r = last_install();+    if r.is_null() {+        return json!({"success": true, "receipt": Value::Null,+            "_hint": "No bundle install has run in this bridge process yet (the receipt lives in memory; a bridge restart clears it)."});+    }+    json!({"success": true, "receipt": r})+}+ pub fn install_library_bundle(args: &Value) -> Value {+    let t0 = Instant::now();+    let mut phases = Phases::start();+    let mut out = install_library_bundle_inner(args, &mut phases);+    if let Some(o) = out.as_object_mut() {+        o.insert("phasesMs".into(), phases.to_json());+        o.insert("totalMs".into(), json!(t0.elapsed().as_millis() as u64));+        o.insert("atMs".into(), json!((support::now() * 1000.0) as u64));+        if let Some(id) = s(args, "installId") { o.insert("installId".into(), json!(id)); }+        if !o.contains_key("library") { o.insert("library".into(), json!(s_or(args, "libraryName", ""))); }+    }+    remember_install(&out);+    out+}++fn install_library_bundle_inner(args: &Value, ph: &mut Phases) -> Value {     // One-click parity with the KiCad/Fusion lanes (Colby, adom/altium-bridge#5): from a cold     // state, launch Altium first (measured cold start on the taskbar bar), then register. The     // resident agent bootstraps on the first agent call. Safe to ship now that the close@@ -492,9 +554,11 @@ pub fn install_library_bundle(args: &Value) -> Value {             crate::instance::select(Some(inst));         }     }+    ph.mark("launch");     if let Some(g) = support::need_licensed() {         return g;     }+    ph.mark("licence");     let zp = match s(args, "zipPath") {         Some(z) if !z.is_empty() => z,         _ => return json!({"success": false, "error": "zipPath is required (stage the bundle with write_file / write_file_chunk first)"}),@@ -600,6 +664,7 @@ pub fn install_library_bundle(args: &Value) -> Value {     } else {         schlibs.iter().chain(pcblibs.iter()).collect()     };+    ph.mark("unpack");     let mut installed = Vec::new();     for lib in to_install {         let winpath = lib.to_string_lossy().replace('/', "\\");@@ -607,6 +672,7 @@ pub fn install_library_bundle(args: &Value) -> Value {         installed.push(json!({"lib": lib.file_name().map(|n| n.to_string_lossy().to_string()),                               "ok": ir.get("success").and_then(|b| b.as_bool()).unwrap_or(false)}));     }+    ph.mark("register");     let mut out = json!({"success": true, "launched": launched,         "library": if name.is_empty() { stem } else { name },         "symbolFiles": schlibs.len(), "footprintFiles": pcblibs.len(), "intlibs": intlibs.len(),@@ -623,12 +689,14 @@ pub fn install_library_bundle(args: &Value) -> Value {         let kind = if schlibs.is_empty() { "pcb" } else { "sch" };         let o = support::agent_open(&show_path.to_string_lossy().replace('/', "\\"), 60);         let opened = o.get("success").and_then(|b| b.as_bool()) == Some(true);+        ph.mark("open");         if opened {             let lst = agent::call("lib_list", &[("kind", kind.into()), ("limit", "200".into())], None, Duration::from_secs(30));             if let Some(n) = lst.get("data").and_then(|d| d.get("count")) {                 out[if kind == "sch" { "symbolCount" } else { "footprintCount" }] = n.clone();                 out["components"] = lst.get("data").and_then(|d| d.get("names")).cloned().unwrap_or(Value::Null);             }+            ph.mark("list");             // land the user on the library LIST, not just the first symbol's canvas: the panel             // tab wants the UIA select action, not a click (a click does not switch, measured)             if let Some(hw) = crate::instance::licence_state().get("hwnd").and_then(|h| h.as_i64()) {@@ -637,7 +705,9 @@ pub fn install_library_bundle(args: &Value) -> Value {                     "reason": "land the user on their freshly installed library list (adom/altium-bridge#7)"}), &[], 10);                 out["panelSelected"] = json!(sel.get("success").and_then(|b| b.as_bool()).unwrap_or(false));             }+            ph.mark("panel");             let frame = shot::shot_of_main();+            ph.mark("capture");             if let Some(img) = frame.get("image").and_then(|i| i.as_str()) {                 out["thumbnailDataUrl"] = json!(format!("data:image/png;base64,{img}"));                 out["shot"] = frame.clone();@@ -651,6 +721,7 @@ pub fn install_library_bundle(args: &Value) -> Value {     // close to wedge on. Every live loop is stopped before the reply goes out; the next verb     // re-bootstraps in ~8s. The library stays open for the user; the ENGINE goes idle.     let teardown = crate::lifecycle::stop_all_live();+    ph.mark("agentStop");     out["agentStopped"] = json!(teardown.get("success").and_then(|b| b.as_bool()).unwrap_or(false));     out["_hint"] = json!("Registered in Installed Libraries (live, no restart needed) and opened in Altium when shown:true - the SCH/PCB Library panel lists every part. The resident agent was stopped afterward so a user-initiated Altium close finds an idle engine (adom/altium-bridge#6).");     out@@ -660,6 +731,29 @@ pub fn install_library_bundle(args: &Value) -> Value { mod tests {     use super::*; +    #[test]+    fn install_receipt_is_remembered_and_the_status_summary_stays_light() {+        remember_install(&json!({"success": true, "library": "L", "installId": "L-1", "thumbnailDataUrl": "data:x",+            "shot": {"image": "y"}, "components": ["a"], "installed": [{"lib": "L.SchLib", "ok": true}], "shown": true}));+        assert_eq!(last_install()["installId"], "L-1");+        assert_eq!(install_receipt(&json!({}))["receipt"]["thumbnailDataUrl"], "data:x");+        let sum = last_install_summary();+        assert_eq!(sum["shown"], true);+        assert_eq!(sum["installId"], "L-1");+        for k in ["thumbnailDataUrl", "shot", "components", "installed"] { assert!(sum.get(k).is_none(), "{k} stripped"); }+    }++    #[test]+    fn phases_record_each_step_in_order() {+        let mut p = Phases::start();+        p.mark("launch");+        p.mark("unpack");+        let j = p.to_json();+        assert!(j.get("launch").is_some() && j.get("unpack").is_some());+        assert_eq!(p.items[0].0, "launch");+        assert_eq!(p.items[1].0, "unpack");+    }+     #[test]     fn model_spec_separators_are_sanitised() {         assert_eq!(spec_safe("a;b|c"), "a,b/c");
bridge-rs/src/misc.rs+2
@@ -51,6 +51,8 @@ pub fn status(args: &Value) -> Value {     r["windows"] = json!(instance::altium_windows());     r["dialogs"] = json!(dialogs::scan());     r["reapedDialogs"] = json!(dialogs::reaped_log(5));+    // The last bundle install, light: a client whose install outlived ab's budget polls this.+    r["lastInstall"] = crate::library::last_install_summary();     r } 
bridge-rs/src/verbs.rs+20−1
@@ -34,7 +34,7 @@ pub const KNOWN: &[&str] = &[     "add_models", "normalize_bodies", "set_params", "set_visibility", "read_symbol",     "clone_symbol", "rename_symbol", "rename_footprint", "remove_symbol", "remove_footprint",     "copy_footprint", "xcopy_footprint", "swap_footprint", "symbol_mode", "cleanup_libs",-    "install_library", "install_library_bundle", "compile_intlib", "extract_intlib",+    "install_library", "install_library_bundle", "install_receipt", "compile_intlib", "extract_intlib",     "place_on_sch", "place_on_pcb", "import_part",     // licence / account     "license", "license_status", "ensure_licensed", "setup_trial", "signin", "signin_status",@@ -58,6 +58,15 @@ fn verb_doc(name: &str, summary: &str, input: Value, output: Value, timeout: u64            "pitfalls": pitfalls}) } +/// A verb that can outlive ab's budget: the same doc, marked long-running with the verb to poll.+#[allow(clippy::too_many_arguments)]+fn verb_doc_long(name: &str, summary: &str, input: Value, output: Value, timeout: u64, status_verb: &str, hint: &str, related: &[&str], pitfalls: &[&str], example: Value) -> Value {+    let mut d = verb_doc(name, summary, input, output, timeout, hint, related, pitfalls, example);+    d["longRunning"] = json!(true);+    d["statusVerb"] = json!(format!("altium_{status_verb}"));+    d+}+ fn describe() -> Value {     let board = json!({"success": "bool", "data": "object - the agent's payload", "roundtripMs": "int"});     let shot = json!({"success": "bool", "shot": "{image: base64 png, width, height}", "windowEtiquette": "background"});@@ -124,6 +133,15 @@ fn describe() -> Value {         verb_doc("close", "Polite close (releases the licence seat); force needs acceptSeatLoss.", json!({"force": "bool", "acceptSeatLoss": "bool"}), json!({"closedInSec": "float"}), 30,             "A save prompt on close NAMES the documents - read it; on a shared machine one may belong to another AI thread. Never defaulted.",             &["license", "launch"], &["force:true without acceptSeatLoss is refused: a kill strands the On-Demand seat."], json!({})),+        verb_doc_long("install_library_bundle", "One-click install of a wiki library bundle (zip of .SchLib/.PcbLib): unpack, register, open the library, prove it with a frame.",+            json!({"zipPath": "string - staged zip", "libraryName": "string", "capture": "bool", "captureMaxWidth": "int", "installId": "string - echoed in the receipt so a client can match it"}),+            json!({"shown": "bool", "symbolCount": "int", "components": "[names]", "shot": "{image, ...}", "thumbnailDataUrl": "string", "phasesMs": "{launch, licence, unpack, register, open, list, panel, capture, agentStop}", "totalMs": "int", "installId": "string", "atMs": "int"}),+            330, "status",+            "Launches a closed Altium first (up to 240s), then registers and opens the library. Past ab's budget the verb keeps running: poll altium_status until lastInstall.installId matches, then altium_install_receipt for the full receipt.",+            &["install_receipt", "status", "install_library"], &["The receipt lives in bridge memory; a bridge restart clears it."],+            json!({"zipPath": "C:\\Users\\me\\adom-wiki-libs\\Lib-altium.zip", "libraryName": "Lib", "capture": true, "installId": "Lib-1757350000000"})),+        verb_doc("install_receipt", "The last bundle install's full receipt (proof frame included), for a client whose install call outlived ab's budget.", json!({}),+            json!({"receipt": "object|null - the install_library_bundle reply"}), 10, "Read after altium_status shows lastInstall with your installId.", &["install_library_bundle", "status"], &[], json!({})),         verb_doc("describe", "This list - the bridge's verbs and schemas for ab's Verbs tab.", json!({}), json!({"verbs": "[{name, summary, input, output, ...}]"}), 5, "Static; safe to call any time.", &["status"], &[], json!({})),     ],     "allVerbs": names()})@@ -441,6 +459,7 @@ pub fn dispatch(command: &str, args: &Value, _forwarded: &[(String, String)]) ->         "cleanup_libs" => library::cleanup_libs(args),         "install_library" => library::install_library(args),         "install_library_bundle" => library::install_library_bundle(args),+        "install_receipt" => library::install_receipt(args),         "compile_intlib" => library::compile_intlib(args),         "extract_intlib" => library::extract_intlib(args),         "place_on_sch" => library::place_on_sch(args),
page.json+1−1
@@ -139,4 +139,4 @@     "skills/altium-wiki-import/SKILL.md",     "uninstall.sh"   ]-}\ No newline at end of file+}