← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.67\ No newline at end of file+1.9.69\ No newline at end of file
CHANGELOG.md+17
@@ -3,6 +3,23 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/fusion-bridge` +## 1.9.69 - 2026-08-18 (ralph loop findings)++- **Driving badge: diagnostics + transport fallback.** The badge silently never appeared on one box+  while working on another with identical code. fusion_state.drivingBadge now reports `enabled` and+  `lastError` (unavailable / timeout / error / missing asset), the desktop_taskbar timeout went+  6s -> 15s, and when the in-process direct API is unavailable the bridge now falls back to+  shelling ab's CLI instead of failing silently.+- **Dashboard, from the ralph pass:** status verbs now fan out in PARALLEL (a cold remote box took+  36.7s serially; warm latency is now ~3.0s), skeleton LED cards render instantly so the Status+  section is never blank while the first status is in flight, every button carries a tooltip+  (16 had none), a `window.__dash` handle exposes live page state for AI drivability, and+  auto-green's single-flight/debounce is now PER TARGET - a run for one box was silently+  blocking the other while the endpoint still answered "started".+- **auto-green heals a stuck add-in**: Fusion running but the add-in not serving used to sit red+  forever (auto-green only tried sign-in); it now shares ensure_ready's healing path. Proven live:+  a stuck ConfRoomROG went green in 59s.+ ## 1.9.67 - 2026-08-18  - **Window sizing now CONVERGES** (found by measuring the previous release live): the bridge sized
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {     "manifest_version": 1,     "name": "fusion360",-    "version": "1.9.67",-    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.67/adom-bridge-fusion-v1.9.67.zip",-    "sha256": "484b1df5b9461ff1ecf41f2b7527b281547487fd77c737cc6bcb6011e7439741",-    "size": 407937,+    "version": "1.9.69",+    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.69/adom-bridge-fusion-v1.9.69.zip",+    "sha256": "8ed73542d0b42a1ae74a5df2295cd388ffa74c745d506c7ec164349a5de1bf21",+    "size": 408678,     "verbPrefixes": [         "fusion_"     ],
bin/fusion-dashboard
⋯ 1 unchanged line ⋯
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.65"+version = "1.9.67" edition = "2021"  [dependencies]
dashboard/src/main.rs+61−32
@@ -79,13 +79,21 @@ fn default_target() -> String {  /// The LED payload: one call per facet, each with state + an AI hint on how to act. fn gather_status(ai_thread: &str, target: &str) -> serde_json::Value {-    let rd = unwrap_data(&bridge(ai_thread, target, "fusion_readiness", "{}"));-    let aps = unwrap_data(&bridge(ai_thread, target, "fusion_aps_status", "{}"));-    let mcp = unwrap_data(&bridge(ai_thread, target, "fusion_mcp_status", "{}"));-    // No-args form is a pure READ of the sticky pref (verified in server.py).-    let auto_upd = unwrap_data(&bridge(ai_thread, target, "fusion_set_auto_update", "{}"));-    let seat = unwrap_data(&bridge(ai_thread, target, "fusion_set_seat_takeover", "{}"));-    let badge = unwrap_data(&bridge(ai_thread, target, "fusion_set_driving_badge", "{}"));+    // PARALLEL, not sequential: each verb is a relay round-trip, and six of them in series took+    // 36.7s against a remote box (measured live 2026-08-18) - the Dashboard sat blank that whole+    // time. Fan them out and the wall clock is the slowest single call.+    let verbs = ["fusion_readiness", "fusion_aps_status", "fusion_mcp_status",+                 "fusion_set_auto_update", "fusion_set_seat_takeover", "fusion_set_driving_badge"];+    let handles: Vec<_> = verbs.iter().map(|v| {+        let (ai, t, verb) = (ai_thread.to_string(), target.to_string(), v.to_string());+        std::thread::spawn(move || unwrap_data(&bridge(&ai, &t, &verb, "{}")))+    }).collect();+    let mut res: Vec<serde_json::Value> = Vec::with_capacity(verbs.len());+    for h in handles {+        res.push(h.join().unwrap_or(serde_json::json!({})));+    }+    let (rd, aps, mcp) = (res[0].clone(), res[1].clone(), res[2].clone());+    let (auto_upd, seat, badge) = (res[3].clone(), res[4].clone(), res[5].clone());      let running = rd["fusionRunning"].as_bool().or(rd["running"].as_bool()).unwrap_or(false);     let ready = rd["ready"].as_bool().unwrap_or(false);@@ -218,8 +226,11 @@ struct App {     last_activity: Mutex<Instant>,     // Auto-green: John's ruling 2026-08-18 - "the meaning of opening the dashboard is that you     // open fusion and all the other services", with a sticky opt-out toggle.-    auto_green_running: Mutex<bool>,-    auto_green_last: Mutex<Option<Instant>>,+    // PER TARGET (ralph, 2026-08-18): these were single globals, so an auto-green for one box+    // silently blocked a run for the other and the endpoint still answered "started" - exactly+    // wrong now that multi-box is first-class.+    auto_green_running: Mutex<std::collections::HashSet<String>>,+    auto_green_last: Mutex<std::collections::HashMap<String, Instant>>,     // Surfaces demo staging, keyed BY TARGET: staging sends files to one specific box, so a     // single global slot made a switch to another box reuse paths that box never received     // (caught live 2026-08-18).@@ -276,15 +287,15 @@ impl App { // step it can do WITHOUT the human, narrates each one as a toast on the live channel, and // stops honestly at any wall that genuinely needs a person (a password, an unconfigured APS // client id) rather than pretending. All of it background - never steals focus.-fn auto_green(app: &Arc<App>, target: &str) {-    {   // single-flight + 3-minute debounce: several tabs/reloads must not stack launches+fn auto_green(app: &Arc<App>, target: &str) -> bool {+    {   // single-flight + 3-minute debounce, PER TARGET: several tabs/reloads must not stack+        // launches on the SAME box, but a run for one box must never block another.         let mut running = app.auto_green_running.lock().unwrap();-        if *running { return; }-        let last = app.auto_green_last.lock().unwrap();-        if let Some(t) = *last {-            if t.elapsed() < Duration::from_secs(180) { return; }+        if running.contains(target) { return false; }+        if let Some(t) = app.auto_green_last.lock().unwrap().get(target) {+            if t.elapsed() < Duration::from_secs(180) { return false; }         }-        *running = true;+        running.insert(target.to_string());     }     let t = target.to_string();     let ai = app.ai_thread.clone();@@ -321,15 +332,21 @@ fn auto_green(app: &Arc<App>, target: &str) {                 if led(&st, "addinReady") { toast("Fusion is up and the bridge add-in is serving", "ok"); }             } -            // 2. The Autodesk sign-in wall - self-drive as far as code can, then hand off.+            // 2. Fusion is up but the add-in is not serving. TWO causes, both fixable without+            //    the human: a sign-in wall (drive it), or an add-in that simply did not load+            //    (restart Fusion - it only loads at launch). John's rule: detect and fix, then+            //    continue. Previously this branch only tried sign-in, so a stuck add-in sat red+            //    on the Dashboard forever (caught by the ralph loop, 2026-08-18).             st = push_state();             if led(&st, "fusionRunning") && !led(&st, "addinReady") {                 let si = unwrap_data(&bridge(&ai, &t, "fusion_signin", "{}"));                 if si["signinWall"].is_string() {                     toast("Fusion needs your Autodesk password - I drove the sign-in to that step for you", "err");                 } else {-                    for _ in 0..12 { std::thread::sleep(Duration::from_secs(10)); st = push_state();-                                     if led(&st, "addinReady") { break; } }+                    toast("Fusion is up but its add-in is not serving - healing that now", "");+                    wait_ready_with_heal(&app2, &t, 240);+                    st = push_state();+                    if led(&st, "addinReady") { toast("Bridge add-in is serving", "ok"); }                 }             } @@ -366,9 +383,10 @@ fn auto_green(app: &Arc<App>, target: &str) {         if reds.is_empty() { toast("All green - ready to go", "ok"); }         else { toast(&format!("Ready except: {} (see each card for the exact next step)", reds.join(", ")), ""); } -        *app2.auto_green_last.lock().unwrap() = Some(Instant::now());-        *app2.auto_green_running.lock().unwrap() = false;+        app2.auto_green_last.lock().unwrap().insert(t.clone(), Instant::now());+        app2.auto_green_running.lock().unwrap().remove(&t);     });+    true }  /// Make the box ready, do not lecture the user about it (John, 2026-08-18: "when i load the@@ -377,15 +395,12 @@ fn auto_green(app: &Arc<App>, target: &str) { /// Any action that needs Fusion calls this FIRST: it returns immediately when ready, otherwise /// it kicks auto-green (single-flight, so an in-flight launch is joined rather than duplicated) /// and waits, narrating progress, before the caller proceeds.-fn ensure_ready(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool {+/// Wait for readiness while HEALING the fixable states. Does NOT call auto_green (auto_green+/// calls this, so putting the kick here would recurse).+fn wait_ready_with_heal(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool {     let ai = app.ai_thread.clone();     let is_ready = || unwrap_data(&bridge(&ai, target, "fusion_readiness", "{}"))["ready"]         .as_bool().unwrap_or(false);-    if is_ready() { return true; }-    app.hub.broadcast("toast", &serde_json::json!({-        "message": format!("Getting {} ready first - launching Fusion and waiting for the bridge add-in", target),-        "kind": ""}));-    auto_green(app, target);                    // no-op if one is already running     let t0 = Instant::now();     let mut announced = false;     let mut restarted = false;@@ -431,6 +446,19 @@ fn ensure_ready(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool {     false } +/// The doctrine entry point: make the box ready, never tell the user to do it.+fn ensure_ready(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool {+    let ai = app.ai_thread.clone();+    if unwrap_data(&bridge(&ai, target, "fusion_readiness", "{}"))["ready"].as_bool().unwrap_or(false) {+        return true;+    }+    app.hub.broadcast("toast", &serde_json::json!({+        "message": format!("Getting {} ready first - launching Fusion and waiting for the bridge add-in", target),+        "kind": ""}));+    let _ = auto_green(app, target);            // false when one is already in flight for this box+    wait_ready_with_heal(app, target, max_wait_s)+}+ // ── SURFACES DEMO (John, 2026-08-18) ───────────────────────────────────────────────────── // The same eight-surface showcase the ab web-control demo runs, but on a DIFFERENT adom basic // part (default aravk/in-s63btg, a green 0603 LED - web control uses the red 0402 IN-S42ATR),@@ -715,9 +743,10 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) {             let t = qs_target.clone().unwrap_or_else(|| app.tgt());             if read_settings()["autoGreenOnLaunch"].as_bool().unwrap_or(true)                 || body["force"].as_bool().unwrap_or(false) {-                auto_green(app, &t);-                respond_json(&mut stream, &serde_json::json!({"ok": true, "started": true,-                    "_hint": "Auto-green is running in the BACKGROUND; watch /events for per-step toasts and state pushes."}));+                let started = auto_green(app, &t);+                respond_json(&mut stream, &serde_json::json!({"ok": true, "started": started, "target": t,+                    "_hint": if started {"Auto-green is running in the BACKGROUND; watch /events for per-step toasts and state pushes."}+                             else {"A run for THIS box is already in flight (or finished in the last 3 minutes) - joined it instead of stacking a second launch."}}));             } else {                 respond_json(&mut stream, &serde_json::json!({"ok": true, "started": false,                     "_hint": "autoGreenOnLaunch is off - the Dashboard is showing status only (the user's choice, sticky)."}));@@ -785,8 +814,8 @@ fn serve(port: u16, target: String, ai_thread: String, print_url: bool) {         hub: Hub { clients: Mutex::new(Vec::new()) },         console: Mutex::new(Vec::new()),         last_activity: Mutex::new(Instant::now()),-        auto_green_running: Mutex::new(false),-        auto_green_last: Mutex::new(None),+        auto_green_running: Mutex::new(std::collections::HashSet::new()),+        auto_green_last: Mutex::new(std::collections::HashMap::new()),         staged: Mutex::new(std::collections::HashMap::new()),     });     if print_url {
dashboard/ui/index.html+23−11
@@ -146,16 +146,16 @@ dialog.settings{max-width:560px;overflow:hidden;padding:0}   <div class="seatnote" id="seatnote" style="display:none">One Autodesk seat, many boxes: start Fusion wherever suits the moment - the seat follows automatically and the other box's Fusion suspends resumably.</div>   <div class="grid" id="ledGrid"></div>   <div class="btnrow">-    <button class="btn primary" id="btnSetup">Setup</button>-    <button class="btn" id="btnStart">Start Fusion</button>-    <button class="btn" id="btnStop">Close Fusion</button>+    <button class="btn primary" id="btnSetup" data-tip="Check every facet and get whatever is not ready READY for you - nothing here is a manual chore">Setup</button>+    <button class="btn" id="btnStart" data-tip="Launch Fusion on the selected box, in the background - your focus is never taken">Start Fusion</button>+    <button class="btn" id="btnStop" data-tip="Close Fusion gracefully on the selected box (documents are closed cleanly first)">Close Fusion</button>   </div>    <h2>Demos</h2>   <div class="card">     <div class="led-label">Guided tour</div>     <div class="led-sub">Walks every surface on a sample part: library, symbol, footprint, 3D chip, a generated solo board in 2D and 3D. Runs entirely in the background.</div>-    <div class="btnrow"><button class="btn primary" id="btnTour">Run the tour</button></div>+    <div class="btnrow"><button class="btn primary" id="btnTour" data-tip="Walk every surface of a sample part - library, symbol, footprint, 3D chip, and a generated solo board in 2D and 3D - with a live progress bar">Run the tour</button></div>     <div class="bar" id="tourBar"><div></div></div>     <div class="barlabel" id="tourLabel"></div>   </div>@@ -164,7 +164,7 @@ dialog.settings{max-width:560px;overflow:hidden;padding:0}     <div class="led-sub">Pulls an adom basic part page from the wiki and opens its library in Fusion's Library Manager.</div>     <div class="btnrow">       <input id="partPage" value="aravk/in-s42ata" style="flex:1;min-width:180px;background:var(--elevated);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:8px 10px;font:13px 'JetBrains Mono',monospace">-      <button class="btn" id="btnImportPart">Import part</button>+      <button class="btn" id="btnImportPart" data-tip="Clone this wiki part page, send its library to the box, and open it in Fusion's Library Manager">Import part</button>     </div>   </div>   <div class="card" style="margin-top:12px">@@ -224,15 +224,15 @@ dialog.settings{max-width:560px;overflow:hidden;padding:0}    </div><!-- /dlgbody -->   <div class="setfoot" style="padding:0 18px 14px">Settings are stored on this machine: the Dashboard's own choices in <code>~/.adom/fusion-dashboard/settings.json</code>, the Fusion upkeep and badge choices as sticky bridge settings on the target box.</div>-  <div class="btnrow" style="justify-content:flex-end;padding:0 18px 16px;margin-top:0"><button class="btn" id="btnCloseSettings">Close</button></div>+  <div class="btnrow" style="justify-content:flex-end;padding:0 18px 16px;margin-top:0"><button class="btn" id="btnCloseSettings" data-tip="Close settings - Escape and clicking outside do the same">Close</button></div> </dialog>  <dialog id="dlgSetup">   <h2 style="margin-top:0">First-time setup</h2>   <div id="setupBody" class="led-sub" style="font-size:14px"></div>   <div class="btnrow" style="justify-content:flex-end">-    <button class="btn" id="btnSetupNext">Run next step</button>-    <button class="btn" id="btnCloseSetup">Close</button>+    <button class="btn" id="btnSetupNext" data-tip="Run the next remaining setup step for you, in the background">Run next step</button>+    <button class="btn" id="btnCloseSetup" data-tip="Close this setup summary">Close</button>   </div> </dialog> @@ -268,11 +268,18 @@ async function loadTargets(){     }   }catch(e){} }+const LED_ORDER=['fusionRunning','addinReady','signedIn','aps','mcp','datahub','update'];+const LED_LABELS={fusionRunning:'Fusion running',addinReady:'Bridge add-in',signedIn:'Autodesk sign-in',+                  aps:'APS cloud search',mcp:'Fusion MCP server',datahub:'Data Hub API',update:'Fusion up to date'};+function renderSkeleton(){+  const g=$('#ledGrid');if(g.children.length)return;      // never wipe real data+  g.innerHTML=LED_ORDER.map(k=>`<div class="card"><div class="led-row"><span class="led"></span>`++    `<span class="led-label">${LED_LABELS[k]}</span></div><div class="led-sub">checking...</div></div>`).join('');+} function renderLeds(st){   const g=$('#ledGrid');g.innerHTML='';   const leds=st.leds||{};-  const ORDER=['fusionRunning','addinReady','signedIn','aps','mcp','datahub','update'];-  const keys=ORDER.filter(k=>leds[k]).concat(Object.keys(leds).filter(k=>!ORDER.includes(k)));+  const keys=LED_ORDER.filter(k=>leds[k]).concat(Object.keys(leds).filter(k=>!LED_ORDER.includes(k)));   for(const key of keys){     const l=leds[key];     const cls=l.pending?'warn':(l.state==='dormant'?'warn':(l.on?'on':'off'));@@ -451,7 +458,7 @@ const PROMPTS=[   'export gerbers, BOM and CPL for the open board to a temp folder',   'keep Fusion updated automatically and never show me the update nag', ];-$('#prompts').innerHTML=PROMPTS.map(p=>`<div class="prompt"><code>${p}</code><button class="btn copy" onclick="navigator.clipboard.writeText('${p.replace(/'/g,"\\'")}');toast('copied','ok')">Copy</button></div>`).join('');+$('#prompts').innerHTML=PROMPTS.map(p=>`<div class="prompt"><code>${p}</code><button class="btn copy" data-tip="Copy this prompt so you can paste it to your AI" onclick="navigator.clipboard.writeText('${p.replace(/'/g,"\\'")}');toast('copied','ok')">Copy</button></div>`).join('');  // ── Live channel (SSE): state pushes, AI toasts, CLI click directives ── const CLICK_TARGETS={setup:'#btnSetup',start:'#btnStart',stop:'#btnStop',tour:'#btnTour','import-part':'#btnImportPart',settings:'#btnSettings',refresh:'#btnRefresh'};@@ -468,6 +475,10 @@ function connectSSE(){   es.onerror=()=>{sseUp=false;es.close();setTimeout(connectSSE,3000);}; } connectSSE();+// Drivability handle: an AI (or the ralph loop) can read the page's real state without+// scraping the DOM. `let` bindings are not window properties, so expose them explicitly.+window.__dash={get sseUp(){return sseUp},get status(){return lastStatus},get target(){return curTarget},+               get settings(){return dashSettings},get errors(){return consoleBuf.slice(-10)}};  // ── Tooltips: body-appended, fixed, clamped to the viewport on BOTH axes ── const tt=$('#tt');let ttTimer=null;@@ -507,6 +518,7 @@ brand.addEventListener('mouseleave',()=>{clearTimeout(aiShow);aiHide=setTimeout( ai.addEventListener('mouseenter',()=>clearTimeout(aiHide)); ai.addEventListener('mouseleave',()=>{aiHide=setTimeout(()=>ai.classList.remove('show'),220);}); +renderSkeleton(); loadDashSettings().then(()=>{renderSurfaces();return loadTargets();}).then(()=>{refresh();kickAutoGreen();}); setInterval(refresh,15000);setInterval(loadTargets,60000); </script>
handlers/taskbar_overlay.py+52−4
@@ -25,6 +25,14 @@ import threading _LOCK = threading.Lock() _B64 = None _BADGED_HWND = None+# Last failure reason, surfaced in fusion_state.drivingBadge.lastError - a badge that silently+# never appears is undebuggable from the outside (ralph, 2026-08-18: everything reported healthy+# while the badge never rendered).+_LAST_ERROR = [None]+++def last_error():+    return _LAST_ERROR[0]   def _badge_b64():@@ -40,15 +48,54 @@ def _badge_b64():     return _B64  +def _taskbar_via_cli(args: dict) -> bool:+    """Fallback when the in-process direct API is unavailable on this box: shell ab's own CLI.+    Same pattern the bridge already uses elsewhere (_find_adom_desktop_cli) - a capability that+    exists on the machine must never be unreachable just because one transport is down."""+    try:+        import json as _j+        import subprocess as _sp+        import sys as _sys+        _sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))+        from server import _find_adom_desktop_cli          # late import: server owns the lookup+        exe = _find_adom_desktop_cli()+        if not exe:+            _LAST_ERROR[0] = "no ab CLI found for the taskbar fallback"+            return False+        cnw = _sp.CREATE_NO_WINDOW if hasattr(_sp, "CREATE_NO_WINDOW") else 0+        out = _sp.run([exe, "desktop_taskbar", _j.dumps(args)], capture_output=True, text=True,+                      timeout=20, creationflags=cnw).stdout+        if "Taskbar updated" in out:+            _LAST_ERROR[0] = None+            return True+        _LAST_ERROR[0] = "CLI fallback reply: %s" % out[:160]+        return False+    except Exception as e:+        _LAST_ERROR[0] = "CLI fallback exception: %s" % str(e)[:160]+        return False++ def _taskbar(args: dict) -> bool:     try:         import ad_client         if not ad_client.available():-            return False-        r = ad_client.call("desktop_taskbar", args, timeout=6)+            _LAST_ERROR[0] = "ad_client unavailable - trying the ab CLI fallback"+            return _taskbar_via_cli(args)+        r = ad_client.call("desktop_taskbar", args, timeout=15)         raw = str(r)-        return bool(r) and ("Taskbar updated" in raw or (isinstance(r, dict) and not r.get("error")))-    except Exception:+        if not r:+            _LAST_ERROR[0] = "desktop_taskbar returned nothing (timeout?)"+            return False+        if "Taskbar updated" in raw:+            _LAST_ERROR[0] = None+            return True+        if isinstance(r, dict) and r.get("error"):+            _LAST_ERROR[0] = "desktop_taskbar error: %s" % str(r.get("error"))[:160]+            return _taskbar_via_cli(args)      # one honest retry down the other transport+        _LAST_ERROR[0] = "unrecognized desktop_taskbar reply: %s" % raw[:160]+        return False+    except Exception as e:+        _LAST_ERROR[0] = "exception: %s" % str(e)[:160]         return False  @@ -67,6 +114,7 @@ def set_driving_badge(hwnd) -> bool:         return True     icon = _badge_b64()     if not icon:+        _LAST_ERROR[0] = "badge asset missing or unreadable (resources/adom-overlay-32.png)"         return False     with _LOCK:         ok = _taskbar({"hwnd": int(hwnd),
server.py+4−2
@@ -4532,10 +4532,12 @@ def _handle_fusion_state(fusion_info: dict, args: dict) -> dict:         result["addinResponding"] = False     result["mainThreadBusy"] = busy     try:-        from handlers.taskbar_overlay import badge_active+        from handlers.taskbar_overlay import badge_active, last_error as _badge_err         result["drivingBadge"] = {"active": badge_active(),                                   "lastDrivingVerbAt": _LAST_DRIVE_AT[0] or None,-                                  "idleShedSeconds": _BADGE_IDLE_SECONDS}+                                  "idleShedSeconds": _BADGE_IDLE_SECONDS,+                                  "enabled": _driving_badge_enabled(),+                                  "lastError": _badge_err()}     except Exception:         pass     # Active document, best-effort (skip when busy - it would just be rejected).