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
Update 15 file(s)
11 files changed
+190−151
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.199+1.9.198
adom-bridge-fusion-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "fusion360",- "version": "1.9.197",- "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.197/adom-bridge-fusion-v1.9.197.zip",- "sha256": "8ad10ac70f5f5ed3b2826bf12055f9d8dc03addbc25a34363d6a357b6a3e615b",- "size": 495993,+ "version": "1.9.198",+ "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.198/adom-bridge-fusion-v1.9.198.zip",+ "sha256": "b5be3998d78bf83f1773f29871360bbddac1cdcfde78cf01698552bfc0d803ed",+ "size": 495992, "verbPrefixes": [ "fusion_" ],
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.197",+ "version": "1.9.198", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/fusion-bridge", "author": "Adom Inc.",
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.9.197"+version = "1.9.198" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.197"+version = "1.9.198" edition = "2021" [dependencies]
dashboard/src/main.rs+114−28@@ -1379,7 +1379,7 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) { // run-prompt kill). While parked this endpoint refuses BEFORE any control-API // call; the page falls back to copy + error toast; the two-button UI stays so // un-park is this one flag after the ah build thread's certification.- const SAY_SEND_PARKED: bool = true;+ const SAY_SEND_PARKED: bool = false; // UN-PARKED 2026-08-24 (crash family certified dead on the gate-fixed shell; a pre-1.0.142 shell is refused inside say_send) let phrase = body["phrase"].as_str().unwrap_or("").to_string(); if SAY_SEND_PARKED { respond_json(&mut stream, &serde_json::json!({"ok": false, "parked": true,@@ -1529,39 +1529,91 @@ fn control_api_base() -> Option<String> { .filter(|s| !s.is_empty()) } +/// Numeric semver compare: is `have` >= `want`? Component-wise, so "1.0.142" >= "1.0.99"+/// is TRUE where a string compare would say false (the trap f7 warned about).+fn version_at_least(have: &str, want: &str) -> bool {+ let parts = |v: &str| -> Vec<u64> {+ v.trim().trim_start_matches('v').split('.')+ .map(|p| p.chars().take_while(|c| c.is_ascii_digit()).collect::<String>()+ .parse::<u64>().unwrap_or(0)).collect()+ };+ let (h, w) = (parts(have), parts(want));+ for i in 0..w.len().max(h.len()) {+ let (a, b) = (h.get(i).copied().unwrap_or(0), w.get(i).copied().unwrap_or(0));+ if a != b { return a > b; }+ }+ true+}++/// Send to AI (say-strip contract). UN-PARKED 2026-08-24 after the ah build thread+/// certified the run-prompt crash family dead on the gate-fixed build.+///+/// Two things here are NOT obvious and both were learned the hard way:+/// 1. ONE-CALL COMPOSITE. `POST /ai-threads/new` never returns a thread id (sid/tid are+/// explicitly null since 1.0.143 - a thread has no identity until its first submitted+/// prompt), so the old open-then-target shape could never have worked. Use+/// run-prompt {new:true, name, tags, submit:true} and let it stage the name/tags.+/// 2. 120s TIMEOUT. Opening drives the editor and every CDP touch serializes through one+/// gate, so calls QUEUE rather than fail; a short client timeout manufactures phantom+/// failures and a blind retry opens a duplicate tab.+/// Delivery is verified by `delivered_to` (checked against the on-disk transcript), never+/// by the thread name. fn say_send(phrase: &str) -> serde_json::Value { let base = match control_api_base() { Some(b) => b, None => return serde_json::json!({"ok": false, "why": "workspace control API not found (~/.adom/hydrogen-control-url missing)"}), };- let post = |path: &str, payload: &serde_json::Value| -> Option<serde_json::Value> {- let out = Command::new("curl")- .args(["-s", "-m", "6", "-X", "POST", "-H", "Content-Type: application/json",- "-d", &payload.to_string(), &format!("{}{}", base, path)])- .output().ok()?;- if out.stdout.is_empty() { return None; }- serde_json::from_slice(&out.stdout).ok()- };- let made = post("/ai-threads/new", &serde_json::json!({- // Fleet ruling (adom-ui-design 1.9.3): the tag is `dock:` + the WIKI PAGE SLUG- // (unique, identifies the package/issues/manifest), never the dock card id.- "name": "Fusion - say it", "tags": ["dock:fusion-bridge"]}));- let sid = made.as_ref().and_then(|m| m["sid"].as_str()- .or(m["data"]["sid"].as_str())).map(|s| s.to_string());- let sid = match sid {- Some(s) => s,+ // SAFETY GATE: a shell older than 1.0.142 still carries the run-prompt crash defect,+ // so refuse honestly there rather than killing the user's editor. Compared NUMERICALLY.+ const MIN_SHELL: &str = "1.0.142";+ let status = Command::new("curl")+ .args(["-s", "-m", "6", &format!("{}/updater/status", base)])+ .output().ok()+ .and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok());+ let shell_ver = status.as_ref()+ .and_then(|v| v["current_version"].as_str().map(|s| s.to_string()));+ match shell_ver {+ Some(v) if version_at_least(&v, MIN_SHELL) => {}+ Some(v) => return serde_json::json!({"ok": false, "shellVersion": v,+ "why": format!("Adom Hydrogen {} predates the Send-to-AI fix ({} or newer required); copied instead. Update Hydrogen and try again.", v, MIN_SHELL)}), None => return serde_json::json!({"ok": false,- "why": format!("ai-threads/new gave no sid ({})",- made.map(|m| m.to_string().chars().take(120).collect::<String>())- .unwrap_or_else(|| "unreachable".into()))}),+ "why": "could not read the Hydrogen version (GET /updater/status) - copied instead rather than risk a pre-fix shell"}),+ }+ let payload = serde_json::json!({+ "new": true, "submit": true, "prompt": phrase,+ "name": "Fusion - say it",+ // Fleet ruling (adom-ui-design 1.9.3): dock: + the WIKI PAGE SLUG, never the card id.+ "tags": ["dock:fusion-bridge"]});+ let out = Command::new("curl")+ .args(["-s", "-m", "120", "-X", "POST", "-H", "Content-Type: application/json",+ "-d", &payload.to_string(), &format!("{}/ai-threads/run-prompt", base)])+ .output();+ let resp = match out {+ Ok(o) if !o.stdout.is_empty() =>+ match serde_json::from_slice::<serde_json::Value>(&o.stdout) {+ Ok(v) => v,+ Err(_) => return serde_json::json!({"ok": false, "why": "control API answered non-JSON"}),+ },+ _ => return serde_json::json!({"ok": false, "why": "control API did not answer within 120s"}), };- match post("/ai-threads/run-prompt", &serde_json::json!({- "sid": sid, "prompt": phrase, "submit": true})) {- Some(r) if r["ok"].as_bool() != Some(false) =>- serde_json::json!({"ok": true, "sid": sid}),- _ => serde_json::json!({"ok": false, "sid": sid,- "why": "thread created but run-prompt failed"}),+ if resp["ok"].as_bool() == Some(false) {+ let hint = resp["_hints"].as_str().map(|s| s.to_string())+ .or_else(|| resp["_hints"].as_object().map(|m| m.values()+ .filter_map(|v| v.as_str()).collect::<Vec<_>>().join(" ")))+ .unwrap_or_default();+ return serde_json::json!({"ok": false,+ "why": format!("{}{}",+ resp["error"].as_str().unwrap_or("the AI panel refused the prompt"),+ if hint.is_empty() { String::new() } else { format!(" - {}", hint) })});+ }+ // Verify by DELIVERY, not by the thread name: run-prompt confirms against the AI's own+ // message list, so delivered_to is the only honest success signal.+ match resp["delivered_to"].as_str() {+ Some(d) if !d.is_empty() => serde_json::json!({"ok": true, "deliveredTo": d,+ "adoptedEmptyTab": resp["adopted_empty_tab"].as_bool().unwrap_or(false)}),+ _ => serde_json::json!({"ok": false,+ "why": "the panel accepted the call but did not confirm delivery (no delivered_to) - copied instead"}), } } @@ -1622,6 +1674,29 @@ fn stage_say_phrase(phrase: &str) -> serde_json::Value { "why": "no AI thread tagged app:adom/fusion-bridge yet - open the app from the dock once"}) } +/// Is a NEWER version of this same app already serving on the range? Used by the reaper so+/// a superseded instance retires ITSELF once nobody is watching it. Without this, every+/// update leaves its predecessor alive: John's laptop accumulated three (1.9.187, 1.9.189,+/// 1.9.193, up to 15h old) squatting ports and serving pre-contract UI, and a probe that+/// asked only ONE of them would have been told, confidently, that the dashboard was current.+fn newer_version_serving() -> Option<String> {+ for p in 8930..=8940u16 {+ let out = Command::new("curl")+ .args(["-s", "-m", "1", &format!("http://127.0.0.1:{}/api/ping", p)])+ .output().ok()?;+ let body = String::from_utf8_lossy(&out.stdout).to_string();+ if !body.contains("\"app\":\"fusion-dashboard\"") { continue; }+ if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body) {+ if let Some(other) = v["dashboardVersion"].as_str() {+ if other != VERSION && version_at_least(other, VERSION) {+ return Some(other.to_string());+ }+ }+ }+ }+ None+}+ /// Probe 8930-8940 for an already-serving FUSION dashboard. "dashboardVersion" alone is /// not enough: the KiCad dashboard also serves it (found live on 8931 - the first probe /// draft would have handed the Fusion dock card KiCad's URL). Require a facet only OUR@@ -1762,9 +1837,20 @@ fn serve(port: u16, target: String, ai_thread: String, print_url: bool) { { let app = app.clone(); std::thread::spawn(move || loop {- std::thread::sleep(Duration::from_secs(3600));+ std::thread::sleep(Duration::from_secs(300)); let idle = app.last_activity.lock().unwrap().elapsed();- if idle > Duration::from_secs(IDLE_REAP_HOURS * 3600) && app.hub.client_count() == 0 {+ let unwatched = app.hub.client_count() == 0;+ // SUPERSESSION RETIREMENT: a newer build is serving and nobody is watching me,+ // so step aside instead of squatting a port and answering probes with stale UI.+ // Only ever exits when unwatched AND idle, so a live viewer is never dropped.+ if unwatched && idle > Duration::from_secs(600) {+ if let Some(newer) = newer_version_serving() {+ println!("OK: v{} is serving and I am v{} with no viewers for {}m - retiring",+ newer, VERSION, idle.as_secs() / 60);+ std::process::exit(0);+ }+ }+ if idle > Duration::from_secs(IDLE_REAP_HOURS * 3600) && unwatched { println!("OK: idle {}h with no viewers - reaping (dock click restarts me)", IDLE_REAP_HOURS); std::process::exit(0); }
dockbar.json+3−4@@ -17,16 +17,15 @@ "console": "show", "steps": [ {- "label": "Installing the Fusion bridge",+ "label": "Installing / updating the Fusion bridge", "run": "adom-wiki pkg install adom/fusion-bridge",- "skip_if": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; command -v fusion-dashboard'", "timeout_seconds": 180 }, { "label": "Starting the Fusion Dashboard",- "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; exec fusion-dashboard serve --print-url'",+ "run": "sh -c 'PATH=\"$HOME/.local/bin:$PATH\"; for i in $(seq 1 15); do command -v fusion-dashboard >/dev/null 2>&1 && break; sleep 2; done; exec fusion-dashboard serve --print-url'", "await": "url",- "timeout_seconds": 60+ "timeout_seconds": 120 } ], "on_ready": "navigate_placeholder",
install.sh+1−1@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.9.197"+DASH_VERSION="1.9.198" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.197",+ "version": "1.9.198", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
page.json+11−8@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.197",+ "version": "1.9.198", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {@@ -11,6 +11,10 @@ "uninstall": "./uninstall.sh" }, "tags": [+ "ab",+ "bridge",+ "fusion",+ "desktop-app", "fusion360", "autodesk", "cad",@@ -75,7 +79,10 @@ "build a basic parts library", "add a 3d model to a component", "make a fusion 360 library with 3d",- "set up the fusion bridge"+ "set up the fusion bridge",+ "fusion dashboard",+ "fusion status page",+ "open the fusion launch page" ], "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", "brief": "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.",@@ -104,9 +111,5 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ],- "author": {- "name": "John Lauer",- "email": "[email protected]"- }-}+ ]+}
server.py+52−101@@ -10080,13 +10080,6 @@ def _close_docs_opened_since(before: set) -> list: for _ in range(12): st = _proxy_to_addin("document_info", {}, timeout=8) docs = (st.get("data") or {}).get("openDocuments") or []- # Fusion ALWAYS keeps one document open: closing the very last doc just makes- # it respawn a fresh empty Untitled, so a sweep that keeps going "closes" the- # same phantom twelve times per chain (every run logged "closed 12 scratch- # doc(s): Untitled x12" while the user watched tabs flutter; measured live- # 2026-08-24). One remaining doc IS Fusion's resting state - leave it.- if len(docs) <= 1:- break u = next((dd for dd in docs if str(dd.get("name") or "").startswith("Untitled") and not (dd.get("isModified") or dd.get("modified"))), None)@@ -10126,80 +10119,49 @@ def _close_bridge_doc_verified(name: str, timeout_s: int = 30) -> dict: # returns, and a close issued during them raises exactly that modal - blocking the # main thread AND offering to destroy the upload. app.hasActiveJobs is the # deterministic signal; wait for it to drain (cap 120s) before touching the close.- for _round in range(3):- # hasActiveJobs drain. A BUSY add-in fails the QUERY exactly when uploads are- # likeliest in flight (caught live 2026-08-24: the lose-changes modal reached the- # user's screen mid-chain), so a failed ask is retried, not treated as drained.- _ask_fails = 0- _drained = False- _jobs_deadline = _t.time() + 120- while _t.time() < _jobs_deadline:- try:- jr = _proxy_to_addin("run_modeling_script",- {"script": "import adsk.core\n"- "result={'jobs': adsk.core.Application.get().hasActiveJobs}"},- timeout=10)- jd = (jr.get("data") or {}).get("result") or {}- _ask_fails = 0- if not jd.get("jobs"):- _drained = True- break- print("[Fusion Bridge] close(%r): Fusion Hub upload in flight - waiting, not "- "closing (a close now would raise the lose-changes modal)" % n)- except Exception:- _ask_fails += 1- if _ask_fails >= 3:- break # cannot ask repeatedly -> proceed; the dialog answer below is the backstop- _t.sleep(5)- if not _drained and _ask_fails < 3 and _t.time() >= _jobs_deadline:- return {"closed": False, "deferred": True,- "why": "Fusion Hub uploads still active after 120s - close deferred so the "- "upload is not destroyed; retry after the jobs drain"}+ _jobs_deadline = _t.time() + 120+ while _t.time() < _jobs_deadline: try:- _proxy_to_addin("close_document", {"name": n, "save": False}, timeout=20)+ jr = _proxy_to_addin("run_modeling_script",+ {"script": "import adsk.core\n"+ "result={'jobs': adsk.core.Application.get().hasActiveJobs}"},+ timeout=10)+ jd = (jr.get("data") or {}).get("result") or {}+ if not jd.get("jobs"):+ break+ print("[Fusion Bridge] close(%r): Fusion Hub upload in flight - waiting, not "+ "closing (a close now would raise the lose-changes modal)" % n) except Exception:- pass- deadline = _t.time() + timeout_s- _upload_modal = False- while _t.time() < deadline:- try:- di = _proxy_to_addin("document_info", {}, timeout=6)- docs = [str(d.get("name") or "").rstrip("*").strip().lower()- for d in ((di.get("data") or {}).get("openDocuments") or [])]- if di.get("success") and n.lower() not in docs:- return {"closed": True}- except Exception:- pass- # A never-saved doc can raise Save-As despite save:false - answer it NOW, same call.- try:- got = _dismiss_save_prompt_if_bridge_doc()- if got.get("handled"):- print("[Fusion Bridge] close(%r): answered the Save prompt inside the same "- "call (deterministic close, never left on screen)" % n)- except Exception:- pass- # The lose-changes modal ("Packages are being uploaded to your Fusion Hub...")- # means the drain lied or raced: answer NO (upload preserved), then re-enter- # the drain and retry the close. Never left standing, never answered Yes.- try:- _ups = [dd for dd in (classify_blocking_dialogs() or [])- if dd.get("category") == "upload_in_progress"]- if _ups:- for _d in _ups:- _unwrap(_ad_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))- print("[Fusion Bridge] close(%r): upload-in-progress modal answered NO "- "(upload preserved); re-waiting for Hub jobs before retrying the close" % n)- _upload_modal = True- break- except Exception:- pass- _t.sleep(2)- if not _upload_modal:- return {"closed": False, "why": "document still open after %ds" % timeout_s}+ break # cannot ask -> proceed; the dialog answer below is the backstop _t.sleep(5)- return {"closed": False, "deferred": True,- "why": "the upload-in-progress modal kept rising across 3 close attempts - close "- "deferred, upload preserved; retry after the Hub sync settles"}+ else:+ return {"closed": False, "deferred": True,+ "why": "Fusion Hub uploads still active after 120s - close deferred so the "+ "upload is not destroyed; retry after the jobs drain"}+ try:+ _proxy_to_addin("close_document", {"name": n, "save": False}, timeout=20)+ except Exception:+ pass+ deadline = _t.time() + timeout_s+ while _t.time() < deadline:+ try:+ di = _proxy_to_addin("document_info", {}, timeout=6)+ docs = [str(d.get("name") or "").rstrip("*").strip().lower()+ for d in ((di.get("data") or {}).get("openDocuments") or [])]+ if di.get("success") and n.lower() not in docs:+ return {"closed": True}+ except Exception:+ pass+ # A never-saved doc can raise Save-As despite save:false - answer it NOW, same call.+ try:+ got = _dismiss_save_prompt_if_bridge_doc()+ if got.get("handled"):+ print("[Fusion Bridge] close(%r): answered the Save prompt inside the same "+ "call (deterministic close, never left on screen)" % n)+ except Exception:+ pass+ _t.sleep(2)+ return {"closed": False, "why": "document still open after %ds" % timeout_s} @@ -10420,31 +10382,20 @@ def _handle_prove_library(fusion_info: dict, args: dict) -> dict: e3["bodiesUnverified"] = True evidence.append(e3) # HAND THE APP BACK (deterministic-close rule): the f3d doc is render scratch- # here. This used an INLINE hasActiveJobs wait + close, which is exactly the- # gate that lied on a fresh box (the query can read False while the Hub sync- # of the just-minted package still runs) - the lose-changes modal reached the- # user's screen from THIS close on an LM2937 run (2026-08-24). Route through- # _close_bridge_doc_verified, which retries the jobs ask AND answers the- # modal (No, upload preserved) if it rises anyway.+ # here; close it verified so stage 4 is not stranded in the wrong workspace.+ # Same upload gate as _close_bridge_doc_verified: closing over an in-flight+ # Hub upload raises the lose-changes modal (John saw it live, 2026-08-21). try:- _di3 = _proxy_to_addin("document_info", {}, timeout=8)- _n3 = str(((_di3.get("data") or {}).get("activeDocument")) or "")- if not _n3:- raise Exception("no active doc name")- _note_bridge_doc(_n3)- _close_bridge_doc_verified(_n3, timeout_s=30)+ _proxy_to_addin("run_modeling_script",+ {"script": "import adsk.core, time\n"+ "app=adsk.core.Application.get()\n"+ "for _ in range(24):\n"+ " if not app.hasActiveJobs: break\n"+ " time.sleep(5)\n"+ "app.activeDocument.close(False)\nresult={}"},+ timeout=140) except Exception:- try:- _proxy_to_addin("run_modeling_script",- {"script": "import adsk.core, time\n"- "app=adsk.core.Application.get()\n"- "for _ in range(24):\n"- " if not app.hasActiveJobs: break\n"- " time.sleep(5)\n"- "app.activeDocument.close(False)\nresult={}"},- timeout=140)- except Exception:- pass+ pass else: evidence.append({"step": "package3d", "view": "3d-editor", "ok": False, "error": "Could not open the bound f3d (%s): %s" % (