← Commit history

v1.6.3: cloud folder hygiene (Adom AI Workspace, never root) + precise urn cleanup

John Lauer ·9c0b6e6b63 ·3mo ago ·parent dfd28c0
9 files changed +230−23
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.6.2\ No newline at end of file+1.6.3\ No newline at end of file
CHANGELOG.md+12
@@ -3,6 +3,18 @@ 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.3 - 2026-06-29++- **Cloud folder hygiene - the bridge NEVER writes loose files to a project ROOT anymore.** Root cause+  of a real incident: `fusion_make_3d_package` defaulted its upload to the shared Adom project ROOT, so+  a big build dumped 100+ loose f3d files into the team's root and colleagues complained. Now uploads+  default to an AI-owned **`Adom AI Workspace`** folder with a **per-task subfolder**+  (`build_library_3d` derives the task from the .lbr name), and `fusion_aps_upload` **REFUSES** a known+  root folderId (`errorCode: refused_root_upload`). New **`fusion-cloud-hygiene`** skill.+- **NEW `fusion_cleanup_cloud_files`** - precisely delete a list of cloud files **by lineage urn** (not+  by name), so cleaning up AI clutter can't touch a teammate's file in a shared folder. The add-in+  delete gained a `fileId`/`lineageUrn` exact-match path.+ ## 1.6.2 - 2026-06-29  - **`fusion_capture_library_views` settle fix.** It fired the screenshot ~0.4s after `EDIT`, before
addin/AdomBridge/commands/cloud_documents.py+9−3
@@ -526,8 +526,9 @@ def handle_delete_cloud_file(app: adsk.core.Application, args: dict) -> dict:             - folderPath: Subfolder path (optional, defaults to root folder).     """     file_name = args.get("fileName", "").strip()-    if not file_name:-        return {"success": False, "error": "No fileName specified."}+    file_id = (args.get("fileId") or args.get("lineageUrn") or "").strip()  # PRECISE delete by id+    if not file_name and not file_id:+        return {"success": False, "error": "Provide fileName or fileId (lineage urn for a precise delete)."}      project_name = args.get("projectName", "")     folder_path = args.get("folderPath", "")@@ -552,6 +553,7 @@ def handle_delete_cloud_file(app: adsk.core.Application, args: dict) -> dict:     target = None     started = time.time()     needle = file_name.lower()+    id_needle = file_id  # exact lineage-urn match when provided (safe in shared folders)     try:         try:             data_files = folder.dataFiles@@ -574,7 +576,11 @@ def handle_delete_cloud_file(app: adsk.core.Application, args: dict) -> dict:                 }             try:                 df = data_files.item(i)-                if df.name.lower() == needle:+                if id_needle:+                    if df.id == id_needle:+                        target = df+                        break+                elif df.name.lower() == needle:                     target = df                     break             except Exception:
adom-bridge-fusion-manifest.json+5−5
@@ -1,13 +1,13 @@ {   "manifest_version": 1,   "name": "fusion360",-  "version": "1.6.2",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.6.2/adom-bridge-fusion-v1.6.2.zip",-  "sha256": "a3f9922f29273ca0b24eab7a0cd744fd3aa5b84a7c3439f2223b4b7066e97657",-  "size": 285138,+  "version": "1.6.3",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.6.3/adom-bridge-fusion-v1.6.3.zip",+  "sha256": "27aab4981320faddb6828e69d70f4ebcb0b082ab2d38f2a0a5af5b855a1ee363",+  "size": 288303,   "verbPrefixes": [     "fusion_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-06-29T19:30:00Z"+  "released_at": "2026-06-29T20:30:00Z" }\ No newline at end of file
aps.py+10
@@ -941,6 +941,16 @@ def handle_upload(args: dict) -> dict:     project_id, folder_id, local = args.get("projectId"), args.get("folderId"), args.get("localPath")     if not (project_id and folder_id and local):         return {"success": False, "error": "Provide projectId, folderId, localPath."}+    # FOLDER HYGIENE: never dump loose files into a shared project ROOT (see fusion-cloud-hygiene).+    _KNOWN_ROOT_FOLDERS = {"urn:adsk.wipprod:fs.folder:co.jyO4vxQXR6S9zFpTQWnZAg"}+    if folder_id in _KNOWN_ROOT_FOLDERS:+        return {"success": False, "errorCode": "refused_root_upload",+                "error": "REFUSED: that folderId is a shared project ROOT - the bridge never writes "+                         "loose files there (it clutters the team's cloud).",+                "_hint": "Create/choose a real subfolder first (fusion_aps_create_folder under an "+                         "'Adom AI Workspace' folder), or just call fusion_make_3d_package / "+                         "fusion_build_library_3d, which now default to a non-root workspace folder. "+                         "See the fusion-cloud-hygiene skill."}     if not os.path.isfile(local):         return {"success": False, "error": f"Local file not found: {local}"}     fname = args.get("fileName") or os.path.basename(local)
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.6.2",+  "version": "1.6.3",   "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://github.com/adom-inc/adom-desktop-fusion-bridge",   "author": "Adom Inc.",
describe.py+3
@@ -86,6 +86,9 @@ _T = [     ("fusion_capture_library_views", "Capture the LIBRARY-EDITOR views EEs trust: the schematic SYMBOL (full pinout), the FOOTPRINT (pads + layer stack), and the COMPONENT/device view (Content Manager: symbol + the package table with the footprint<->package Mapped check + pin/pad counts). The 3D before/after shots don't show these. Requires the .lbr OPEN in the Electronics Library workspace (fusion_open_lbr first). Runs EDIT <pkg>.sym/.pac/.dev + WINDOW FIT + a background hwnd screenshot per view; returns the PNG paths on the box (C:/tmp/conduit-screenshots - pull with desktop_pull_file). EDIT uses the DEVICESET name (e.g. ESP32-S3FN8).",      {"packages": "required [<deviceset name>] (or 'package': single name)", "views": "optional subset of ['component','symbol','footprint'] (default all)", "settle": "optional float secs after each EDIT before capture (default 1.5; raise if a shot shows the PREVIOUS part)"},      {"success": "bool", "captured": "[{package,view,path,ok}]"}, 300, _STATUS, True, {"packages": ["ESP32-S3FN8", "ATSAMD51J20A-AUT"]}),+    ("fusion_cleanup_cloud_files", "PRECISELY delete a list of cloud files by lineage urn - SAFE cleanup of AI-created clutter. Deletes ONLY the exact fileIds given (never name-guessing), so it can't touch a teammate's file in a shared folder. Loops server-side. Get the ids from fusion_aps_browse (item .id). Use this to remove f3d files the bridge created. A big list takes minutes (relay may time out at ~60s while deletes continue server-side - re-browse to confirm).",+     {"fileIds": "required [<lineage urn>]", "projectName": "optional str (default active project)", "folderPath": "optional str (default root - where the files live)"},+     {"success": "bool", "deletedCount": "int", "failedCount": "int", "failed": "[{fileId,error}]"}, 1800, _STATUS, True, {"fileIds": ["urn:adsk.wipprod:dm.lineage:abc123"], "projectName": "Adom"}),     ("fusion_run_modeling_script", "Run an adsk.fusion modeling script in the live session (sketches/extrudes/params). Free programmatic CAD.",      {"script": "required str (Python using adsk; globals: adsk, app, ui, design)"},      {"result": "obj", "documentName": "str"}, 180, None, True, {"script": "import adsk.fusion\n# ...build geometry..."}),
server.py+132−13
@@ -1216,19 +1216,78 @@ def _orchestrate_attach_3d_package(args: dict) -> dict:     }  -# Default APS upload target for 3D-package f3d uploads (the MAIN Adom project + its-# bridge-uploads folder). Used when build_library_3d / make_3d_package are called WITHOUT-# an explicit projectId/folderId, so the AI never has to browse for them. Override by-# passing projectId/folderId.-_DEFAULT_UPLOAD_PROJECT = "a.YnVzaW5lc3M6YWRvbTMjMjAyMzExMjk3MDM5NjAzMzE"-_DEFAULT_UPLOAD_FOLDER = "urn:adsk.wipprod:fs.folder:co.jyO4vxQXR6S9zFpTQWnZAg"+# ── Fusion cloud FOLDER HYGIENE (never write loose files to a shared project ROOT) ───────────+# Hard lesson (2026-06-29): defaulting uploads to a project's ROOT folder dumped 100+ loose f3d+# files into the shared Adom team root and other employees complained. RULE: the bridge NEVER+# writes a file to a project root. It writes into an AI-OWNED "Adom AI Workspace" folder, with a+# per-task SUBfolder, keeping the cloud tidy. See the fusion-cloud-hygiene skill.+_DEFAULT_UPLOAD_PROJECT = "a.YnVzaW5lc3M6YWRvbTMjMjAyMzExMjk3MDM5NjAzMzE"  # the Adom business project+# The shared team ROOT folder of that project - OFF LIMITS for loose files (only the workspace+# folder itself may live here). Known roots we must refuse as a write target.+_KNOWN_ROOT_FOLDERS = {"urn:adsk.wipprod:fs.folder:co.jyO4vxQXR6S9zFpTQWnZAg"}+_AI_WORKSPACE_NAME = "Adom AI Workspace"   # the AI-owned work area (BRAND: "Adom AI", not "Claude")+_ws_folder_cache = {}+++def _safe_folder_name(name: str) -> str:+    import re as _re+    return _re.sub(r'[<>:"/\\|?*]', "", str(name or "")).strip()[:60] or "task"+++def _find_child_folder(project_id: str, parent_id: str, name: str):+    """folderId of a subfolder named `name` directly under parent_id, or None."""+    try:+        r = aps.handle_browse({"projectId": project_id, "folderId": parent_id})+        items = (r.get("data") or {}).get("items", []) if isinstance(r, dict) else []+        for it in items:+            if it.get("type") == "folders" and (it.get("name") or "").strip() == name:+                return it.get("id")+    except Exception:+        pass+    return None+++def _ensure_workspace_folder(project_id: str, task: str = None):+    """Return a folderId inside the AI-owned 'Adom AI Workspace' (NEVER a project root). Creates the+    workspace folder (one tidy folder under the project root) + an optional per-task subfolder if+    missing. Cached per (project, task). Returns None if it cannot be resolved (caller must NOT then+    fall back to root)."""+    task = _safe_folder_name(task) if task else None+    key = (project_id, task or "")+    if key in _ws_folder_cache:+        return _ws_folder_cache[key]+    root = next(iter(_KNOWN_ROOT_FOLDERS))  # parent for the single workspace folder+    ws = _find_child_folder(project_id, root, _AI_WORKSPACE_NAME)+    if not ws:+        cr = aps.handle_create_folder({"projectId": project_id, "parentFolderId": root, "name": _AI_WORKSPACE_NAME})+        ws = (cr.get("data") or {}).get("folderId") if isinstance(cr, dict) and cr.get("success") else None+    folder = ws+    if task and ws:+        sub = _find_child_folder(project_id, ws, task)+        if not sub:+            cr = aps.handle_create_folder({"projectId": project_id, "parentFolderId": ws, "name": task})+            sub = (cr.get("data") or {}).get("folderId") if isinstance(cr, dict) and cr.get("success") else None+        folder = sub or ws+    if folder:+        _ws_folder_cache[key] = folder+    return folder   def _discover_upload_target(args: dict) -> tuple:-    """(projectId, folderId) for f3d uploads: explicit args win, else the known MAIN-project-    upload folder, so the AI never has to fusion_aps_browse for them."""-    return (args.get("projectId") or _DEFAULT_UPLOAD_PROJECT,-            args.get("folderId") or _DEFAULT_UPLOAD_FOLDER)+    """(projectId, folderId) for f3d uploads - ALWAYS a non-root, AI-owned folder.++    Defaults to 'Adom AI Workspace/<task>' (auto-created). If the caller explicitly passes a+    folderId that is a known project ROOT, it is REFUSED (we steer to the workspace instead) - the+    bridge must never write loose files to a shared root. Returns (projectId, folderId|None);+    folderId is None only if the workspace folder could not be created (caller must error, NOT+    fall back to root)."""+    project_id = args.get("projectId") or _DEFAULT_UPLOAD_PROJECT+    fid = args.get("folderId")+    if fid and fid in _KNOWN_ROOT_FOLDERS:+        fid = None  # explicit root -> refuse, steer to workspace+    if not fid:+        fid = _ensure_workspace_folder(project_id, args.get("task"))+    return (project_id, fid)   def _capture_labeled(label) -> str | None:@@ -1319,10 +1378,16 @@ def _orchestrate_make_3d_package(args: dict) -> dict:     import os as _os, time as _time     lbr_path = (args.get("lbrPath") or args.get("filePath") or "").replace("\\", "/")     model_path = (args.get("modelPath") or "").replace("\\", "/")-    project_id, folder_id = _discover_upload_target(args)  # default to the MAIN-project upload folder+    project_id, folder_id = _discover_upload_target(args)  # an AI-owned non-root folder (never project root)     if not lbr_path or not model_path:         return {"success": False,                 "error": "lbrPath (.lbr with the footprint) and modelPath (.step) are required (Windows paths)."}+    if not folder_id:+        return {"success": False, "errorCode": "no_work_folder",+                "error": "Could not resolve a non-root 'Adom AI Workspace' upload folder.",+                "_hint": "The bridge refuses to write to a shared project ROOT. Sign in (fusion_aps_signin) "+                         "so it can create 'Adom AI Workspace', or pass a real (non-root) folderId. NEVER "+                         "pass a project root folderId. See the fusion-cloud-hygiene skill."}     cap_label = args.get("captureLabel")  # when set, capture BEFORE (footprint) + AFTER (chip placed)     orient = args.get("orient", True)     base = _os.path.basename(model_path).rsplit(".", 1)[0]@@ -1451,7 +1516,16 @@ def _orchestrate_build_library_3d(args: dict) -> dict:                          "projectId/folderId default to the MAIN-project upload folder."}     out_path = (args.get("outLbrPath") or combined).replace("\\", "/")     capture = args.get("capture", True)-    project_id, folder_id = _discover_upload_target(args)+    # Put this library's f3d in its OWN task subfolder under 'Adom AI Workspace' (never project root).+    import os as _os2+    task = args.get("task") or _os2.path.basename(combined).rsplit(".", 1)[0]+    project_id, folder_id = _discover_upload_target({**args, "task": task})+    if not folder_id:+        return {"success": False, "errorCode": "no_work_folder",+                "error": "Could not resolve a non-root 'Adom AI Workspace' upload folder.",+                "_hint": "The bridge refuses to write to a shared project ROOT. Sign in "+                         "(fusion_aps_signin) so it can create the workspace folder, or pass a real "+                         "(non-root) folderId. See the fusion-cloud-hygiene skill."}     results = []; bindings = []; shots = []     for p in parts:         pkg = p.get("package")@@ -1600,6 +1674,49 @@ def _orchestrate_capture_library_views(args: dict) -> dict:     }  +def _orchestrate_cleanup_cloud_files(args: dict) -> dict:+    """Precisely delete a LIST of cloud files by lineage urn - SAFE cleanup of AI-created clutter.++    Deletes ONLY the exact `fileIds` (lineage urns) given - never name-guessing, so it cannot touch a+    teammate's file in a shared folder. Loops server-side (one call deletes the whole list). Use this+    to clean up f3d files the bridge created. (Find the ids with fusion_aps_browse on the folder.)++    args: {fileIds: [<lineage urn>, ...], projectName?: str (default active), folderPath?: str+           (default root - where the file lives)}+    """+    file_ids = args.get("fileIds") or []+    if not file_ids:+        return {"success": False, "error": "fileIds [lineage urns] required.",+                "_hint": "Get them from fusion_aps_browse {projectId, folderId} (item .id)."}+    project_name = args.get("projectName", "")+    folder_path = args.get("folderPath", "")+    deleted = []; failed = []+    for fid in file_ids:+        try:+            r = _proxy_to_addin("delete_cloud_file",+                                {"fileId": fid, "projectName": project_name, "folderPath": folder_path},+                                timeout=40)+            if isinstance(r, dict) and r.get("success"):+                deleted.append(fid)+            else:+                failed.append({"fileId": fid, "error": (r.get("error") if isinstance(r, dict) else str(r))[:90]})+        except Exception as e:+            failed.append({"fileId": fid, "error": str(e)[:90]})+    return {+        "success": len(deleted) > 0 or not failed,+        "deletedCount": len(deleted),+        "failedCount": len(failed),+        "failed": failed[:25],+        "_hint": (+            f"Deleted {len(deleted)}/{len(file_ids)} cloud files by PRECISE lineage urn (no name-guessing, "+            "so teammates' files are untouched). NOTE: a large list takes minutes and the relay request "+            "may time out at ~60s while the deletes continue server-side - re-browse the folder to confirm "+            "the count dropped. Anything in failed[] usually means the file was already gone or you lack "+            "delete permission. See the fusion-cloud-hygiene skill."+        ),+    }++ def _orchestrate_save_lbr(args: dict) -> dict:     """Save the currently open Electronics library as a .flbr file. @@ -1669,7 +1786,7 @@ def dispatch_command(command: str, args: dict) -> dict:     # The user should launch Fusion themselves; we just report the status.     # Commands that need Fusion running (add-in commands + orchestrated commands)     _OPEN_WITH_SCREENSHOT = {"open_schematic", "open_board", "show_3d_board", "show_2d_board", "show_schematic", "import_electronics"}-    if command in ADDIN_COMMANDS or command in ("open_lbr", "save_lbr", "attach_3d_package", "make_3d_package", "build_library_3d", "capture_library_views") or command in _OPEN_WITH_SCREENSHOT:+    if command in ADDIN_COMMANDS or command in ("open_lbr", "save_lbr", "attach_3d_package", "make_3d_package", "build_library_3d", "capture_library_views", "cleanup_cloud_files") or command in _OPEN_WITH_SCREENSHOT:         if not fusion_info.get("installed"):             return {                 "success": False,@@ -1779,6 +1896,8 @@ def dispatch_command(command: str, args: dict) -> dict:         return _orchestrate_build_library_3d(args)     if command == "capture_library_views":         return _orchestrate_capture_library_views(args)+    if command == "cleanup_cloud_files":+        return _orchestrate_cleanup_cloud_files(args)      # Add-in proxy commands     if command in ADDIN_COMMANDS:
skills/fusion-cloud-hygiene/SKILL.mdadded+57
@@ -0,0 +1,57 @@+---+name: fusion-cloud-hygiene+description: Keep the Fusion 360 / Autodesk cloud TIDY when the bridge creates files (f3d 3D packages, saved libraries, exports). The cardinal rule - NEVER write loose files into a shared project ROOT folder; always work inside an AI-owned "Adom AI Workspace" folder with a per-task SUBfolder. Covers why (a real incident where 100+ loose f3d files dumped into the shared Adom team root and colleagues complained), how the bridge now enforces it (defaults uploads to a non-root workspace folder, REFUSES a root folderId), and how to clean up AI-created clutter PRECISELY by lineage urn (fusion_cleanup_cloud_files) without ever touching a teammate's file. Read this before any verb that writes to the cloud - fusion_make_3d_package, fusion_build_library_3d, fusion_aps_upload, fusion_save_to_cloud, fusion_aps_create_folder. Trigger words - fusion cloud folder, project root, dumped files in root, clean up cloud files, fusion_cleanup_cloud_files, Adom AI Workspace, aps_upload folder, save to cloud folder, folder hierarchy fusion, f3d clutter, delete cloud file, lineage urn delete, shared project cloud, organize fusion cloud, never write to root.+---++# Fusion cloud folder hygiene (never clutter a shared root)++The Autodesk/Fusion cloud is a **shared team space**. When the bridge uploads f3d 3D packages, saves+libraries, or exports, those files are visible to every member of the hub/project. So keeping a clean+folder hierarchy is not optional - it is courtesy to your colleagues.++## ⛔ The cautionary tale (why this skill exists, 2026-06-29)++`fusion_make_3d_package` defaulted its upload target to the **project's ROOT folder**. Over two days a+138-part-library build dumped **100+ loose f3d files** (`R_0R_0402`, `C_220nF_3d`, `Untitled`, ...)+straight into the **shared Adom team root**, mixed in with everyone's real designs. Colleagues+complained that it was a mess. Every rule below is the fix.++## Rule 1 - NEVER write a loose file to a project ROOT folder++Not an f3d, not a save, not an export. A project root is the team's shared landing area; loose files+there clutter it for everyone. The bridge now **refuses** an upload whose `folderId` is a known root+(`errorCode: refused_root_upload`) and **defaults** every f3d upload to a non-root workspace folder.++## Rule 2 - work inside an AI-owned "Adom AI Workspace", with a per-task subfolder++The bridge auto-creates ONE tidy folder, **`Adom AI Workspace`**, and puts each task in its own+SUBfolder, e.g. `Adom AI Workspace/AdomBasicParts-3D/`. So:++- `fusion_make_3d_package` / `fusion_build_library_3d` with NO `folderId` → land in+  `Adom AI Workspace/<task>` automatically (`build_library_3d` derives `<task>` from the .lbr name; or+  pass `task`). You do not have to browse for a folder.+- If you DO pass a `folderId`, make it a real subfolder - never a root. (Create one with+  `fusion_aps_create_folder {projectId, parentFolderId, name}` first.)+- Different project/task/situation → a different subfolder. Keep it organized.++## Rule 3 - clean up after yourself, PRECISELY (by lineage urn, never by name)++To remove f3d clutter the bridge made, use **`fusion_cleanup_cloud_files {fileIds:[<lineage urn>...]}`**.+It deletes ONLY the exact ids you pass (gotten from `fusion_aps_browse` on the folder → each item's+`.id`), so it **cannot** delete a teammate's file even if names collide. NEVER bulk-delete by name in a+shared folder - a colleague may have a file with the same name (especially generic ones like+`Untitled`). A big list takes minutes (the relay request may time out at ~60s while the deletes+continue server-side - re-browse the folder to confirm the count dropped).++```jsonc+// find my files+fusion_aps_browse { "projectId": "...", "folderId": "<the folder>" }   // items[].id are lineage urns+// delete exactly mine (protect everyone else's)+fusion_cleanup_cloud_files { "fileIds": ["urn:adsk.wipprod:dm.lineage:...", ...], "projectName": "Adom" }+```++## The discipline, in one line++Pick (or make) a real, non-root, AI-owned subfolder before you write anything to the cloud; clean up+your own files by precise urn when you're done. Treat the shared cloud the way you'd want a colleague+to treat it.