← Commit history

2.13.0: port fallback so a foreign occupant cannot kill the dock launch; Send to AI un-parked with a shell-version guard

John Lauer ·f88bb46c91 ·1mo ago ·parent 0584187
9 files changed +97−25
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.12.7"+version = "2.13.0" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.12.7"+version = "2.13.0" edition = "2021" description = "adom-lbr generator / EDA translator: the canonical adom-lbr JSON is the hub, with native converters to Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium output is written natively — no Altium needed at conversion time." 
VERSION+1−1
@@ -1 +1 @@-2.12.7+2.13.0
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.12.7",+  "version": "2.13.0",   "type": "app",   "description": "adom-lbr \u2014 the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON \u2014 symbol + footprint + 3D + pin\u2194pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",   "dependencies": {},
page.json+9−12
@@ -1,8 +1,8 @@ {   "slug": "adom-lbr",-  "version": "2.12.7",+  "version": "2.13.0",   "type": "app",-  "description": "adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",+  "description": "adom-lbr \u2014 the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON \u2014 symbol + footprint + 3D + pin\u2194pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",   "dependencies": {},   "tags": [     "eda",@@ -52,24 +52,21 @@   ],   "files": [     "bin/**",-    "assets/**",     "SKILL.md",-    "skills/**",     "README.md",     "install.sh",     "uninstall.sh",-    "screenshots/**",-    "dockbar.json"+    "screenshots/**"   ],-  "discovery_pitch": "Convert electronic-component symbols + footprints between any two EDA tools — KiCad, Altium (written natively, no Altium needed), and EAGLE/Fusion — through one canonical adom-lbr JSON, silk/courtyard/fab layers preserved. Plus a Manager to browse and inspect any wiki part.",+  "discovery_pitch": "Convert electronic-component symbols + footprints between any two EDA tools \u2014 KiCad, Altium (written natively, no Altium needed), and EAGLE/Fusion \u2014 through one canonical adom-lbr JSON, silk/courtyard/fab layers preserved. Plus a Manager to browse and inspect any wiki part.",   "sample_prompts": [     {       "prompt": "Convert this KiCad symbol and footprint to an Altium SchLib and PcbLib",-      "label": "KiCad → Altium"+      "label": "KiCad \u2192 Altium"     },     {       "prompt": "Import this Altium footprint into the adom-lbr JSON",-      "label": "Altium → JSON"+      "label": "Altium \u2192 JSON"     },     {       "prompt": "Bundle this part into a single Altium IntLib",@@ -77,15 +74,15 @@     },     {       "prompt": "Convert this Altium SchLib and PcbLib to a KiCad symbol and footprint",-      "label": "Altium → KiCad"+      "label": "Altium \u2192 KiCad"     },     {       "prompt": "Export this adom-lbr JSON to a KiCad .kicad_sym and .kicad_mod",-      "label": "JSON → KiCad"+      "label": "JSON \u2192 KiCad"     },     {       "prompt": "Open the adom-lbr Manager to browse wiki components and view the layer stackup",       "label": "Browse parts"     }   ]-}\ No newline at end of file+}
src/dock.rs+61−7
@@ -178,12 +178,36 @@ fn control_api_base() -> String { /// thread named for the app and tagged dock:adom-lbr, then submit the phrase. /// NEW THREADS ONLY - resuming or typing into existing threads stays parked /// (try_phrase below remains dormant behind TRY_PARKED).-/// EMERGENCY PARK (dock thread, 2026-08-24): the Send-to-AI success path-/// crashed the ah dev shell on ConfRoomROG during kicad's cold-verify. Until-/// the un-park broadcast after main's certification, /api/say-send refuses-/// BEFORE any control-API call; the page's failure path (error toast + copy-/// to clipboard) carries users. Flip this to false on the broadcast.-pub const SAY_SEND_PARKED: bool = true;+/// UN-PARKED 2026-08-24 on John's ruling, after the ah build thread certified+/// the crash family dead on the gate-fixed build (33ac2074 gated six raw-CDP+/// typing helpers that ran outside the concurrency gate) and the dock thread+/// verified the installed binary by code marker rather than the version stamp.+/// Kept as a const so a future park is one line again.+pub const SAY_SEND_PARKED: bool = false;++/// Hydrogen builds BEFORE the gate fix still carry the crash, and published+/// 1.0.141 predates it. A pre-fix shell gets an honest refusal here rather+/// than a dead editor; the page then copies the phrase as it always did.+/// Un-gated once every user is past 1.0.142.+const MIN_SHELL_VERSION: (u32, u32, u32) = (1, 0, 142);++fn parse_ver(v: &str) -> Option<(u32, u32, u32)> {+    let core = v.trim().trim_start_matches('v');+    let core = core.split(|c: char| c == '-' || c == '+' || c == ' ').next()?;+    let mut it = core.split('.').map(|p| p.parse::<u32>().ok());+    Some((it.next()??, it.next()??, it.next().flatten().unwrap_or(0)))+}++/// The shell's version, from the control API. None means "could not tell",+/// which is NOT treated as too old: an unknown shell still gets the attempt,+/// because the crash is the exception and refusing everyone on a probe miss+/// would break the feature for everybody.+fn shell_version(base: &str) -> Option<(u32, u32, u32)> {+    let v = curl_json(&format!("{base}/version"), None, 2)?;+    let s = v.get("version").or_else(|| v.get("hydrogenVersion")).or_else(|| v.get("app"))+        .and_then(|x| x.as_str())?;+    parse_ver(s)+}  pub fn say_send(phrase: &str) -> Value {     if phrase.trim().is_empty() {@@ -194,6 +218,12 @@ pub fn say_send(phrase: &str) -> Value {             "why": "Send to AI is parked fleet-wide while a crash in the AI panel's send path is fixed. Copied instead; paste it into any AI thread."});     }     let base = control_api_base();+    if let Some(v) = shell_version(&base) {+        if v < MIN_SHELL_VERSION {+            return json!({"ok": false, "parked": true,+                "why": format!("This Hydrogen ({}.{}.{}) predates the fix for a crash in the AI panel's send path; update to 1.0.142 or newer. Copied instead; paste it into any AI thread.", v.0, v.1, v.2)});+        }+    }     let new_body = json!({"name": "Adom LBR — say it", "tags": ["dock:adom-lbr"]});     let Some(created) = curl_json(&format!("{base}/ai-threads/new"), Some(&new_body), 4) else {         return json!({"ok": false, "why": "the AI panel's control API is not reachable from this machine"});@@ -589,6 +619,28 @@ fn ask_server_to_claim(port: u16) { /// The dock entry point. Returns Ok(true) when a server is (now) serving and /// the caller should exit 0; the foreground serve itself is the caller's job /// when this returns Ok(false).+/// The port this process will actually serve on: the requested one, or the+/// next free port when a FOREIGN service holds it.+///+/// Found live on John's box (2026-08-25): another Adom app had taken 8785, so+/// the old guard refused with "pick another with --port" and the DOCK LAUNCH+/// DIED - correct diagnosis, useless outcome, because the human never types+/// that command; the dock does, and it scrapes whatever URL we print. A+/// foreign occupant is now a reason to move over, announced on stderr so the+/// launch console says why.+pub fn resolve_port(requested: u16) -> anyhow::Result<u16> {+    if running_version(requested).is_some() || !port_in_use(requested) || !serve_pids(requested).is_empty() {+        return Ok(requested);   // ours, or free: the normal paths handle it+    }+    for p in (requested + 1)..=(requested + 20) {+        if !port_in_use(p) {+            eprintln!("adom-lbr dashboard: port {requested} is held by another service; serving on {p} instead");+            return Ok(p);+        }+    }+    anyhow::bail!("port {requested} is taken by another service and no free port was found in {}..{}", requested + 1, requested + 20)+}+ pub fn serve_entry(port: u16, print_url: bool, foreground: bool) -> anyhow::Result<bool> {     let mut theirs = running_version(port);     // A pre-2.11 Manager has no /api/status, so it cannot identify itself; but@@ -597,7 +649,9 @@ pub fn serve_entry(port: u16, print_url: bool, foreground: bool) -> anyhow::Resu     // timed out means free, and we simply bind.     if theirs.is_none() && port_in_use(port) {         if serve_pids(port).is_empty() {-            anyhow::bail!("port {port} is taken by something that is not adom-lbr; pick another with --port");+            // Should be unreachable: resolve_port moved us off a foreign port+            // before we got here. Kept as a guard rather than a silent bind.+            anyhow::bail!("port {port} is taken by something that is not adom-lbr");         }         if !retire_stale(port, "pre-2.11") {             anyhow::bail!("an older adom-lbr Manager owns port {port} and would not exit; kill it and relaunch");
src/main.rs+6
@@ -2044,6 +2044,12 @@ fn main() {         }         Commands::Dashboard { cmd } => match cmd {             DashboardCmd::Serve { port, print_url, foreground } => {+                // A foreign service on the default port moves us over rather+                // than killing the launch (John's box, 2026-08-25).+                let port = match dock::resolve_port(port) {+                    Ok(p) => p,+                    Err(e) => { eprintln!("ERROR: {e}"); process::exit(2); }+                };                 match dock::serve_entry(port, print_url, foreground) {                     Ok(true) => {}                     Ok(false) => {
src/manage.html+9−1
@@ -3,7 +3,7 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1">-<title>adom-lbr Dashboard</title>+<title>Adom LBR Dashboard</title> <link rel="icon" href="favicon.svg" type="image/svg+xml"> <script type="module" src="https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"></script> <style>@@ -796,6 +796,14 @@ async function refreshStatus(force){   try{     const s=await api('api/status'+(force?'?force=1':''));lastStatus=s;     document.getElementById('pop-ver').textContent='v'+s.version;+    // Cache cross-check (dockbar.md, 2026-08-24): if the served build moved on+    // while this document stayed cached, say so loudly rather than let anyone+    // screenshot stale UI that reports a fresh version.+    if(!window.__buildAtLoad)window.__buildAtLoad=s.version;+    else if(window.__buildAtLoad!==s.version&&!window.__staleWarned){+      window.__staleWarned=true;+      toast('This page is running '+window.__buildAtLoad+' but the server now serves '+s.version+'. Reload to catch up.',true);+    }     paintLeds(s);     paintSettingsStatus();     gradLaunchTick(s);
src/manage.rs+8−1
@@ -1265,8 +1265,15 @@ fn export_part(slug: &str) -> Value { fn hdr(ct: &str) -> Header {     Header::from_bytes(&b"Content-Type"[..], ct.as_bytes()).unwrap() }+/// The dashboard HTML is served no-store (dockbar.md, 2026-08-24): a webview+/// can keep a CACHED page across a version change, so the server reports the+/// new version while the page still runs the OLD build's JavaScript - which is+/// how a sibling app screenshotted stale UI after a clean Hard Refresh Open.+/// Hashed static assets may still cache; this document must not. fn html(s: &str) -> Response<Cursor<Vec<u8>>> {-    Response::from_string(s).with_header(hdr("text/html; charset=utf-8"))+    Response::from_string(s)+        .with_header(hdr("text/html; charset=utf-8"))+        .with_header(Header::from_bytes(&b"Cache-Control"[..], &b"no-store, must-revalidate"[..]).unwrap()) } fn json_resp(s: &str) -> Response<Cursor<Vec<u8>>> {     Response::from_string(s).with_header(hdr("application/json"))