app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
v1.9.69: ralph loop findings
9 files changed
+163−55
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.67+1.9.69
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).