← Commit history

v1.6.8: stale add-in detection (#55) + SDK re-audit (verbs sync, 8 conventions)

John Lauer ·aeadd901b0 ·3mo ago ·parent 148a8fd
9 files changed +194−65
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.6.7\ No newline at end of file+1.6.8\ No newline at end of file
CHANGELOG.md+16
@@ -3,6 +3,22 @@ All notable changes to the Adom Desktop Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/adom-desktop-fusion-bridge · GitHub backup: `adom-inc/adom-desktop-fusion-bridge` +## 1.6.8 - 2026-06-30++- **Stale add-in is no longer a SILENT failure (fixes #55, Drew's report).** The add-in now REPORTS+  its version (`AdomBridge.manifest` → `/health` + `/status`), and the bridge compares it to the+  version it bundles. `fusion_readiness` and `fusion_addin_status` now return `addinVersion` /+  `expectedAddinVersion` / `addinStale` (+ a re-sync `_hint`), and any add-in command the running+  add-in is too old to know (e.g. `open_by_urn`, which silently broke `fusion_aps_open` for Drew) now+  comes back with `errorCode:"addin_stale"` and the exact fix (`fusion_stop` then `fusion_start` to+  re-sync from cache with Fusion closed). Add-in manifest bumped 1.0.2 → 1.0.3.+- **Bridge-SDK re-audit against the updated SDK.** (1) `bridge.json` `verbs[]` re-synced to the+  authoritative `fusion_describe` catalog (was stale: 40 → 53 verbs; added the library/3D + board ++  app-state verbs; `statusVerb` `fusion_get_app_state` is now listed; dropped the dead+  `fusion_apply_instapcb_rules`). (2) The USER skill's cold-start section now encodes all **8** SDK+  conventions (was 5), led by an `errorCode` response→meaning→action table (incl. the new+  `addin_stale` row) and the reuse-don't-relaunch + surface-`_hint`-verbatim conventions.+ ## 1.6.7 - 2026-06-30  - **AD 1.9.63 alignment (discussion #57).** (1) The bridge HTTP server now honors **`ADOM_BIND_HOST`**
SKILL.md+24−15
@@ -14,22 +14,31 @@ adom-desktop fusion_<action> '<json_args>'  The leading `fusion_` routes to the Fusion plugin; `<action>` is the plugin-side name. -## Read this before you panic (cold-start + conventions)+## FIRST-TIME / COLD-START — read before you panic -1. **Check readiness first, don't launch blindly.** Call **`fusion_readiness`** (or AD's-   `bridge_readiness`) - it reports `installed` / `running` / `ready` WITHOUT launching anything.-   `installed:false` → the user needs Fusion (see [fusion-onboarding](skills/fusion-onboarding/SKILL.md));-   AD **detects** Fusion (never installs it) and **provisions Python itself** (a portable no-UAC-   3.12.13 runtime if none is on PATH - never bootstrap your own; check AD's `runtimes` verb for-   state). `running:false` → `fusion_start` (it blocks until the add-in is ready). `ready:true` → drive.-2. **A long op that "times out" at ~60s is usually STILL RUNNING server-side** (the relay caps the-   request, not the work). Verify by artifacts (the saved file / screenshots), don't blindly retry.-3. **Never fly blind past a dialog.** Mutating verbs return a dialog array; analyze it and dismiss in-   the BACKGROUND (`fusion_close_window`, never `fusion_send_key`/Escape which steals focus). See-   [fusion-driving](skills/fusion-driving/SKILL.md).-4. **Never write files to a shared cloud ROOT.** Uploads default to an `Adom AI Workspace` folder; see-   [fusion-cloud-hygiene](skills/fusion-cloud-hygiene/SKILL.md).-5. Read each verb's `_hint` in its OUTPUT - it tells you the next step.+A fresh laptop may not have Fusion running (or may run a stale add-in). That is EXPECTED, not defeat.+Branch on the stable `errorCode`, never the prose:++| response (`errorCode`) | what it means | what you do |+|---|---|---|+| `ready:true` (fusion_readiness) | Fusion up + add-in current | drive |+| `running:false` / `fusion_not_running` | installed, not launched | `fusion_start` (blocks until the add-in is ready), retry |+| `installed:false` | no Fusion host app | [fusion-onboarding](skills/fusion-onboarding/SKILL.md); AD DETECTS Fusion (never installs it) |+| `addinStale:true` / `addin_stale` | Fusion runs an OLD add-in that never re-synced (silent-failure risk) | `fusion_stop` then `fusion_start` — the bridge re-syncs the add-in from cache on start (Fusion must be CLOSED) |+| `blockingDialogs:[…]` | a modal is blocking the add-in | dismiss in the BACKGROUND, then the op auto-resumes |++**Runtime note:** AD **provisions Python itself** (a portable no-UAC 3.12.13 runtime if none is on PATH — the bridge never bootstraps its own; check AD's `runtimes` verb). You never install Python.++## The 8 conventions (from the Bridge SDK)++1. **Poll readiness, don't launch blindly.** `fusion_readiness` (or AD's `bridge_readiness`) is read-only — it reports `installed`/`running`/`ready`/`addinStale` WITHOUT launching anything. Loop: readiness → act on the row above → drive.+2. **`fusion_readiness` is the standardized cold-start probe.** (Fusion is a detected HOST APP with no heavy download, so there's no `prewarm` step — readiness is the whole gate.)+3. **Core verbs live in the table below; `fusion_describe '{}'` is the full machine-readable catalog.** Read the table to act fast; hit `describe` for completeness — don't guess a verb.+4. **Background by default — don't disrupt the user.** This bridge drives the AI's workspace, not the user's screen. Dismiss dialogs in the BACKGROUND (`fusion_close_window`, never `fusion_send_key`/Escape, which steals focus); only foreground to SHOW the user something. See [fusion-driving](skills/fusion-driving/SKILL.md).+5. **`ok:true` is not enough — verify.** A long op that "times out" at ~60s is usually STILL RUNNING server-side (the relay caps the request, not the work). Verify by artifacts (the saved file / a screenshot / `fusion_get_app_state`), don't blindly retry.+6. **Reuse the running Fusion, don't relaunch.** Fusion is a singleton — `fusion_readiness` before `fusion_start` (especially after a compaction); if it's already `ready`, just drive. Relaunching churns state.+7. **Surface `_hint` verbatim + branch on `errorCode`.** Every verb returns an actionable `_hint`; relay it to the user unchanged and key your logic off the stable `errorCode`, not the prose.+8. **Never write files to a shared cloud ROOT.** Uploads default to an `Adom AI Workspace` folder; see [fusion-cloud-hygiene](skills/fusion-cloud-hygiene/SKILL.md).  ## [!] Electronics PCBs: ALWAYS open the PROJECT file (never the schematic / board / 3D) 
addin/AdomBridge/AdomBridge.manifest+1−1
@@ -6,7 +6,7 @@     "description": {         "": "Adom Desktop Conduit bridge add-in. Exposes a localhost HTTP API for external automation of Fusion 360."     },-    "version": "1.0.2",+    "version": "1.0.3",     "runOnStartup": true,     "supportedOS": "windows" }
addin/AdomBridge/http_server.py+19−1
@@ -20,6 +20,7 @@ Threading model: """  import json+import os import threading import time import uuid@@ -31,6 +32,21 @@ from .commands.cloud_documents import get_walk_progress  PORT = 8774 +# Add-in version — read from the sibling manifest so /health + /status can+# REPORT it. The bridge compares this against the version it bundles to catch a+# STALE add-in (issue #55: an old Roaming add-in that never re-synced from the+# bridge cache, which made fusion_aps_open fail SILENTLY). Sourced from the+# manifest so a single bump is the source of truth (no hardcode to drift).+def _read_addin_version():+    try:+        mf = os.path.join(os.path.dirname(__file__), "AdomBridge.manifest")+        with open(mf, "r", encoding="utf-8") as f:+            return (json.load(f) or {}).get("version", "unknown")+    except Exception:+        return "unknown"++ADDIN_VERSION = _read_addin_version()+ _server = None _server_thread = None _custom_event_id = None@@ -256,6 +272,7 @@ class AdomBridgeHandler(BaseHTTPRequestHandler):             self._respond(200, {                 "status": "ok",                 "addin": "AdomBridge",+                "version": ADDIN_VERSION,                 "port": PORT,                 "main_thread": main_status,                 "main_thread_locked": main_lock_held,@@ -274,6 +291,7 @@ class AdomBridgeHandler(BaseHTTPRequestHandler):                 elapsed = round(time.time() - busy["started_at"], 1)                 resp = {                     "busy": True,+                    "version": ADDIN_VERSION,                     "busyCommand": busy["command"],                     "elapsedSeconds": elapsed,                     "requestId": busy["request_id"],@@ -295,7 +313,7 @@ class AdomBridgeHandler(BaseHTTPRequestHandler):                     )                 self._respond(200, resp)             else:-                self._respond(200, {"busy": False})+                self._respond(200, {"busy": False, "version": ADDIN_VERSION})         else:             self._respond(404, {"error": "Not found"}) 
adom-bridge-fusion-manifest.json+5−5
@@ -1,13 +1,13 @@ {   "manifest_version": 1,   "name": "fusion360",-  "version": "1.6.7",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.6.7/adom-bridge-fusion-v1.6.7.zip",-  "sha256": "c9aa67c269f6da763d08ded74a33eb5e120a5e1894eed2989964793a892613ee",-  "size": 294279,+  "version": "1.6.8",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.6.8/adom-bridge-fusion-v1.6.8.zip",+  "sha256": "9dc5bed4d8a7db0dbdc7fc4863c42d12b95b0747e9081dbb8ebd4cb6661428da",+  "size": 297014,   "verbPrefixes": [     "fusion_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-06-30T16:30:00Z"+  "released_at": "2026-06-30T17:15:00Z" }\ No newline at end of file
bridge.json+47−33
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.6.7",+  "version": "1.6.8",   "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/adom-desktop-fusion-bridge",   "author": "Adom Inc.",@@ -20,45 +20,59 @@     "fusion_"   ],   "verbs": [-    "fusion_describe",-    "fusion_readiness",-    "fusion_start",-    "fusion_kill",-    "fusion_stop",-    "fusion_close_window",-    "fusion_apply_instapcb_rules",-    "fusion_load_design_rules",-    "fusion_export_step",-    "fusion_export_iges",-    "fusion_export_stl",-    "fusion_export_3mf",-    "fusion_export_usdz",-    "fusion_export_obj",-    "fusion_export_dxf",-    "fusion_export_dwg",-    "fusion_export_gerbers",-    "fusion_walk_cloud_tree",-    "fusion_search_cloud_files",-    "fusion_dismiss_blocking_dialogs",-    "fusion_aps_status",-    "fusion_aps_set_client_id",-    "fusion_aps_signin",-    "fusion_aps_search",-    "fusion_aps_get",     "fusion_aps_browse",+    "fusion_aps_create_folder",+    "fusion_aps_download",+    "fusion_aps_file_info",+    "fusion_aps_get",     "fusion_aps_open",-    "fusion_open_by_urn",-    "fusion_run_modeling_script",     "fusion_aps_recent",-    "fusion_aps_file_info",+    "fusion_aps_search",+    "fusion_aps_set_client_id",+    "fusion_aps_signin",+    "fusion_aps_status",     "fusion_aps_upload",-    "fusion_aps_create_folder",-    "fusion_aps_download",     "fusion_aps_versions",-    "fusion_electron_zoom",+    "fusion_attach_3d_package",+    "fusion_board_info",+    "fusion_build_library_3d",+    "fusion_capture_library_views",+    "fusion_cleanup_cloud_files",+    "fusion_close_window",+    "fusion_describe",+    "fusion_detect_layers",+    "fusion_dismiss_blocking_dialogs",     "fusion_electron_pan",+    "fusion_electron_run",     "fusion_electron_select",-    "fusion_show_schematic"+    "fusion_electron_zoom",+    "fusion_export_3mf",+    "fusion_export_bom",+    "fusion_export_cpl",+    "fusion_export_dwg",+    "fusion_export_dxf",+    "fusion_export_gerbers",+    "fusion_export_iges",+    "fusion_export_obj",+    "fusion_export_step",+    "fusion_export_stl",+    "fusion_export_usdz",+    "fusion_get_app_state",+    "fusion_kill",+    "fusion_load_design_rules",+    "fusion_make_3d_package",+    "fusion_open_by_urn",+    "fusion_open_lbr",+    "fusion_readiness",+    "fusion_run_modeling_script",+    "fusion_search_cloud_files",+    "fusion_set_design_rules",+    "fusion_show_2d_board",+    "fusion_show_3d_board",+    "fusion_show_schematic",+    "fusion_start",+    "fusion_stop",+    "fusion_walk_cloud_tree"   ],   "dependencies": {     "python": ">=3.11",
page.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-desktop-fusion-bridge",-  "version": "1.6.8",+  "version": "1.6.9",   "type": "app",   "description": "Drive Autodesk Fusion 360 from the cloud via Adom Desktop: 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 with browse/recent/versions, cloud download/upload and folder creation, and in-app parametric modeling. Never-charge: APS calls are capped to the free tier.",   "dependencies": {},
server.py+80−8
@@ -47,6 +47,46 @@ try: except Exception:     BRIDGE_VERSION = "unknown" +# The add-in version this bridge BUNDLES (the one install_addin syncs into+# Fusion's Roaming AddIns dir). Read from our bundled manifest so it can't drift+# from a hardcode. Compared against the RUNNING add-in's reported version to+# catch a stale add-in (issue #55) - the running copy only re-syncs when Fusion+# is closed on bridge start, so a user who never restarted Fusion keeps an old+# add-in and commands like fusion_aps_open fail silently. We make it LOUD.+try:+    EXPECTED_ADDIN_VERSION = (json.loads(+        (Path(__file__).parent / "addin" / "AdomBridge" / "AdomBridge.manifest").read_text(encoding="utf-8")+    ) or {}).get("version", "unknown")+except Exception:+    EXPECTED_ADDIN_VERSION = "unknown"+++def _addin_staleness(reported_version) -> dict:+    """Compare the running add-in's reported version to the bundled one.++    Returns {addinVersion, expectedAddinVersion, addinStale, _staleHint?}. A+    stale add-in is the #55 silent-failure: re-sync it by RESTARTING Fusion+    (fusion_stop then fusion_start) so the bridge's on-start install_addin can+    overwrite the Roaming copy (which is file-locked while Fusion runs)."""+    info = {+        "addinVersion": reported_version or "unknown",+        "expectedAddinVersion": EXPECTED_ADDIN_VERSION,+    }+    stale = (+        reported_version not in (None, "unknown")+        and EXPECTED_ADDIN_VERSION not in (None, "unknown")+        and str(reported_version) != str(EXPECTED_ADDIN_VERSION)+    )+    info["addinStale"] = bool(stale)+    if stale:+        info["_staleHint"] = (+            f"STALE add-in: Fusion is running add-in v{reported_version} but this bridge "+            f"bundles v{EXPECTED_ADDIN_VERSION}. Newer verbs (e.g. fusion_aps_open/open_by_urn) "+            f"can fail silently. Fix: fusion_stop then fusion_start - the bridge re-syncs the "+            f"add-in from its cache on start (only possible with Fusion CLOSED)."+        )+    return info+ # Populated at startup fusion_info = None @@ -568,6 +608,25 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict:     installed = bool(fusion_info.get("installed"))     running = _is_fusion_running() if installed else False     ready = installed and running++    # When Fusion is up, also check the add-in isn't STALE (issue #55) so a fresh+    # readiness call surfaces the mismatch instead of a later verb failing silently.+    stale_info = {}+    if running:+        addin_status = _check_addin_status(timeout=1.0)+        if addin_status is not None:+            stale_info = _addin_staleness(addin_status.get("version"))++    if ready and stale_info.get("addinStale"):+        hint = stale_info["_staleHint"]+    elif ready:+        hint = "READY - drive Fusion (fusion_open_lbr / build_library_3d / etc.)."+    elif installed:+        hint = "Fusion is INSTALLED but not running - call fusion_start (blocks until the add-in is ready), then retry."+    else:+        hint = ("Fusion 360 is NOT installed. AD detects (never installs) the host app via bridge.json 'detect'; "+                "ask the user to install Fusion from autodesk.com. (AD provisions Python itself - no bridge bootstrap.)")+     return {         "success": True,         "hostApp": "Fusion 360",@@ -575,13 +634,8 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict:         "running": running,         "ready": ready,         "bridgeVersion": BRIDGE_VERSION,-        "_hint": (-            "READY - drive Fusion (fusion_open_lbr / build_library_3d / etc.)." if ready else-            ("Fusion is INSTALLED but not running - call fusion_start (blocks until the add-in is ready), then retry."-             if installed else-             "Fusion 360 is NOT installed. AD detects (never installs) the host app via bridge.json 'detect'; "-             "ask the user to install Fusion from autodesk.com. (Python is auto-installed by AD if missing.)")-        ),+        **stale_info,+        "_hint": hint,     }  @@ -917,7 +971,12 @@ def _check_addin_status(timeout: float = 3.0) -> dict | None: def _handle_addin_status() -> dict:     """Bridge-level handler for addin_status — wraps /status in standard format."""     status = _check_addin_status(timeout=3.0) or {"busy": False}-    return {"success": True, "output": json.dumps(status), **status}+    stale = _addin_staleness(status.get("version"))+    status = {**status, **stale}+    result = {"success": True, "output": json.dumps(status), **status}+    if stale.get("addinStale"):+        result["_hint"] = stale["_staleHint"]+    return result   def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict:@@ -943,6 +1002,19 @@ def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict:                 and "timed out" in result.get("error", "").lower()):             return _diagnose_addin_timeout(command, result) +        # If the add-in rejected the command as unknown/unsupported, it may be a+        # STALE add-in that predates this verb (issue #55 - Drew's silent failure:+        # old add-in had no open_by_urn). Enrich with a version comparison + the+        # re-sync hint + a stable errorCode so the failure is LOUD, not silent.+        if not result.get("success"):+            err = (result.get("error") or "").lower()+            if any(s in err for s in ("unknown command", "no such command", "not supported", "unsupported command")):+                stale = _addin_staleness((_check_addin_status(timeout=1.0) or {}).get("version"))+                if stale.get("addinStale"):+                    result["errorCode"] = "addin_stale"+                    result["_hint"] = stale["_staleHint"]+                    result.update({k: stale[k] for k in ("addinVersion", "expectedAddinVersion", "addinStale")})+         return result      except urllib.error.URLError as e: