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 12 file(s)
12 files changed
+423−41
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.72+1.9.73
adom-bridge-fusion-manifest.json+5−5@@ -1,13 +1,13 @@ { "manifest_version": 1, "name": "fusion360",- "version": "1.9.72",- "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.72/adom-bridge-fusion-v1.9.72.zip",- "sha256": "18b4365ec71d90c26ef39554eba77a863efdad5161ff7dcc0ad3ef29976011bb",- "size": 409290,+ "version": "1.9.73",+ "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.85/adom-bridge-fusion-v1.9.73.zip",+ "sha256": "7d16614d4e1870208aa18e4870a9d376a6fc88a133b23151902ee6b6841bf939",+ "size": 414344, "verbPrefixes": [ "fusion_" ], "healthEndpoint": "/status",- "released_at": "2026-08-16T00:00:00Z"+ "released_at": "2026-08-19T00:00:00Z" }
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.59",+ "version": "1.9.73", "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.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.84"+version = "1.9.85" edition = "2021" [dependencies]
dashboard/src/main.rs+41−7@@ -557,10 +557,28 @@ fn wait_ready_with_heal(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool { app.hub.broadcast("state", &gather_status(&ai, target)); return true; }- if !announced && t0.elapsed() > Duration::from_secs(45) {- announced = true;- app.hub.broadcast("toast", &serde_json::json!({- "message": "Still waiting on Fusion (a cold start takes a minute or two)", "kind": ""}));+ // MEASURED, NEVER A SHRUG (page issue #32, John 2026-08-19): the bridge now returns a+ // `progress` block seeded from THIS machine's rolling launch history, so the wait is+ // reported with a real percent + ETA instead of "a minute or two". Pushed on the live+ // channel every tick so the LED and its caption narrow as the launch proceeds.+ let rd = unwrap_data(&bridge(&ai, target, "fusion_readiness", "{}"));+ if let Some(p) = rd.get("progress").filter(|p| !p.is_null()) {+ app.hub.broadcast("progress", &serde_json::json!({+ "target": target, "phase": p["phase"].clone(), "percent": p["percent"].clone(),+ "etaSec": p["etaSec"].clone(), "elapsedSec": p["elapsedSec"].clone(),+ "stepLabel": p["stepLabel"].clone(), "confidence": p["confidence"].clone(),+ "startKind": p["startKind"].clone()}));+ if !announced {+ announced = true;+ let eta = p["etaSec"].as_i64();+ app.hub.broadcast("toast", &serde_json::json!({+ "message": match eta {+ Some(s) => format!("{} - about {}s left ({})",+ p["stepLabel"].as_str().unwrap_or("Fusion is starting"), s,+ p["confidence"].as_str().unwrap_or("typical")),+ None => format!("{}", p["stepLabel"].as_str().unwrap_or("Fusion is starting")),+ }, "kind": ""}));+ } } } false@@ -569,6 +587,21 @@ fn wait_ready_with_heal(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool { fn auto_green(app: &Arc<App>, target: &str) -> bool { auto_green_forced(app, target, false) } /// The doctrine entry point: make the box ready, never tell the user to do it.+/// How long to wait for a launch on THIS box, from the bridge's own MEASURED history+/// (page issue #32) rather than a literal. p90 with generous headroom, because giving up+/// early on a genuinely-slow-but-healthy cold start hands the user a failure the machine+/// was about to resolve (ralph, 2026-08-19: a 4-minute cap failed a box that needed more).+/// Falls back to `floor` when this machine has no history yet.+fn ready_budget(ai: &str, target: &str, floor_s: u64) -> u64 {+ let st = unwrap_data(&bridge(ai, target, "fusion_state", "{\"screenshot\":false}"));+ let cold = st["timings"]["launch"]["cold"]["p90"].as_f64()+ .or_else(|| st["timings"]["launch"]["cold"]["p50"].as_f64());+ match cold {+ Some(s) if s > 0.0 => ((s * 2.0) as u64 + 120).max(floor_s),+ _ => floor_s,+ }+}+ fn ensure_ready(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool { let ai = app.ai_thread.clone(); if unwrap_data(&bridge(&ai, target, "fusion_readiness", "{}"))["ready"].as_bool().unwrap_or(false) {@@ -578,7 +611,8 @@ fn ensure_ready(app: &Arc<App>, target: &str, max_wait_s: u64) -> bool { "message": format!("Getting {} ready first - launching Fusion and waiting for the bridge add-in", target), "kind": ""})); let _ = auto_green(app, target); // false when one is already in flight for this box- wait_ready_with_heal(app, target, max_wait_s)+ let budget = ready_budget(&ai, target, max_wait_s);+ wait_ready_with_heal(app, target, budget) } // ── SURFACES DEMO (John, 2026-08-18) ─────────────────────────────────────────────────────@@ -608,7 +642,7 @@ fn stage_demo_part(app: &Arc<App>, target: &str, page: &str) -> Result<(String, }; // GET it ready rather than refusing: a click means "show me this", not "audit my setup". if !ensure_ready(app, target, 240) {- return Err(format!("Fusion did not become ready on {} within 4 minutes. Open the Settings and check the status cards - a sign-in wall or a stuck launch will say so there.", target));+ return Err(format!("Fusion did not become ready on {} within the measured launch budget for that machine. Open the Settings and check the status cards - a sign-in wall or a stuck launch will say so there.", target)); } toast(&format!("Staging {} from the wiki (clone -> send -> bind 3D -> generate project)", page), ""); let tmp = format!("/tmp/fd-surfaces-{}", std::process::id());@@ -1007,7 +1041,7 @@ fn handle_conn(app: &Arc<App>, mut stream: TcpStream) { if !ensure_ready(app, &t, 240) { respond_json(&mut stream, &serde_json::json!({ "success": false,- "error": format!("Fusion did not become ready on {} within 4 minutes", t),+ "error": format!("Fusion did not become ready on {} within its measured launch budget", t), "_hint": "Open Settings and read the status cards - a sign-in wall or a stuck launch is reported there."})); return; }
dashboard/ui/index.html+30−1@@ -46,6 +46,19 @@ h2{font-family:'Familjen Grotesk',sans-serif;font-size:17px;color:var(--text);ma .led.off{background:var(--red)} .led.warn{background:var(--yellow)} .led.starting{background:var(--yellow);animation:ledpulse 1.1s ease-in-out infinite}+/* MEASURED progress (page issue #32): a real bar with a real ETA - never "a couple of+ minutes". Width is driven by the bridge's progress.percent, which is monotonic. */+#launchbar{display:none;margin:10px 0 2px;padding:10px 12px;border:1px solid var(--line);+ border-radius:10px;background:var(--panel)}+#launchbar.show{display:block}+#launchbar .lbtop{display:flex;justify-content:space-between;align-items:baseline;gap:12px;+ font-size:13px;margin-bottom:7px}+#launchbar .lbeta{color:var(--text-2);font-family:'JetBrains Mono',monospace;font-size:12px;+ white-space:nowrap}+#launchbar .lbtrack{height:6px;border-radius:3px;background:#20262e;overflow:hidden}+#launchbar .lbfill{height:100%;width:0;border-radius:3px;background:var(--accent);+ transition:width .5s ease}+#launchbar .lbnote{margin-top:6px;font-size:11px;color:var(--text-2)} @keyframes ledpulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(210,153,34,.55)}50%{opacity:.55;box-shadow:0 0 10px 3px rgba(210,153,34,.35)}} .led-label{font-weight:500;color:var(--text)} .led-sub{font-size:12.5px;color:var(--text-2);margin-top:6px}@@ -469,7 +482,7 @@ function renderSurfaces(){ g.querySelectorAll('button[data-surface]').forEach(b=>{ b.onclick=async()=>{ const key=b.getAttribute('data-surface');- const st=$('#ss-'+key);st.className='sstate';st.textContent='opening (staging on first run - up to a couple of minutes)...';+ const st=$('#ss-'+key);st.className='sstate';st.textContent='opening\u2026'; try{ const r=await api('api/surface',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({surface:key,target:curTarget||undefined})});@@ -503,6 +516,22 @@ function connectSSE(){ const es=new EventSource('events'); es.addEventListener('hello',()=>{sseUp=true;console.log('sse connected');}); es.addEventListener('state',e=>{try{const st=JSON.parse(e.data);if(!curTarget||st.target===curTarget){lastStatus=st;renderLeds(st);}}catch(_){}});+ // The bridge's measured progress block, rendered verbatim - no local guessing.+ es.addEventListener('progress',e=>{try{+ const p=JSON.parse(e.data);+ if(curTarget && p.target && p.target!==curTarget) return;+ const box=$('#launchbar');box.classList.add('show');+ $('#lbLabel').textContent=p.stepLabel||'Fusion is starting';+ const pct=Math.max(0,Math.min(100,p.percent||0));+ $('#lbFill').style.width=pct+'%';+ $('#lbEta').textContent=(p.etaSec!=null?('~'+p.etaSec+'s left'):'')+' '+pct+'%';+ $('#lbNote').textContent=(p.startKind?(p.startKind+' start'):'')++ (p.confidence?(' \u00b7 '+p.confidence+(p.confidence==='measured'?' from this machine\u2019s own history':'')):'')++ (p.elapsedSec!=null?(' \u00b7 '+p.elapsedSec+'s elapsed'):'');+ clearTimeout(window.__lbHide);+ window.__lbHide=setTimeout(()=>box.classList.remove('show'),25000);+ if(pct>=100) setTimeout(()=>box.classList.remove('show'),1500);+ }catch(_){}}); es.addEventListener('toast',e=>{try{const t=JSON.parse(e.data);toast(t.message,t.kind==='info'?'':t.kind);}catch(_){}}); es.addEventListener('click',e=>{try{ const {target}=JSON.parse(e.data);const sel=CLICK_TARGETS[target];
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.84"+DASH_VERSION="1.9.85" 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.84",+ "version": "1.9.85", "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,16 +1,20 @@ { "slug": "fusion-bridge",- "version": "1.9.84",+ "version": "1.9.85", "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": {- "adom/adom-desktop": "*"+ "adom/adom-desktop": "^1.9.0" }, "scripts": { "install": "./install.sh", "uninstall": "./uninstall.sh" }, "tags": [+ "ab",+ "bridge",+ "fusion",+ "desktop-app", "fusion360", "autodesk", "cad",@@ -70,7 +74,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.",@@ -99,9 +106,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+128−15@@ -549,13 +549,27 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict: "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in. (For a plain 'what EDA tools are installed' check, AD's bridge_readiness reports this cleanly without erroring.)", } + import time as _t0+ import timings as _tm+ _warm_start = _t0.time() if _is_fusion_running(): addin_ok = wait_for_addin(timeout=5)+ # MEASURED, not asserted (issue #32): a warm start is a real sample too, and+ # it is the number that lets a UI say "3s warm" instead of hedging with the+ # cold-start worst case on every single launch.+ _el = _t0.time() - _warm_start+ if addin_ok:+ _tm.record("launch", _el, "warm") return { "success": True, "output": "Fusion 360 is already running.", "addinConnected": addin_ok, "alreadyRunning": True,+ "startKind": "warm",+ "progress": _tm.terminal_block("launch", _warm_start,+ "Fusion is already running - reusing it",+ start_kind="warm", failed=not addin_ok),+ "timings": _tm.summary().get("launch"), "resolvedPath": fusion_info.get("exe_path", ""), } @@ -578,6 +592,13 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict: # clean stillLaunching response telling the caller to POLL fusion_readiness. import time as _t _start = _t.time()+ # POLLABLE (issue #32 section 3): stamp the launch so fusion_readiness - the verb the+ # caller polls while this one returns stillLaunching - can report the SAME measured+ # progress block. Sticky in prefs so a bridge respawn mid-launch does not lose the bar.+ try:+ _set_bridge_pref("launchStartedAt", _start)+ except Exception:+ pass err = ensure_fusion_running(fusion_info, wait_addin=False) if err: return err@@ -647,10 +668,19 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict: if not addin_ok: # Add-in not up within our budget. On a first launch this is EXPECTED (still # initializing / signing in), not a failure and not a retry-able timeout.+ # ISSUE #32: this is THE response John quoted. It now carries a measured+ # progress block (percent + eta + cold/warm, seeded from this machine's own+ # rolling history) so the caller draws a real bar instead of printing a shrug.+ _prog = _tm.progress_block("launch", _start,+ "Fusion is starting - waiting for the Adom add-in to serve",+ start_kind="cold", fallback=150) return { "success": True, "stillLaunching": True, "addinConnected": False,+ "startKind": "cold",+ "progress": _prog,+ "timings": _tm.summary().get("launch"), "statusVerb": "fusion_readiness", "recoveryFilesRelocated": reloc["moved"], "resolvedPath": fusion_info.get("exe_path", ""),@@ -661,8 +691,14 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict: "steal the user's focus - do NOT foreground it. " + ("A seat 'Active Sessions Exceeded' dialog appeared and was AUTO-RESOLVED in the " "background via UIA (no foreground). " if _seat_clicks else "")- + "The add-in isn't up yet - a first launch / fresh sign-in can take 2-4 min. This is "- "NOT a failure and NOT a retry-able timeout: do NOT re-call fusion_start. WHAT HAPPENS "+ + ((_tm.sentence() + ". ") if _tm.sentence() else "")+ + (("The add-in isn't up yet - about %ss remaining by this machine's own measured "+ "history (read progress.percent / progress.etaSec on this response; the estimate "+ "is revised as the launch proceeds). This is " % _prog.get("etaSec"))+ if _prog.get("etaSec") is not None else+ "The add-in isn't up yet and this machine has no measured launch history yet, so "+ "progress.confidence is 'typical' until it learns. This is ")+ + "NOT a failure and NOT a retry-able timeout: do NOT re-call fusion_start. WHAT HAPPENS " "NEXT: POLL fusion_readiness every ~10s; it returns needsSignin/licensingDialog/updating " "if something is mid-flight (the bridge handles the seat dialog itself), and ready:true " "when it is drivable - then run your fusion_* verbs. NEVER poll blind past ~2 min: "@@ -686,6 +722,15 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict: if addin_ok: dialog_blocking = _check_main_thread_blocked() + # TERMINAL FRAME CARRIES THE ACTUALS (issue #32 section 4): the real total goes+ # into this machine's rolling history, which is what makes the NEXT launch's+ # estimate "measured" instead of a literal someone typed.+ _tm.record("launch", _t.time() - _start, "cold")+ try:+ _set_bridge_pref("launchStartedAt", None)+ except Exception:+ pass+ output = "Fusion 360 launched successfully." if reloc["moved"] > 0: output += (@@ -710,6 +755,10 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict: "recoveryDialogDismissed": recovery_dismissed, "recoveryFilesRelocated": reloc["moved"], "recoveryFilesPath": reloc["dest"],+ "startKind": "cold",+ "progress": _tm.terminal_block("launch", _start, "Fusion is up and the add-in is serving",+ start_kind="cold", failed=not addin_ok),+ "timings": _tm.summary().get("launch"), "resolvedPath": fusion_info.get("exe_path", ""), } @@ -1532,6 +1581,27 @@ def _handle_fusion_readiness(fusion_info: dict, args: dict) -> dict: **stale_info, "_hint": hint, }+ # MEASURED PROGRESS WHILE LAUNCHING (issue #32): the poll verb must render the same+ # bar the launch verb started, or the caller is back to a spinner with no end.+ try:+ import time as _t_mod+ import timings as _tm+ result["timings"] = _tm.summary().get("launch")+ _lsa = _get_bridge_pref("launchStartedAt", None)+ if ready:+ if _lsa:+ _set_bridge_pref("launchStartedAt", None)+ elif _lsa and (_t_mod.time() - float(_lsa)) < 900:+ _label = ("Signing in to Autodesk" if needs_signin else+ "Clearing the Autodesk seat dialog" if licensing else+ "Applying a Fusion auto-update" if updating else+ "Fusion is up - waiting for the Adom add-in to serve" if running else+ "Fusion is starting")+ result["progress"] = _tm.progress_block(+ "launch", float(_lsa), _label, start_kind="cold", fallback=150)+ result["statusVerb"] = "fusion_readiness"+ except Exception:+ pass if updating: result["updating"] = True result["statusVerb"] = "fusion_readiness"@@ -4531,6 +4601,24 @@ def _handle_fusion_state(fusion_info: dict, args: dict) -> dict: except Exception: result["addinResponding"] = False result["mainThreadBusy"] = busy+ # WHAT TO EXPECT, BEFORE THE CLICK (issue #32 section 2): this machine's own measured+ # launch history, so a UI can say "typically 45s cold, 3s warm here" instead of making+ # the user discover it by waiting.+ try:+ import time as _tnow+ import timings as _tm+ result["timings"] = _tm.summary()+ _s = _tm.sentence()+ if _s:+ result["timingsSentence"] = _s+ _lsa = _get_bridge_pref("launchStartedAt", None)+ if _lsa and not result.get("addinResponding") and (_tnow.time() - float(_lsa)) < 900:+ result["progress"] = _tm.progress_block(+ "launch", float(_lsa),+ "Fusion is starting - waiting for the Adom add-in to serve",+ start_kind="cold", fallback=150)+ except Exception:+ pass try: from handlers.taskbar_overlay import badge_active, last_error as _badge_err result["drivingBadge"] = {"active": badge_active(),@@ -6626,24 +6714,28 @@ def _orchestrate_demo_tour(args: dict) -> dict: cold = not probe.get("success") except Exception: pass- steps = ([("launch", "Starting Fusion (cold start)", 120)] if cold else []) + [- ("stage", "Staging the sample library", 3),- ("library", "Opening the electronics library", 35),- ("symbol", "Symbol: full pinout in the symbol editor", 8),- ("footprint", "Footprint: pads + layers in the footprint editor", 8),- ("device", "Component view: symbol + package mapping", 8),- ("3d-package", "3D chip: the Package3D environment", 30),+ import timings as _tm+ def _est(phase, seeded):+ """Measured p50 for this phase on THIS machine, else the seeded literal."""+ return int(round(_tm.estimate(phase, seeded, "cold" if phase == "launch" else None)[0]))+ steps = ([("launch", "Starting Fusion (cold start)", _est("launch", 120))] if cold else []) + [+ ("stage", "Staging the sample library", _est("stage", 3)),+ ("library", "Opening the electronics library", _est("library", 35)),+ ("symbol", "Symbol: full pinout in the symbol editor", _est("symbol", 8)),+ ("footprint", "Footprint: pads + layers in the footprint editor", _est("footprint", 8)),+ ("device", "Component view: symbol + package mapping", _est("device", 8)),+ ("3d-package", "3D chip: the Package3D environment", _est("3d-package", 30)), ] if include_design: # Design side is SELF-SUFFICIENT by default (John, 2026-08-15: customers have # only a component page, never a canonical cloud board): generate + import a # test project around the sample part. A query switches to the cloud-open path.- steps += ([("open-design", "Opening the cloud electronics design", 150)] if query- else [("make-project", "Generating + importing a test project around the sample part", 60)])+ steps += ([("open-design", "Opening the cloud electronics design", _est("open-design", 150))] if query+ else [("make-project", "Generating + importing a test project around the sample part", _est("make-project", 60))]) steps += [- ("schematic", "Schematic view", 12),- ("2d-board", "2D board layout", 12),- ("3d-board", "3D board", 20),+ ("schematic", "Schematic view", _est("schematic", 12)),+ ("2d-board", "2D board layout", _est("2d-board", 12)),+ ("3d-board", "3D board", _est("3d-board", 20)), ] est = sum(s[2] for s in steps) state = {"demoId": demo_id, "percent": 0, "elapsedSec": 0, "estimatedSec": est,@@ -6692,9 +6784,23 @@ def _orchestrate_demo_tour(args: dict) -> dict: # while they typed in other threads (John, live during ab's demo, # 2026-08-16). Nesting is refcounted, so re-arming is free. begin_background_envelope(float(dur * 3 + 240))+ _step_started = time.time()+ # LIVE, per-step ETA (issue #32): the tour reports the same contract block+ # every other slow verb does, so ONE renderer draws every bar in Adom. state.update(step=key, stepLabel=label, percent=int(100 * done_est / est),- elapsedSec=int(time.time() - state["started"]))+ elapsedSec=int(time.time() - state["started"]),+ estimatedSec=est,+ etaSec=max(0, int(est - (time.time() - state["started"]))),+ confidence=_tm.estimate(key, dur)[1],+ startKind=("cold" if cold else "warm"),+ progress={"phase": key, "stepLabel": label,+ "percent": int(100 * done_est / est),+ "elapsedSec": int(time.time() - state["started"]),+ "estimatedSec": est,+ "etaSec": max(0, int(est - (time.time() - state["started"]))),+ "confidence": _tm.estimate(key, dur)[1],+ "startKind": ("cold" if cold else "warm")}) if key == "stage" and args.get("sampleLbr"): pass # handled in the stage branch below if key == "launch":@@ -6855,6 +6961,13 @@ def _orchestrate_demo_tour(args: dict) -> dict: else: state.setdefault("failedSteps", []).append(key) state.setdefault("warnings", []).append("%s: %s" % (key, str(r.get("error"))[:120]))+ # Every finished step is a MEASURED sample - this is what turns the next run's+ # estimate from a literal into this machine's own p50 (issue #32).+ try:+ _tm.record(key, time.time() - _step_started,+ "cold" if key == "launch" and cold else None)+ except Exception:+ pass done_est += dur state.update(percent=int(100 * done_est / est), elapsedSec=int(time.time() - state["started"]))
timings.pyadded+202@@ -0,0 +1,202 @@+"""Measured timing history + the `progress` contract block (page issue #32).++John, 2026-08-19: a slow verb must never answer "can take a couple of minutes".+That string is a shrug - not measured, never narrowing, impossible to draw a bar+from. This module is the other half of the fix: it keeps a ROLLING MEASURED+HISTORY per (phase, startKind) on THIS machine, so an estimate is the p50 of what+actually happened here rather than a literal someone typed once.++Shape returned by `progress_block()` is exactly the contract in issue #32:++ {"phase","stepLabel","percent","elapsedSec","estimatedSec","etaSec",+ "confidence","startKind"}++Rules the callers rely on:+ - `percent` is MONOTONIC (enforced here via `floor`), never walks backwards.+ - `estimatedSec` is REVISED as the run proceeds: once elapsed passes the+ estimate, the estimate grows rather than pinning eta at 0 and lying.+ - `confidence` is "measured" ONLY when this machine has real samples for that+ phase; "typical" when we are running on a seeded literal; "unknown" when we+ genuinely cannot say.++Storage: ~/.adom/fusion-bridge/timings.json, last _KEEP samples per key, written+atomically. A corrupt or unwritable store degrades to in-memory - timing history+must never be able to break a launch.+"""++import json+import os+import pathlib+import threading+import time++_KEEP = 10 # rolling samples per (phase, startKind)+_LOCK = threading.Lock()+_MEM = {} # fallback when the file cannot be read/written+++def _path():+ d = pathlib.Path(os.path.expanduser("~")) / ".adom" / "fusion-bridge"+ try:+ d.mkdir(parents=True, exist_ok=True)+ except Exception:+ pass+ return d / "timings.json"+++def _load():+ try:+ with open(_path(), "r", encoding="utf-8") as f:+ d = json.load(f)+ return d if isinstance(d, dict) else {}+ except Exception:+ return dict(_MEM)+++def _save(d):+ global _MEM+ _MEM = dict(d)+ try:+ p = _path()+ tmp = str(p) + ".tmp"+ with open(tmp, "w", encoding="utf-8") as f:+ json.dump(d, f, indent=1)+ os.replace(tmp, str(p))+ except Exception:+ pass # in-memory copy still serves this process+++def _key(phase, start_kind=None):+ return "%s|%s" % (phase, start_kind or "any")+++def record(phase, seconds, start_kind=None):+ """Add one MEASURED sample. Call it on success AND on a clean failure that+ still consumed real time - both are truth about how long this box takes."""+ try:+ seconds = float(seconds)+ except Exception:+ return+ if seconds < 0 or seconds > 7200:+ return # a clock jump is not a sample+ with _LOCK:+ d = _load()+ k = _key(phase, start_kind)+ row = d.get(k) or {}+ samples = [s for s in (row.get("samples") or []) if isinstance(s, (int, float))]+ samples.append(round(seconds, 1))+ row["samples"] = samples[-_KEEP:]+ row["lastAt"] = int(time.time())+ d[k] = row+ _save(d)+++def _pct(samples, q):+ if not samples:+ return None+ s = sorted(samples)+ i = min(len(s) - 1, max(0, int(round(q * (len(s) - 1)))))+ return s[i]+++def stats(phase, start_kind=None):+ """{'p50','p90','count'} of real samples on THIS machine, or count 0."""+ row = _load().get(_key(phase, start_kind)) or {}+ s = [x for x in (row.get("samples") or []) if isinstance(x, (int, float))]+ return {"p50": _pct(s, 0.5), "p90": _pct(s, 0.9), "count": len(s)}+++def estimate(phase, fallback, start_kind=None):+ """Seconds this phase should take here: measured p50 when we have samples,+ else the caller's seeded literal. Returns (seconds, confidence)."""+ st = stats(phase, start_kind)+ if st["count"] >= 2 and st["p50"]:+ return float(st["p50"]), "measured"+ if st["count"] == 1 and st["p50"]:+ # one sample beats a literal but is not yet a distribution+ return float(st["p50"]), "typical"+ try:+ return float(fallback), "typical"+ except Exception:+ return 0.0, "unknown"+++def progress_block(phase, started, step_label, percent=None, start_kind=None,+ fallback=None, floor=None):+ """Build the issue-#32 progress block. `started` is a time.time() stamp.++ percent=None derives a percentage from elapsed/estimate - honest for a phase+ with no internal checkpoints, and it still moves, which is the whole point.+ `floor` keeps it monotonic across polls (pass the last percent you reported).+ """+ elapsed = max(0.0, time.time() - float(started))+ est, conf = estimate(phase, fallback if fallback is not None else 0, start_kind)+ # REVISE rather than lie: a run that outlives its estimate gets a bigger+ # estimate (and a non-zero eta), never a bar pinned at 100% or an eta of 0.+ if est and elapsed > est:+ est = elapsed * 1.25+ if conf == "measured":+ conf = "typical" # we are past what this machine has ever measured+ if percent is None:+ percent = int(100 * elapsed / est) if est else 0+ percent = max(0, min(99 if est else 0, int(percent)))+ if floor is not None:+ percent = max(int(floor), percent)+ eta = max(0, int(round(est - elapsed))) if est else None+ return {+ "phase": phase,+ "stepLabel": step_label,+ "percent": percent,+ "elapsedSec": int(round(elapsed)),+ "estimatedSec": int(round(est)) if est else None,+ "etaSec": eta,+ "confidence": conf if est else "unknown",+ "startKind": start_kind,+ }+++def terminal_block(phase, started, step_label, start_kind=None, failed=False):+ """The completion frame: percent 100 (or the phase it died in) + the ACTUALS,+ so a caller can log truth and a bar can turn red in the right place."""+ elapsed = max(0.0, time.time() - float(started))+ return {+ "phase": phase,+ "stepLabel": step_label,+ "percent": 100 if not failed else None,+ "elapsedSec": int(round(elapsed)),+ "estimatedSec": int(round(elapsed)),+ "etaSec": 0 if not failed else None,+ "confidence": "measured",+ "startKind": start_kind,+ "failed": bool(failed),+ "measured": {"phase": phase, "seconds": round(elapsed, 1), "startKind": start_kind},+ }+++def summary():+ """Per-machine history for describe/status: what a caller can tell the user+ BEFORE they click ("typically 45s cold, 3s warm on THIS machine")."""+ d = _load()+ out = {}+ for k, row in d.items():+ phase, _, kind = k.partition("|")+ s = [x for x in (row.get("samples") or []) if isinstance(x, (int, float))]+ if not s:+ continue+ out.setdefault(phase, {})[kind] = {+ "p50": _pct(s, 0.5), "p90": _pct(s, 0.9), "count": len(s),+ "lastAt": row.get("lastAt"),+ }+ return out+++def sentence():+ """One human line for a UI/hint, built only from what we actually measured."""+ s = summary().get("launch") or {}+ bits = []+ for kind in ("cold", "warm"):+ if s.get(kind, {}).get("p50") is not None:+ bits.append("%s %ss" % (kind, int(s[kind]["p50"])))+ if not bits:+ return None+ return "on this machine Fusion starts in " + ", ".join(bits) + " (measured)"
tools/release-files.txt+1@@ -34,6 +34,7 @@ adom-bridge-fusion-manifest.json aps.py bridge.json describe.py+timings.py eagle_lbr.py fusion_detect.py fusion_helper.py