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)
9 files changed
+31−235
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.202+1.9.203
adom-bridge-fusion-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "fusion360",- "version": "1.9.202",- "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.202/adom-bridge-fusion-v1.9.202.zip",- "sha256": "eba8083cf16b8a6001c6c4a7cf8f9424ae3c78d9fc2ff738bf873391a0bbf11c",- "size": 495989,+ "version": "1.9.203",+ "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.203/adom-bridge-fusion-v1.9.203.zip",+ "sha256": "765dd1ed08bb3a373fff1224943930c8c20262d6b13aa0a4054b3b83add96a61",+ "size": 495992, "verbPrefixes": [ "fusion_" ],
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.202",+ "version": "1.9.203", "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.202"+version = "1.9.203" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.202"+version = "1.9.203" edition = "2021" [dependencies]
dashboard/src/main.rs+10−217@@ -1419,51 +1419,6 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) { } } }- ("POST", "/api/say-send") => {- // Send to AI (say-strip contract, John's 2026-08-24 ruling): NEW threads only.- // POST /ai-threads/new (staged name + dock tag) then run-prompt submit:true,- // through the workspace control API named in ~/.adom/hydrogen-control-url.- // Resume/existing threads stay parked; this never targets a census tid.- // ⛔ EMERGENCY PARK (project-c3, 2026-08-24): the fleet Send-to-AI success- // path CRASHED the ah dev shell during kicad's cold-verify (the historical- // 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 = 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,- "why": "Send to AI is parked fleet-wide (a run-prompt crash on ah is being certified). Copied instead - paste it into any AI thread."}));- } else if phrase.is_empty() {- respond_json(&mut stream, &serde_json::json!({"ok": false, "why": "phrase required"}));- } else {- respond_json(&mut stream, &say_send(&phrase));- }- }- ("POST", "/api/say-try") => {- // "Say it" strip Try-it (dock-bar `say` contract, John 2026-08-23: teach users to- // ASK for the app by its noun). Stages the phrase into this app's own AI thread- // via the Hydrogen control API - submit:false, so the USER presses Enter; we never- // run a prompt on their behalf. Reachable on 127.0.0.1:47084 from WSL2 (mirrored- // networking); in a cloud container it is not, and the UI falls back to copy.- // SHIP-NIGHT PARK, SERVER HALF (dock-bar contract, project-a6 2026-08-23):- // while parked, refuse BEFORE any control-API call - a direct POST (curl, a- // script, another AI) must not be able to reach run-prompt and wake or crash- // ah's parked AI backend. Both halves flip together when the all-clear lands:- // this const and SAY_TRY_ENABLED in ui/index.html.- const SAY_TRY_PARKED: bool = true;- if SAY_TRY_PARKED {- respond_json(&mut stream, &serde_json::json!({"ok": false, "parked": true,- "why": "Try-it is parked fleet-wide until the ah AI-threads typing fix is certified (dock-bar ship-night posture). Copy the phrase and paste it to your AI instead."}));- } else {- let phrase = body["phrase"].as_str().unwrap_or("").to_string();- if phrase.is_empty() {- respond_json(&mut stream, &serde_json::json!({"ok": false, "why": "phrase required"}));- } else {- respond_json(&mut stream, &stage_say_phrase(&phrase));- }- }- } ("POST", "/ui/click") => { // CLI-driven front-end click: the page clicks its own control (same path a user takes). let target = body["target"].as_str().unwrap_or("").to_string();@@ -1595,181 +1550,19 @@ fn version_at_least(have: &str, want: &str) -> bool { 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)"}),- };- // 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());- // SHAPE (measured live 2026-08-25, NOT assumed): /updater/status answers- // {ok, _hints, updater:{current_version, latest_version, ...}} - current_version is- // NESTED. I first read it top-level and my own mock returned it top-level too, so the- // mock cheerfully confirmed the bug: a mock written from your own assumption cannot- // falsify that assumption. Accept either shape so a future move does not re-break it.- let shell_ver = status.as_ref().and_then(|v| {- v["updater"]["current_version"].as_str()- .or_else(|| 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": "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"}),- };- 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.- // delivered_to may sit at the top level or under result/data - I assumed top-level for- // current_version and was wrong, so accept the variants rather than guess again.- // delivered_to IS AN ARRAY (measured live 2026-08-25: {"adopted_empty_tab":true,- // "delivered_to":[{...}]}), because it lists the on-disk transcript(s) that GAINED this- // prompt text - the panel's own _hints call it "a diff, not a guess", hence plural.- // I first read it as a string, so a delivery that SUCCEEDED was reported as a failure -- // the most dangerous shape of wrong, because the honest-looking retry it invites is a- // double-send into someone's editor. Accept array, string, or nested.- let dt = {- let cands = [&resp["delivered_to"], &resp["result"]["delivered_to"],- &resp["data"]["delivered_to"], &resp["delivered_in_conversation"]];- cands.iter().find_map(|v| match *v {- serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),- serde_json::Value::Array(a) if !a.is_empty() => Some(- a.iter().map(|e| match e {- serde_json::Value::String(s) => s.clone(),- other => other["sid"].as_str()- .or_else(|| other["id"].as_str())- .or_else(|| other["path"].as_str())- .map(|x| x.to_string())- .unwrap_or_else(|| other.to_string().chars().take(80).collect()),- }).collect::<Vec<_>>().join(", ")),- _ => None,- })- };- match dt.as_deref() {- Some(d) if !d.is_empty() => serde_json::json!({"ok": true, "deliveredTo": d,- "adoptedEmptyTab": resp["adopted_empty_tab"].as_bool()- .or_else(|| resp["result"]["adopted_empty_tab"].as_bool()).unwrap_or(false)}),- // FAIL WITH THE EVIDENCE. A bare "no delivered_to" tells the next reader nothing and- // tempts a re-send to find out, which is exactly the double-delivery hazard. Carry a- // truncated copy of what the panel actually said so the shape is diagnosable WITHOUT- // sending a second prompt into someone's editor.- _ => {- let raw: String = resp.to_string().chars().take(400).collect();- serde_json::json!({"ok": false, "panelResponse": raw,- "why": "the panel accepted the call but did not confirm delivery (no delivered_to in any known field) - copied instead; panelResponse carries what it actually returned"})- }- }-}--fn stage_say_phrase(phrase: &str) -> serde_json::Value {- const TAG: &str = "app:adom/fusion-bridge";- // 1. CENSUS: open threads. Shape: {"ok":true,"groups":[{"threads":[{"tid","sid"|null,"tags":[...]}]}]}- let census = hy_get("/ai-threads");- if census.is_none() {- return serde_json::json!({"ok": false,- "why": "Hydrogen control API unreachable (not WSL2/mirrored networking?)"});- }- let census = census.unwrap();- let open_row = census["groups"].as_array().into_iter().flatten()- .flat_map(|g| g["threads"].as_array().cloned().unwrap_or_default())- .find(|t| t["tags"].as_array().map_or(false,- |tags| tags.iter().any(|x| x.as_str() == Some(TAG))));- if let Some(row) = open_row {- // Address by sid when present; tid when sid is null (thread open but no session yet).- let mut payload = serde_json::json!({"prompt": phrase, "submit": false});- if let Some(sid) = row["sid"].as_str() {- payload["sid"] = serde_json::json!(sid);- } else if let Some(tid) = row["tid"].as_str() {- payload["tid"] = serde_json::json!(tid);- } else {- return serde_json::json!({"ok": false, "why": "census row has neither sid nor tid"});- }- return match hy_post("/ai-threads/run-prompt", &payload) {- Some(r) if r["ok"].as_bool() != Some(false) =>- serde_json::json!({"ok": true, "staged": true, "via": "census",- "_hint": "phrase staged in the open app thread's input - the user presses Enter"}),- _ => serde_json::json!({"ok": false, "why": "run-prompt failed on the open thread"}),- };- }- // 2. HISTORY: closed threads. Shape: {"ok":true,"items":[{"ai","id","title","updated_ms"}]}- let hist = hy_get(&format!("/ai-threads/history?tag={}", TAG));- if let Some(item) = hist.as_ref()- .and_then(|h| h["items"].as_array())- .and_then(|a| a.first()) {- let (ai, id) = (item["ai"].as_str().unwrap_or("claude"),- item["id"].as_str().unwrap_or(""));- if !id.is_empty() {- // Resume reopens the thread tab - the user clicked Try, so that IS their intent.- let resumed = hy_post("/ai-threads/resume", &serde_json::json!({"ai": ai, "id": id}));- if resumed.map_or(false, |r| r["ok"].as_bool() != Some(false)) {- let staged = hy_post("/ai-threads/run-prompt",- &serde_json::json!({"sid": id, "prompt": phrase, "submit": false}));- if staged.map_or(false, |r| r["ok"].as_bool() != Some(false)) {- return serde_json::json!({"ok": true, "staged": true, "via": "history+resume",- "_hint": "reopened the app thread and staged the phrase - the user presses Enter"});- }- }- return serde_json::json!({"ok": false,- "why": "app thread exists but is closed and resume/stage failed"});- }- }- // 3. Neither: honest copy fallback.- serde_json::json!({"ok": false,- "why": "no AI thread tagged app:adom/fusion-bridge yet - open the app from the dock once"})-}+/// NOTE (2026-08-25): this app no longer implements sending. The shared newbie-panel+/// module owns the whole path - version gate, one-call composite, and the THREE-state+/// delivered_to reading - and our server only PROXIES /updater/status and+/// /ai-threads/run-prompt through unchanged. The old /api/say-send reshaped the panel's+/// reply into {ok, deliveredTo}, which collapses "delivered" and "opened but unconfirmed"+/// into one failure and invites the retry that double-sends. Deleted rather than kept+/// beside the module, for the same reason the hand-rolled CSS was. /// 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.+/// update leaves its predecessor alive: John's laptop accumulated several (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")
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.202"+DASH_VERSION="1.9.203" 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.202",+ "version": "1.9.203", "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.202",+ "version": "1.9.203", "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]"- }-}+ ]+}