← Commit history

Board->library resolution (is_library_page, tags_of_cached) + AD reason-gate helpers (ad_cmd, ad_run_why, ad_shell_out_why); deps empty-state copy

barrett-land ·f41f5d48b0 ·2mo ago ·parent e5f9a84
2 files changed +106−31
src/main.rs+105−30
@@ -855,11 +855,42 @@ fn deps_of(slug: &str) -> Vec<String> {     vec![] } -/// Components on a board. Architecture: a board depends on a component LIBRARY,-/// and the library depends on the components. Resolve one hop, any dependency-/// that itself has dependencies is a library and is expanded to its components;-/// a dependency with none is treated as a direct component (backward-compatible-/// with boards that still list components directly).+/// A page's tags, read from the already-fetched component index. PEEKS the cache+/// only — it never triggers `list_components()`'s ~8s wiki crawl, so a caller that+/// runs before the gallery has loaded just gets an empty list and falls back to+/// whatever cheaper test it has.+fn tags_of_cached(slug: &str) -> Vec<String> {+    let txt = match LIST_CACHE.lock().unwrap().as_ref() {+        Some(s) => s.clone(),+        None => return vec![],+    };+    let v: Value = match serde_json::from_str(&txt) {+        Ok(v) => v,+        Err(_) => return vec![],+    };+    v.get("components")+        .and_then(|c| c.as_array())+        .and_then(|arr| arr.iter().find(|it| it.get("slug").and_then(|s| s.as_str()) == Some(slug)))+        .and_then(|it| it.get("tags"))+        .and_then(|t| t.as_array())+        .map(|a| a.iter().filter_map(|x| x.as_str()).map(|s| s.to_lowercase()).collect())+        .unwrap_or_default()+}++/// Is this dependency a component LIBRARY page rather than a component itself?+///+/// Boards used to depend on a single `<board-slug>-library` page which in turn+/// depended on the components (molecule-publish #15). That indirection is going+/// away: a board/molecule now depends on its component pages DIRECTLY. We still+/// expand a library dep so already-published boards keep resolving a BOM, but the+/// test is the page's IDENTITY (slug suffix / `library` tag), NOT the old+/// "does this dep have dependencies of its own" heuristic — that swallowed any+/// direct component dep that happened to carry dependencies and listed its+/// children in place of the part.+fn is_library_page(slug: &str) -> bool {+    slug.ends_with("-library") || tags_of_cached(slug).iter().any(|t| t == "library")+}+ /// A circuit's bill of materials, derived from the `adom-block.json` on its page /// (the neutral design-block model). Aggregates the block's component instances by /// (value, lcsc, footprint) with a quantity and the reference designators. A part@@ -901,26 +932,26 @@ fn circuit_bom(slug: &str) -> Vec<Value> {     order.into_iter().filter_map(|k| agg.remove(&k)).collect() } +/// Components on a board/molecule: its dependencies that ARE component pages,+/// listed directly. A dependency that is a LIBRARY page is expanded one hop into+/// the components it carries — legacy support for boards published under the old+/// `board -> <slug>-library -> components` graph (molecule-publish #15), which+/// new publishes no longer produce. fn board_deps(slug: &str) -> Value {     let mut components: Vec<String> = vec![];     let mut seen = std::collections::HashSet::new();-    let own_lib = format!("{slug}-library");     for d in deps_of(slug) {-        let sub = deps_of(&d);-        if sub.is_empty() {-            // The page's own library (a <slug>-library that lists no components yet, as-            // with a circuit whose BOM lives in its block model) is surfaced via the-            // "Libraries" expander, not as a fake component row. Skip it so the BOM shows.-            if d == own_lib { continue; }-            if seen.insert(d.clone()) {-                components.push(d);-            }-        } else {-            for c in sub {+        if is_library_page(&d) {+            // A library that lists nothing yet (e.g. a circuit whose BOM lives in its+            // block model) contributes no rows — it is surfaced via the board row's+            // "Library" expander, never as a fake component row.+            for c in deps_of(&d) {                 if seen.insert(c.clone()) {                     components.push(c);                 }             }+        } else if seen.insert(d.clone()) {+            components.push(d);         }     }     let items: Vec<Value> = components.into_iter().map(|s| json!({"slug": s})).collect();@@ -932,7 +963,7 @@ fn board_deps(slug: &str) -> Value {         .and_then(|v| v.get("log").cloned())         .unwrap_or_else(|| json!([]));     json!({ "status": "ok", "board": slug, "deps": items, "bom": bom, "log": log,-        "hints": ["board → component library → components (the library's dependencies)"] })+        "hints": ["board → components (its own dependencies); a legacy '-library' dep is expanded one hop"] }) }  /// A PROJECT's direct board dependencies, a page tagged `project` depends on@@ -1335,17 +1366,44 @@ const SCRATCH: &str = "/tmp/adom-lbr-apply"; /// Run a command on the user's machine via the bridge, returning the command's /// own stdout (not the adom-desktop JSON envelope). None if empty / bridge down. fn ad_run(cmd: &str) -> Option<String> {-    let raw = ad_shell_out(cmd)?;+    ad_run_why(cmd, AD_REASON)+}++fn ad_run_why(cmd: &str, why: &str) -> Option<String> {+    let raw = ad_shell_out_why(cmd, why)?;     let v: Value = serde_json::from_str(&raw).ok()?;     v.get("stdout").and_then(|s| s.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) } +/// Who is asking, for every adom-desktop call. AD refuses anonymous calls with+/// `caller_identity_required`: one desktop serves many AI threads, so the user needs to see+/// WHICH one is driving their machine in the approval prompt and the activity log.+const AI_THREAD: &str = "Adom Project Manager";++/// Default `reason` for gated verbs. AD shows it verbatim to the user, so it says what we+/// are accomplishing, not which command we are running.+const AD_REASON: &str =+    "Adom Project Manager is reading and writing your KiCad/Altium library folders (Add to Library / project delivery)";++/// An `adom-desktop` invocation carrying this app's identity. Every bridge call goes+/// through here; a call without `--ai-thread` is refused before it ever reaches the desktop.+fn ad_cmd() -> Command {+    let mut c = Command::new("adom-desktop");+    c.args(["--ai-thread", AI_THREAD]);+    if let Ok(name) = std::env::var("ADOM_CONTAINER_NAME") {+        if !name.trim().is_empty() {+            c.args(["--container-name", name.trim()]);+        }+    }+    c+}+ /// The connected desktop's OS (`"windows"` | `"macos"` | `"linux"`), read from the /// UNGATED `adom-desktop targets` verb. This needs no shell approval and no shell /// probe, so it's the one OS signal that stays reliable even when `shell_execute` /// is gated or down. None if the bridge is down or no desktop is connected. fn ad_target_os() -> Option<String> {-    let out = Command::new("adom-desktop").arg("targets").output().ok()?;+    let out = ad_cmd().arg("targets").output().ok()?;     if !out.status.success() {         return None;     }@@ -1380,14 +1438,14 @@ fn machine() -> (String, String) {     // gated/down we return an OS-appropriate tentative WITHOUT caching (falling back to the     // ungated `targets` hint), so the next call re-detects once the shell is reachable.     // A Unix shell expands `$HOME` to an absolute /path; a Windows cmd shell does not.-    if let Some(home) = ad_run("echo $HOME").filter(|h| h.starts_with('/')) {-        let os = if ad_run("uname -s").map(|u| u.contains("Darwin")).unwrap_or(false) { "macos" } else { "linux" };+    if let Some(home) = ad_run_why("echo $HOME", "Checking your machine's OS and home folder so delivered files land in the right place").filter(|h| h.starts_with('/')) {+        let os = if ad_run_why("uname -s", "Checking your machine's OS and home folder so delivered files land in the right place").map(|u| u.contains("Darwin")).unwrap_or(false) { "macos" } else { "linux" };         let res = (os.to_string(), home);         *MACHINE.lock().unwrap() = Some(res.clone());         return res;     }     // Not a Unix shell → a Windows cmd shell (echo %USERPROFILE% → C:\Users\...).-    if let Some(home) = ad_run("echo %USERPROFILE%").filter(|h| h.contains(':')) {+    if let Some(home) = ad_run_why("echo %USERPROFILE%", "Checking your machine's OS and home folder so delivered files land in the right place").filter(|h| h.contains(':')) {         let res = ("windows".to_string(), home);         *MACHINE.lock().unwrap() = Some(res.clone());         return res;@@ -1973,7 +2031,7 @@ fn valid_slug(s: &str) -> bool { fn ad_pull(remote: &str) -> Option<Vec<u8>> {     let _ = std::fs::create_dir_all(SCRATCH);     let args = json!({"filePaths": [remote], "saveTo": SCRATCH}).to_string();-    Command::new("adom-desktop").args(["pull_file", &args]).output().ok()?;+    ad_cmd().args(["pull_file", &args]).output().ok()?;     let base = remote.rsplit('/').next()?;     std::fs::read(format!("{SCRATCH}/{base}")).ok() }@@ -1981,7 +2039,7 @@ fn ad_pull(remote: &str) -> Option<Vec<u8>> { /// Send container files to a directory on the user's machine. fn ad_send(files: &[String], dest: &str) -> bool {     let args = json!({"filePaths": files, "dest": dest}).to_string();-    Command::new("adom-desktop")+    ad_cmd()         .args(["send_files", &args])         .output()         .map(|o| o.status.success())@@ -2047,8 +2105,7 @@ fn deliver_models(work: &str, dest: &str) -> Vec<String> { }  fn ad_shell(cmd: &str) {-    let args = json!({"command": cmd}).to_string();-    let _ = Command::new("adom-desktop").args(["shell_execute", &args]).output();+    let _ = ad_shell_out(cmd); }  /// Run a shell command on the user's machine and return its captured reply@@ -2059,13 +2116,31 @@ fn ad_shell(cmd: &str) { // bridge call stays fast; the server still handles local/cached requests in parallel. static BRIDGE_LOCK: Mutex<()> = Mutex::new(()); fn ad_shell_out(cmd: &str) -> Option<String> {+    ad_shell_out_why(cmd, AD_REASON)+}++/// `shell_execute` on the user's machine, carrying the caller identity AD requires and a+/// human-readable `reason` for the gated-verb prompt. AD refuses an anonymous call with+/// `caller_identity_required` and a gated one with `reason_required`; both refusals exit 0+/// with an error JSON and no `stdout`, so without this the probe just returns None and the+/// caller silently falls back (which is how a Linux laptop ended up in Windows mode - the+/// ungated `targets`/`status` OS label reads "Windows" on the AD Linux port, so the fallback+/// had nothing truthful to fall back TO). Log the refusal code so it is diagnosable.+fn ad_shell_out_why(cmd: &str, why: &str) -> Option<String> {     let _guard = BRIDGE_LOCK.lock().unwrap();-    let args = json!({"command": cmd}).to_string();-    let out = Command::new("adom-desktop").args(["shell_execute", &args]).output().ok()?;+    let args = json!({"command": cmd, "reason": why}).to_string();+    let out = ad_cmd().args(["shell_execute", &args]).output().ok()?;     if !out.status.success() {         return None;     }-    Some(String::from_utf8_lossy(&out.stdout).to_string())+    let raw = String::from_utf8_lossy(&out.stdout).to_string();+    if let Ok(v) = serde_json::from_str::<Value>(&raw) {+        if let Some(code) = v.get("errorCode").and_then(|c| c.as_str()) {+            eprintln!("WARN: adom-desktop refused shell_execute ({code}): {cmd}");+            return None;+        }+    }+    Some(raw) }  /// Base64 (standard alphabet, padded).
src/manage.html+1−1
@@ -1572,7 +1572,7 @@ function renderDeps(){   // Circuits carry their BOM in the block model (adom-block.json), not as resolved   // component pages: render that as the dependency list when there are no page deps.   if(!curDeps.length && curBom.length){ renderBom(el,q); updateToolbar(); return; }-  if(!curDeps.length){el.innerHTML='<div class="deps-none">This project lists <b>no component dependencies</b> — its BOM and library can’t resolve. Add the components it uses.</div>';updateToolbar();return;}+  if(!curDeps.length){el.innerHTML='<div class="deps-none">This project lists <b>no component dependencies</b> — its BOM can’t resolve. Add the component pages it uses as dependencies of the board itself.</div>';updateToolbar();return;}   const items=curDeps.map(s=>({slug:s,c:bySlug[s]||{slug:s}}))     .filter(x=>!q||x.slug.includes(q)||((x.c.title||'').toLowerCase().includes(q))||((x.c.brief||'').toLowerCase().includes(q)));   const groups={};