← Commit history

v1.9.47: live per-step tour evidence (stepShot + sinceShot), respawn-proof done frames, license_sessions_exceeded

John Lauer ·5f439f26f9 ·1mo ago ·parent 5edfc91
6 files changed +201−10
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.46\ No newline at end of file+1.9.47\ No newline at end of file
CHANGELOG.md+18
@@ -3,6 +3,24 @@ 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.47 - 2026-08-16++- **Live per-step tour evidence (ab's #30 contract, John's call).** fusion_demo_progress frames+  now carry `stepShot {step, title, image}` - a ~640px base64 capture the bridge took at each+  step's completion, one per step, riding the first frame after it completes. shots[] also+  streams mid-run (paths always; `imagesAs:base64` + `sinceShot:<n>` windows to only the new+  ones, so 1s polls stay cheap). The done:true gallery is unchanged.+- **Finished tours survive bridge respawns.** ab's poller lost the final frame because+  bridge_install replaced the process between polls (tours were never reaped - the process was).+  Done tours snapshot to ~/.adom/fusion-bridge/tours/ (last 8 kept) and fusion_demo_progress+  falls back to the snapshot for unknown in-memory ids.+- **license_sessions_exceeded is now a first-class classification.** The seat modal often has a+  bare Win32 title, so the substring classifier filed it generic; fusion_state and+  fusion_window_info now also run the ownership+shape detector and append the decidable category+  with the auto-takeover status. The tour cold-start self-heals it (takes the seat, reports it as+  its own progress state) and FAILS FAST with the classification when takeover cannot land or is+  opted out - no more 150s wedge at 0%.+ ## 1.9.46 - 2026-08-16  - **fusion_set_seat_takeover: the seat-takeover sweep is now a STICKY setting** (default ON,
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {     "manifest_version": 1,     "name": "fusion360",-    "version": "1.9.46",-    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.46/adom-bridge-fusion-v1.9.46.zip",-    "sha256": "3f37b7c7b4ed81065f6a44fbf40131c5d736639f334d502ce6ffa2aa8c48d5af",-    "size": 392587,+    "version": "1.9.47",+    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.47/adom-bridge-fusion-v1.9.47.zip",+    "sha256": "dd86103860fcbb45e3345f8fd9171bcf96184a6a49bf1a57685f85f9ffef94de",+    "size": 395774,     "verbPrefixes": [         "fusion_"     ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.46",+  "version": "1.9.47",   "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.",
describe.py+1−1
@@ -200,7 +200,7 @@ _T = [     ("fusion_show_symbol", "Open a schematic SYMBOL in Fusion's library editor - first-class, no hand-built EDIT strings. Pass name + libraryPath (.lbr, opened first; omit libraryPath if a library is already open), OR pass a BARE <symbol> XML fragment as symbolXml and the bridge generates a minimal wrapper library around it (deviceset + gate synthesized) so it shows in the Library Manager. Background-safe: never steals the foreground.", {"name": "str (deviceset/symbol name; optional if symbolXml carries name=)", "libraryPath": "optional str (WINDOWS path .lbr)", "symbolXml": "optional str (bare <symbol ...>...</symbol>)", "foreground": "optional bool - user's choice to WATCH; default background"}, {"success": "bool", "view": "str", "screenshot": "str path", "wrappedLibrary": "str (when wrapped)"}, 180, None, True, {"name": "ADS8588SIPM", "libraryPath": "C:/Temp/lib.lbr"}),     ("fusion_show_footprint", "Open a FOOTPRINT (package) in Fusion's library editor. Same contract as fusion_show_symbol: name + libraryPath, or a bare <package> fragment as packageXml (wrapper library generated).", {"name": "str", "libraryPath": "optional str", "packageXml": "optional str (bare <package ...>...</package>)", "foreground": "optional bool - user's choice to WATCH; default background"}, {"success": "bool", "view": "str", "screenshot": "str path", "wrappedLibrary": "str (when wrapped)"}, 180, None, True, {"name": "ADS8588SIPM", "libraryPath": "C:/Temp/lib.lbr"}),     ("fusion_show_3d_package", "Open the 3D CHIP view (Package3DEnvironment) for a library package - and the RELIABLE way to get a 3D model into a library: pass stepPath and it is attached AFTER the library opens (embedded <packages3d> in hand-built .lbr files is the Fusion quirk that fails; attach-after-open works). stepPath with no library/packageXml auto-generates a minimal 2-pad carrier footprint. Without stepPath it just enters the 3D view of the named package.", {"name": "str", "libraryPath": "optional str (.lbr)", "packageXml": "optional str (bare <package> fragment)", "stepPath": "optional str (WINDOWS path .step to attach)", "foreground": "optional bool - user's choice to WATCH; default background"}, {"success": "bool", "view": "str", "screenshot": "str path", "wrappedLibrary": "str"}, 300, None, True, {"name": "MYCHIP", "stepPath": "C:/Temp/chip.step"}),-    ("fusion_demo_progress", "Poll a running fusion_demo tour: {percent, elapsedSec, estimatedSec, step, stepLabel, done, failed, shots}. Poll about once a second and render a REAL progress bar. When done:true the shots array carries base64 images (pass imagesAs:paths to skip the encoding).", {"demoId": "required str (from fusion_demo mode:tour; omitted = the single running tour)", "imagesAs": "optional str paths|base64|both"}, {"percent": "int", "step": "str", "stepLabel": "str", "done": "bool", "shots": "list"}, 15, None, False, {"demoId": "tour-abc123"}),+    ("fusion_demo_progress", "Poll a running fusion_demo tour: {percent, elapsedSec, estimatedSec, step, stepLabel, done, failed, shots, shotCount, stepShot}. Poll ~1-2.5s and render a REAL progress bar. LIVE EVIDENCE (issue #30): each frame MAY carry stepShot {step, title, image} - a ~640px base64 capture the BRIDGE took at that step's completion, one per step, riding the first frame after it completes (render these during the run; the page should not guess-snap). shots[] streams too: mid-run it carries paths for every completed step; pass imagesAs:base64 + sinceShot:<count you have> to receive only NEW shots fully encoded. done:true = the full gallery (base64 by default). Finished tours are DISK-SNAPSHOTTED, so the final frame stays pollable even across a bridge respawn.", {"demoId": "required str (from fusion_demo mode:tour; omitted = the single running tour)", "imagesAs": "optional str paths|base64|both", "sinceShot": "optional int - shots you already received; response windows to the new ones"}, {"percent": "int", "step": "str", "stepLabel": "str", "done": "bool", "shots": "list", "shotCount": "int", "stepShot": "dict|absent"}, 15, None, False, {"demoId": "tour-abc123", "imagesAs": "base64", "sinceShot": 3}),      ("fusion_make_test_project", "Create a NEW electronics test project FROM SCRATCH around a library part - the Web Control demo's PROJECT button, and the answer to 'a customer only has a component page'. Generates a minimal test circuit server-side (the part + a generated 330R series resistor with VCC/GND stubs on a small board) as real EAGLE .sch/.brd, then imports the pair as a new electronics design. NO cloud, NO sign-in, no pre-existing content. After it, fusion_show_schematic / fusion_show_2d_board / fusion_show_3d_board all work on the result. Wiring uses the deviceset's own connects (first two pins), so 2-terminal parts get a real circuit and big ICs get a structurally-valid integration proof.", {"libraryPath": "required str (WINDOWS path .lbr; stage from container first)", "deviceset": "optional str (defaults to the library's first deviceset)"}, {"success": "bool", "schPath": "str", "brdPath": "str", "circuit": "str"}, 300, None, True, {"libraryPath": "C:/Temp/IN-S42ATR.lbr"}),     ("fusion_screenshot_fusion", "Background PrintWindow capture of Fusion's MAIN window (largest same-title window; works occluded/bottom-of-z-order, never raises anything) or a specific dialog by hwnd. Returns savedTo (PNG/WebP on the box; pull with pull_file). (Live since early versions but undescribed until 2026-08-15 - it was invisible to callers AND to the drift check.)", {"hwnd": "optional int (a dialog; default the main window)"}, {"success": "bool", "savedTo": "str", "sizeKB": "int"}, 60, None, False, {}),
server.py+176−3
@@ -1107,6 +1107,7 @@ def _handle_window_info(fusion_info: dict, args: dict) -> dict:             result["blockingDialogs"] = classify_blocking_dialogs() or []         except Exception:             result["blockingDialogs"] = []+        _license_classify_extend(result["blockingDialogs"])         dialog_info = [f"  hwnd={d['hwnd']}: {d['title']}" for d in dialogs]         output_parts = [             f"Main: hwnd={result['hwnd']}, title='{result['title']}'",@@ -4345,6 +4346,37 @@ _DOC_SWITCHING_ADDIN = { }  +def _license_classify_extend(dialogs: list):+    """ab's #30 ask: the Autodesk seat modal ('Active Sessions Exceeded' / 'Suspend Remote+    Session') often carries a bare Win32 title, so the title-substring classifier files it+    as generic_modal - undecidable for a caller. find_licensing_dialog() detects it by+    OWNERSHIP + SHAPE instead, so when it fires, append the decidable category (mutates+    the passed list in place; never raises)."""+    try:+        if any(d.get("category") == "license_sessions_exceeded" for d in dialogs):+            return+        dlg = find_licensing_dialog()+        if not dlg:+            return+        auto = _seat_takeover_enabled()+        dialogs.append({+            "category": "license_sessions_exceeded",+            "title": dlg.get("title") or "",+            "hwnd": dlg.get("hwnd"),+            "resolution": (+                "Autodesk seat modal: this account's Fusion is active on another machine. "+                + ("Auto-takeover is ON - the bridge clicks Continue through both dialogs in the "+                   "background (~20s sweep; a fusion_start resolves it immediately). The other "+                   "machine's Fusion suspends RESUMABLY."+                   if auto else+                   "Auto-takeover is OPTED OUT (fusion_set_seat_takeover) - resolve the dialog "+                   "manually or free the seat with fusion_stop on the other machine.")),+            "autoHandled": auto,+        })+    except Exception:+        pass++ def _handle_fusion_state(fusion_info: dict, args: dict) -> dict:     """fusion_state - the render-me verb (#28; kicad_state is the reference shape). @@ -4372,6 +4404,7 @@ def _handle_fusion_state(fusion_info: dict, args: dict) -> dict:         result["blockingDialogs"] = classify_blocking_dialogs() or []     except Exception:         result["blockingDialogs"] = []+    _license_classify_extend(result["blockingDialogs"])     # Busy state (the add-in's own report; 3s budget, never blocks the state read).     busy = None     try:@@ -6149,6 +6182,64 @@ _DEMO_TOURS: dict = {} _DEMO_TOURS_LOCK = threading.Lock()  +def _demo_tour_dir():+    import pathlib+    d = pathlib.Path(os.path.expanduser("~")) / ".adom" / "fusion-bridge" / "tours"+    d.mkdir(parents=True, exist_ok=True)+    return d+++def _demo_tour_persist(state: dict):+    """Snapshot a FINISHED tour to disk (ab's #30 grace-window ask, 2026-08-16): their+    2.5s poller got 'unknown demoId' for the final frame because a bridge_install+    respawned this process between polls, wiping the in-memory dict - tours were never+    reaped, the PROCESS was. The disk snapshot makes the done frame (with its shots)+    pollable across respawns; keep the last 8, best-effort."""+    try:+        d = _demo_tour_dir()+        snap = {k: v for k, v in state.items() if k != "started"}+        (d / (str(state.get("demoId")) + ".json")).write_text(json.dumps(snap))+        old = sorted(d.glob("tour-*.json"), key=lambda p: p.stat().st_mtime)+        for p in old[:-8]:+            p.unlink()+    except Exception:+        pass+++def _demo_tour_load(demo_id: str):+    try:+        p = _demo_tour_dir() / (demo_id + ".json")+        if p.is_file():+            return json.loads(p.read_text())+    except Exception:+        pass+    return None+++def _shot_b64_small(path: str, max_w: int = 640):+    """Base64 evidence frame for ab's stepShot contract (~640px wide). PIL downscale when+    available; else the raw file if it is small enough for the relay envelope."""+    try:+        try:+            from PIL import Image+            import io+            import base64+            im = Image.open(path)+            if im.width > max_w:+                im = im.resize((max_w, max(1, int(im.height * max_w / im.width))))+            buf = io.BytesIO()+            im.convert("RGB").save(buf, format="PNG")+            return base64.b64encode(buf.getvalue()).decode()+        except ImportError:+            import base64+            raw = open(path, "rb").read()+            if len(raw) <= 1_500_000:+                return base64.b64encode(raw).decode()+    except Exception:+        pass+    return None++ def _demo_tour_sample_lbr():     """The bundled sample library staged into the wrap dir (self-sufficient demo data)."""     import os@@ -6247,6 +6338,7 @@ def _orchestrate_demo_tour(args: dict) -> dict:                 if key == "launch":                     dispatch_command("start", {})                     ready = False+                    seat_attempts = 0                     for _ in range(30):                         time.sleep(5)                         try:@@ -6255,6 +6347,37 @@ def _orchestrate_demo_tour(args: dict) -> dict:                                 break                         except Exception:                             pass+                        # ab's #30 fleet finding (2026-08-16): a single-seat account with Fusion+                        # signed in elsewhere wedges the cold start at 0% behind 'Active Sessions+                        # Exceeded'. Self-heal (take the seat, John's standing call) and REPORT it+                        # as its own state; if takeover cannot land (opt-out, or the UIA path is+                        # unavailable), fail FAST with the decidable classification instead of+                        # burning the whole 150s budget.+                        try:+                            if find_licensing_dialog():+                                if _seat_takeover_enabled():+                                    state.update(stepLabel="Taking over the Autodesk seat (another machine holds it)")+                                    r = _resolve_seat_dialog(max_clicks=3)+                                    seat_attempts += 1+                                    if r.get("clicks"):+                                        state["seatTakeover"] = True+                                        state.setdefault("warnings", []).append(+                                            "license_sessions_exceeded: took the seat over in the background "+                                            "(the other machine's Fusion suspended resumably)")+                                    if seat_attempts >= 3 and find_licensing_dialog():+                                        raise RuntimeError(+                                            "license_sessions_exceeded: the Autodesk seat is held on another "+                                            "machine and background takeover did not land. Free it there "+                                            "(fusion_stop on that box) and re-run the tour.")+                                else:+                                    raise RuntimeError(+                                        "license_sessions_exceeded: seat held on another machine and "+                                        "auto-takeover is opted out (fusion_set_seat_takeover). Resolve the "+                                        "'Active Sessions Exceeded' dialog or re-enable takeover, then re-run.")+                        except RuntimeError:+                            raise+                        except Exception:+                            pass                     if not ready:                         # Not ready usually means the SIGN-IN wall on a fresh box. Drive it as far                         # as code can (email typed, page advanced), toast the human at the exact@@ -6379,6 +6502,8 @@ def _orchestrate_demo_tour(args: dict) -> dict:             state.update(failed=True, done=True, error=str(e)[:300],                          narrate="Fusion tour stopped at '%s': %s" % (state.get("stepLabel"), str(e)[:120]),                          elapsedSec=int(time.time() - state["started"]))+        finally:+            _demo_tour_persist(state)      threading.Thread(target=_run, daemon=True, name="fusion-demo-tour").start()     return {"success": True, "demoId": demo_id, "estimatedSec": est,@@ -6398,20 +6523,68 @@ def _handle_demo_progress(args: dict) -> dict:         # a WRONG id must refuse (the suite's #unknown negative test enforces this).         if state is None and not demo_id and len(_DEMO_TOURS) == 1:             state = list(_DEMO_TOURS.values())[0]+    persisted = False+    if state is None and demo_id:+        # Respawn-survival (ab #30): finished tours are snapshotted to disk, so the final+        # frame stays pollable even after bridge_install replaced this process mid-poll.+        state = _demo_tour_load(demo_id)+        persisted = state is not None     if state is None:         return {"success": False, "error": "unknown demoId " + demo_id,-                "_hint": "Start a tour first: fusion_demo {\"mode\": \"tour\"}. Its response carries the demoId."}+                "_hint": "Start a tour first: fusion_demo {\"mode\": \"tour\"}. Its response carries the demoId. Finished tours stay pollable (disk snapshot) even across bridge respawns - an unknown id here means it never ran on this box."}     out = {"success": True}     out.update({k: v for k, v in state.items() if k != "started"})+    if persisted:+        out["fromSnapshot"] = True+    # ── stepShot: the DURING evidence (ab's exact contract, #30 2026-08-16): one shot per+    # completed step, riding the FIRST progress frame after that step completes, ~640px+    # wide base64 - the bridge knows the exact completion moment and surface, the page+    # does not. Delivery cursor lives in the tour state; encode failure still advances so+    # one unreadable file can never stall the stream. The final shots gallery (below and+    # at done:true) is unchanged - that is the AFTER evidence.+    if not persisted:+        _sent = int(state.get("stepShotSent") or 0)+        _all = list(state.get("shots") or [])+        if _sent < len(_all):+            s = _all[_sent]+            img = _shot_b64_small(s.get("path") or "")+            state["stepShotSent"] = _sent + 1+            if img:+                out["stepShot"] = {"step": s.get("label"), "title": s.get("title") or s.get("label"),+                                   "image": img}+    # ── Progressive per-step EVIDENCE (John, 2026-08-16): shots stream to the caller AS+    # each step completes, not only at done:true. Contract for a live evidence gallery:+    #   poll fusion_demo_progress {demoId, imagesAs:"base64", sinceShot:<count you have>}+    # -> shots = ONLY the new ones (each with .image base64), shotCount = total so far.+    # sinceShot keeps 1/sec polls cheap: unchanged evidence is never re-encoded/re-sent.+    # Shot dicts are COPIED before encoding - mutating state's own dicts embedded base64+    # into the shared tour state (memory bloat + every later poll re-shipping it all).+    shots = list(out.get("shots") or [])+    out["shotCount"] = len(shots)+    try:+        since = max(0, int(args.get("sinceShot") or 0))+    except Exception:+        since = 0+    if since:+        shots = shots[since:]     imgs = args.get("imagesAs") or ("base64" if state.get("done") else "paths")-    if imgs in ("base64", "both") and state.get("done"):+    if imgs in ("base64", "both"):         import base64-        for s in out.get("shots", []):+        encoded = []+        for s in shots:+            s = dict(s)             try:                 with open(s["path"], "rb") as f:                     s["image"] = base64.b64encode(f.read()).decode()             except Exception:                 pass+            encoded.append(s)+        shots = encoded+    out["shots"] = shots+    if not state.get("done"):+        out["_hint"] = ("Tour running (%s). shots[] carries evidence for every COMPLETED step "+                        "already - render it live; pass sinceShot=%d on the next poll to receive "+                        "only new shots." % (out.get("stepLabel"), out["shotCount"]))     return out