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 13 file(s)
13 files changed
+399−19
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.97+1.9.98
adom-bridge-fusion-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "fusion360",- "version": "1.9.97",- "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.112/adom-bridge-fusion-v1.9.97.zip",- "sha256": "872e8f2f98c868821fe2775ca1d64cb3ce283b2fedc0a78e26da9d6fa5f7aece",- "size": 433006,+ "version": "1.9.98",+ "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.113/adom-bridge-fusion-v1.9.98.zip",+ "sha256": "afb12d123b86fa623446ae5013a5417e58da4cf2dae140924c7c148030c6d1cc",+ "size": 436531, "verbPrefixes": [ "fusion_" ],
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.97",+ "version": "1.9.98", "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.112"+version = "1.9.113" edition = "2021" [dependencies]
dashboard/src/main.rs+5−2@@ -91,7 +91,8 @@ fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_jso // 36.7s against a remote box (measured live 2026-08-18) - the Dashboard sat blank that whole // time. Fan them out and the wall clock is the slowest single call. let verbs = ["fusion_readiness", "fusion_aps_status", "fusion_mcp_status",- "fusion_set_auto_update", "fusion_set_seat_takeover", "fusion_set_driving_badge"];+ "fusion_set_auto_update", "fusion_set_seat_takeover", "fusion_set_driving_badge",+ "fusion_set_activity_indicator"]; let handles: Vec<_> = verbs.iter().map(|v| { let (ai, t, verb) = (ai_thread.to_string(), target.to_string(), v.to_string()); std::thread::spawn(move || unwrap_data(&bridge(&ai, &t, &verb, "{}")))@@ -102,6 +103,7 @@ fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_jso } let (rd, aps, mcp) = (res[0].clone(), res[1].clone(), res[2].clone()); let (auto_upd, seat, badge) = (res[3].clone(), res[4].clone(), res[5].clone());+ let activity = res.get(6).cloned().unwrap_or(serde_json::json!({})); let running = rd["fusionRunning"].as_bool().or(rd["running"].as_bool()).unwrap_or(false); let ready = rd["ready"].as_bool().unwrap_or(false);@@ -173,7 +175,8 @@ fn gather_status_ex(ai_thread: &str, target: &str, launching: bool) -> serde_jso "autoUpdateFusion": auto_upd["autoUpdateFusion"].as_bool().unwrap_or(true), "autoTakeOverSeat": seat["autoTakeOverSeat"].as_bool().unwrap_or(true), "autoGreenOnLaunch": read_settings()["autoGreenOnLaunch"].as_bool().unwrap_or(true),- "showDrivingBadge": badge["showDrivingBadge"].as_bool().unwrap_or(true)+ "showDrivingBadge": badge["showDrivingBadge"].as_bool().unwrap_or(true),+ "activityIndicator": activity["activityIndicator"].as_str().unwrap_or("progress") }, "demoPart": read_settings()["demoPart"], "_hint": "This is the Dashboard status payload. Every LED carries its own _hint naming the verb that fixes it. POST /api/action {action, args} to act; GET /api/status to re-read; subscribe to /events (SSE) for live pushes; POST /ui/toast {message,kind} to narrate on top of the app."
dashboard/ui/index.html+36@@ -144,6 +144,14 @@ dialog.settings{max-width:560px;overflow:hidden;padding:0} .xbtn:hover{background:var(--elevated);border-color:var(--border);color:var(--text)} .xbtn:active{transform:scale(.9);background:var(--surface)} .xbtn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}+.segmented{display:flex;gap:6px;flex-wrap:wrap}+.seg{background:var(--panel);border:1px solid var(--line);color:var(--text-2);border-radius:8px;+ padding:7px 12px;font-size:12px;cursor:pointer;min-height:34px;transition:background .15s,border-color .15s,color .15s}+.seg:hover{border-color:var(--accent);color:var(--text)}+.seg:active{transform:translateY(1px)}+.seg:focus-visible{outline:2px solid var(--accent);outline-offset:2px}+.seg.on{background:color-mix(in srgb, var(--accent) 22%, transparent);border-color:var(--accent);color:var(--text)}+@media (max-width:600px){.seg{min-height:44px;padding:10px 14px}} .settings-card{background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb, var(--accent) 45%, transparent);border-radius:10px;padding:.9rem 1.1rem 1rem;margin-bottom:1rem} .card-title{font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:10px;font-family:'Familjen Grotesk',sans-serif} .card-status{font-size:11px;color:var(--text-2);font-weight:400}@@ -245,6 +253,19 @@ dialog.settings{max-width:560px;overflow:hidden;padding:0} <div class="toggle" id="tglBadge" role="switch" aria-checked="true"></div></div> </div> + <div class="settings-card">+ <div class="card-title">Activity indicator <span class="card-status" id="stActivity"></span></div>+ <div class="card-desc">How Fusion's taskbar button signals that Adom is running a verb. <b>Progress bar</b> paints a real bar paced by how long <i>this machine</i> has actually taken for that verb, and clears the moment it finishes or fails - so you can tell "nearly done" from "just started" at a glance. <b>Flash</b> is the older attention flash: it says something is happening, and nothing more. A verb with no measured history yet runs the bar indeterminate rather than inventing a percentage. Run times are recorded per verb to <code>verb-times.jsonl</code>; <code>fusion_verb_times</code> reports p50/p90 per verb.</div>+ <div class="divider"></div>+ <div class="ctlrow"><span class="lbl">Indicator</span>+ <div class="segmented" id="segActivity" role="radiogroup" aria-label="Activity indicator">+ <button class="seg" data-mode="progress" role="radio">Progress bar</button>+ <button class="seg" data-mode="flash" role="radio">Flash</button>+ <button class="seg" data-mode="both" role="radio">Both</button>+ <button class="seg" data-mode="none" role="radio">None</button>+ </div></div>+ </div>+ <div class="settings-card"> <div class="card-title">Surfaces demo <span class="card-status" id="stPart"></span></div> <div class="card-desc">The Surfaces section walks one real adom basic part through every content type. It defaults to a DIFFERENT part than the Adom Bridge web-control demo uses, so clicking through proves the flow generalizes to a new component rather than re-proving one file. Change the wiki page below to demo any basic part that publishes a <code>.lbr</code> (and ideally a <code>.step</code> for the real 3D body).</div>@@ -381,6 +402,21 @@ $('#btnSettings').onclick=async()=>{ set('#tglBadge',bd); $('#stBadge').textContent=bd?'badge on while driving':'badge off'; $('#stBadge').className='card-status'+(bd?' ok':'');+ const am=(s.activityIndicator||'progress');+ document.querySelectorAll('#segActivity .seg').forEach(b=>{+ b.classList.toggle('on', b.getAttribute('data-mode')===am);+ b.setAttribute('aria-checked', b.getAttribute('data-mode')===am ? 'true':'false');+ b.onclick=async()=>{+ const mode=b.getAttribute('data-mode');+ document.querySelectorAll('#segActivity .seg').forEach(x=>{+ x.classList.toggle('on', x===b); x.setAttribute('aria-checked', x===b?'true':'false');});+ $('#stActivity').textContent=mode==='none'?'no taskbar signal':(mode+' while a verb runs');+ try{ await act('set_activity_indicator',{mode}); toast('activity indicator: '+mode); }+ catch(e){ toast('could not set indicator: '+e,'err'); }+ };+ });+ $('#stActivity').textContent=am==='none'?'no taskbar signal':(am+' while a verb runs');+ $('#stActivity').className='card-status'+(am!=='none'?' ok':''); $('#inpPart').value=dashSettings.demoPart||'aravk/in-s63btg'; $('#stPart').textContent=(dashSettings.demoPart||'').split('/').pop().toUpperCase(); const dlg=$('#dlgSettings');
dev-skills/fusion-verb-timing/SKILL.mdadded+82@@ -0,0 +1,82 @@+---+name: fusion-verb-timing+description: DEVELOPER-only. USE WHEN adding or changing any slow Fusion verb, WHEN a caller reports something is hanging or "not responding", or WHEN you need to know how long a verb really takes on a given machine. Covers the two timing stores (rolling window for live estimates, append-only log for later analysis), the taskbar activity indicator paced by those measurements, and the rule that a guessed number must never be presented as a measured one. Trigger words - verb timing, how long does it take, p50, p90, verb-times.jsonl, activity indicator, taskbar progress, progress bar, slow verb, estimate, measured history.+user-invocable: false+---++# Measuring verb run times, and showing them honestly++Two stores, because they answer different questions. Keep both.++| store | file | answers |+|---|---|---|+| rolling window (last 10 per key) | `~/.adom/fusion-bridge/timings.json` | "how long will THIS verb take, on THIS machine, right now?" |+| append-only log (every run) | `~/.adom/fusion-bridge/verb-times.jsonl` | "what changed?", "which verb owns the 546s?", "did 1.9.9x regress the 3D package?" |++A rolling window can only answer what it still remembers. A log can be re-analysed for a+question nobody thought to ask up front, which is most of the interesting ones.++## Recording++`dispatch_command` times EVERY verb and calls `timings.log_run(verb, seconds, ok)`, which+writes both stores. Nothing to remember when adding a verb: it is measured automatically.++Record failures too. A verb that fails FAST is a different bug from one that is slow, and you+cannot tell them apart from durations alone. That is why the log carries `ok`.++Reading it back:++```python+timings.verb_estimate("show_symbol") # (seconds, "measured" | "typical" | "unknown")+timings.verb_report() # per verb: count / p50 / p90 / max / failures+```++`fusion_verb_times` exposes the report as a verb, so the numbers are readable from the+Dashboard instead of requiring someone to SSH in and parse a file.++**p90 is the column to look at when hunting a slow surface.** A p50 of 15s next to a p90 of+400s is not "sometimes slow", it is two different code paths wearing one name, usually a cold+first-open versus a warm re-open.++## Showing it: the activity indicator++`fusion_set_activity_indicator {mode}` - `progress` | `flash` | `both` | `none`, sticky per+user, and a card in the Dashboard's Settings.++- **progress** paints a real taskbar bar via `desktop_taskbar {progress:{state,value}}`,+ paced by `verb_estimate` for that specific verb, and clears the instant the verb returns.+ On failure it flashes `error` (red) for a beat first, so an outcome is visible even if+ nobody was watching the moment it happened.+- **flash** is the older attention flash. It says "something is happening" and nothing else.+- The bar runs 0 to 95 and **holds**. It never reaches 100 while work continues, because a+ bar sitting at 100 during live work is the same lie as an ETA of zero.++## The honesty rule, which is the whole point++**A verb with no measured history runs the bar INDETERMINATE (marquee), never a percentage.**++We have made the opposite mistake twice, and both times it cost a real debugging session:++- an estimate revised as `elapsed * 1.25` pinned percent at exactly 80 forever, so a stuck run+ looked identical to a healthy one;+- a "couple of minutes" string that was never measured, never narrowed, and could not be drawn+ as a bar at all.++The confidence ladder is `measured` (3 or more real samples here) > `typical` (1-2 samples, or+a seeded literal) > `unknown`. Render `unknown` as motion without a number. ab dims a+non-`measured` block in its own window for the same reason: a bridge that guesses should never+look as authoritative as one that measured.++## When a caller says "it is hanging"++Check the measurements before believing it:++1. `fusion_verb_times` - is this verb normally slow? A 546s p90 means the caller is impatient,+ not that the bridge is broken.+2. `fusion_state.timings` - what has THIS machine actually done?+3. `errorCode` - `fusion_addin_busy` carries `elapsedSeconds`; compare it against p90 before+ concluding anything is wrong. Past roughly 2x p90 it is fair to call it stuck.++That comparison is the difference between "slow" and "stuck", and it is a measurement rather+than a judgement call. See `fusion-dialogs` for the neighbouring trap: a modal holding the main+thread looks exactly like a slow verb from outside.
handlers/taskbar_overlay.py+69@@ -140,3 +140,72 @@ def clear_driving_badge(hwnd=None) -> bool: if ok and (hwnd is None or _BADGED_HWND == int(hwnd)): _BADGED_HWND = None return ok+++# ── Activity indicator: taskbar PROGRESS driven by measured verb durations ──────────────+# John's experiment (2026-08-21): instead of flashing the Fusion taskbar button to say+# "Adom is doing something", draw a real PROGRESS BAR for the duration the verb is actually+# expected to take, and clear it the moment the verb finishes or errors.+#+# The estimate is not invented: timings.py keeps a rolling measured history per verb on THIS+# machine, so the bar reflects what this box actually does. Before a verb has any history the+# bar runs INDETERMINATE (a marquee) rather than pretending to a percentage - the same+# honesty rule as the progress contract: never dress a guess as a measurement.++_PROGRESS_STOP = {} # verb -> threading.Event, so a finished verb can stop its ticker+++def _taskbar_progress(state: str, value=None) -> bool:+ """Paint (or clear) the progress bar on Fusion's own taskbar button."""+ try:+ from handlers.fusion_ui import _find_fusion_hwnd+ hwnd = _find_fusion_hwnd()+ if not hwnd:+ return False+ prog = {"state": state}+ if value is not None:+ prog["value"] = max(0, min(100, int(value)))+ return _taskbar({"hwnd": int(hwnd), "progress": prog})+ except Exception:+ return False+++def start_activity_progress(verb: str, estimate_s: float, confidence: str = "typical"):+ """Begin a progress bar for `verb`, paced by its measured estimate. Returns a stopper."""+ import threading+ import time as _t+ stop = threading.Event()+ _PROGRESS_STOP[verb] = stop++ def _tick():+ started = _t.time()+ # No history yet -> marquee. A percentage we cannot justify is worse than no number.+ if not estimate_s or confidence == "unknown":+ _taskbar_progress("indeterminate")+ while not stop.wait(1.0):+ pass+ return+ while not stop.wait(0.7):+ elapsed = _t.time() - started+ # 0 -> 95 across the estimate, then hold: the run is not done until it says so,+ # and a bar that hits 100 while work continues is the lie we already fixed once.+ pct = min(95, int(95 * elapsed / estimate_s)) if estimate_s > 0 else 0+ if not _taskbar_progress("normal", pct):+ return+ threading.Thread(target=_tick, daemon=True).start()+ return stop+++def end_activity_progress(verb: str, failed: bool = False):+ """Stop the bar. On failure flash red briefly so the outcome is visible, then clear."""+ import time as _t+ stop = _PROGRESS_STOP.pop(verb, None)+ if stop:+ stop.set()+ try:+ if failed:+ _taskbar_progress("error", 100)+ _t.sleep(1.2)+ _taskbar_progress("none")+ except Exception:+ pass
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.112"+DASH_VERSION="1.9.113" 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.112",+ "version": "1.9.113", "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+10−7@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.112",+ "version": "1.9.113", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {@@ -11,6 +11,10 @@ "uninstall": "./uninstall.sh" }, "tags": [+ "ab",+ "bridge",+ "fusion",+ "desktop-app", "fusion360", "autodesk", "cad",@@ -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+113−1@@ -5719,6 +5719,8 @@ def _handle_set_seat_takeover(fusion_info: dict, args: dict) -> dict: # lines earlier) is the import-time NameError that crash-looped the bridge on 2026-07-22. COMMAND_HANDLERS["set_seat_takeover"] = _handle_set_seat_takeover COMMAND_HANDLERS["set_driving_badge"] = _handle_set_driving_badge+COMMAND_HANDLERS["set_activity_indicator"] = _handle_set_activity_indicator+COMMAND_HANDLERS["verb_times"] = _handle_verb_times COMMAND_HANDLERS["set_window_sizing"] = _handle_set_window_sizing @@ -8666,6 +8668,115 @@ def _register_docs_we_opened(before: set) -> None: pass ++_ACTIVITY_MODES = ("flash", "progress", "both", "none")+++def _activity_mode() -> str:+ """How to signal 'Adom is driving Fusion' on the taskbar. Sticky, user-settable."""+ try:+ m = str(_get_bridge_pref("activityIndicator", "progress") or "progress").lower()+ except Exception:+ m = "progress"+ return m if m in _ACTIVITY_MODES else "progress"+++def _time_and_indicate(command: str, args: dict, run):+ """Time EVERY verb, log it, and paint the chosen taskbar activity indicator.++ John's experiment (2026-08-21): a flash says "something is happening" and nothing more.+ A progress bar paced by this machine's OWN measured history for THIS verb says how long+ it will take, and disappears the moment the verb finishes or fails. Modes: flash |+ progress | both | none.++ The estimate comes from timings.verb_estimate; a verb with no history runs the bar+ INDETERMINATE rather than inventing a percentage.+ """+ import time as _t+ import timings as _tm+ mode = _activity_mode()+ slow = command in LONG_RUNNING_COMMANDS or command in _WINDOW_RAISING+ est, conf = _tm.verb_estimate(command)+ stop = None+ if slow and mode in ("progress", "both"):+ try:+ from handlers.taskbar_overlay import start_activity_progress+ stop = start_activity_progress(command, est, conf)+ except Exception:+ stop = None+ if slow and mode in ("flash", "both"):+ try:+ _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",+ "flash": "until_focused"}, timeout=10)+ except Exception:+ pass+ t0 = _t.time()+ failed = False+ try:+ res = run()+ failed = isinstance(res, dict) and res.get("success") is False+ return res+ except Exception:+ failed = True+ raise+ finally:+ took = _t.time() - t0+ try:+ _tm.log_run(command, took, ok=not failed)+ except Exception:+ pass+ if stop is not None or (slow and mode in ("flash", "both")):+ try:+ from handlers.taskbar_overlay import end_activity_progress+ end_activity_progress(command, failed=failed)+ if mode in ("flash", "both"):+ _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",+ "flash": "stop"}, timeout=8)+ except Exception:+ pass++++def _handle_set_activity_indicator(fusion_info: dict, args: dict) -> dict:+ """Read or set how the taskbar signals that Adom is driving Fusion."""+ if "mode" in args:+ mode = str(args.get("mode") or "").lower()+ if mode not in _ACTIVITY_MODES:+ return {"success": False,+ "error": "mode must be one of %s" % ", ".join(_ACTIVITY_MODES),+ "errorCode": "bad_activity_mode"}+ _set_bridge_pref("activityIndicator", mode)+ if mode in ("none", "flash"):+ try:+ from handlers.taskbar_overlay import _taskbar_progress+ _taskbar_progress("none") # clear immediately, never leave a stale bar+ except Exception:+ pass+ if mode in ("none", "progress"):+ try:+ _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",+ "flash": "stop"}, timeout=8)+ except Exception:+ pass+ return {"success": True, "activityIndicator": _activity_mode(),+ "modes": list(_ACTIVITY_MODES),+ "_hint": ("Sticky (~/.adom/fusion-bridge/prefs.json). progress = a taskbar bar paced "+ "by THIS machine's measured time for that verb; flash = the old "+ "attention flash; both; none. A verb with no measured history yet runs "+ "the bar indeterminate rather than inventing a percentage.")}+++def _handle_verb_times(fusion_info: dict, args: dict) -> dict:+ """The measured run-time report, aggregated from the append-only verb log."""+ import timings as _tm+ rep = _tm.verb_report(int(args.get("limit") or 40))+ return {"success": True, "verbs": rep, "count": len(rep),+ "_hint": ("Aggregated from ~/.adom/fusion-bridge/verb-times.jsonl (every run, with "+ "its outcome). p90 is the useful column when hunting a slow surface; "+ "`failures` next to a healthy p50 usually means a verb that fails FAST, "+ "which is a different bug from one that is slow.")}++ def dispatch_command(command: str, args: dict, caller_identity: dict = None) -> dict: """Thin wrapper: run the verb, then guarantee the windowEtiquette annotation lands on EVERY backgrounded window-raising result - the inner dispatcher's closure only covered@@ -8677,7 +8788,8 @@ def dispatch_command(command: str, args: dict, caller_identity: dict = None) -> # diff after, so every document our verbs create is attributable to us - and only ours. _cmd_short = command[7:] if command.startswith("fusion_") else command _docs_before = _snapshot_open_docs() if _cmd_short in _WINDOW_RAISING else None- res = _dispatch_command_inner(command, args, caller_identity)+ res = _time_and_indicate(_cmd_short, args,+ lambda: _dispatch_command_inner(command, args, caller_identity)) if _docs_before is not None: _register_docs_we_opened(_docs_before) # ── capture:true - ONE call = ONE consent = surface opened AND proven (issue #30) ──
timings.py+75@@ -225,3 +225,78 @@ def sentence(): if not bits: return None return "on this machine Fusion starts in " + ", ".join(bits) + " (measured)"+++# ── Per-VERB run times ─────────────────────────────────────────────────────────────────+# John, 2026-08-21: "you need to start tracking the length of time it takes to run verbs+# and possibly ... store times in the log and analyze the log later to calc run times".+#+# Two stores on purpose, because they answer different questions:+# - the ROLLING window above (last _KEEP samples) drives the live estimate: recent, cheap,+# and it tracks a machine that got slower or faster.+# - the APPEND-ONLY LOG below is the historical record: every run with its outcome, so a+# question nobody thought to ask up front ("did the 3D package regress after 1.9.9x?",+# "which verb owns the 546s?") is answerable later instead of lost.+# A rolling window can only answer what it still remembers; a log can be re-analysed.++_RUNLOG_MAX_BYTES = 4 * 1024 * 1024+++def _runlog_path():+ return _path().parent / "verb-times.jsonl"+++def log_run(verb: str, seconds: float, ok: bool = True, note: str = "") -> None:+ """Append one verb run to the historical log AND the rolling window. Never raises."""+ try:+ record("verb:" + verb, seconds)+ p = _runlog_path()+ try:+ if p.exists() and p.stat().st_size > _RUNLOG_MAX_BYTES:+ p.replace(str(p) + ".1") # one generation back, never unbounded+ except Exception:+ pass+ with open(p, "a", encoding="utf-8") as f:+ f.write(json.dumps({"at": int(time.time()), "verb": verb,+ "seconds": round(float(seconds), 2),+ "ok": bool(ok), "note": note[:120]}) + "\n")+ except Exception:+ pass+++def verb_estimate(verb: str):+ """(seconds, confidence) for a verb from its rolling window. (0,'unknown') if new."""+ st = stats("verb:" + verb)+ if st["count"] >= 3 and st["p50"]:+ return float(st["p50"]), "measured"+ if st["count"] >= 1 and st["p50"]:+ return float(st["p50"]), "typical"+ return 0.0, "unknown"+++def verb_report(limit: int = 40) -> dict:+ """Aggregate the historical log: per verb count / p50 / p90 / max / failures.++ This is the 'analyse the log later' half. Cheap enough to expose from a verb, so the+ numbers are readable from the Dashboard instead of requiring someone to SSH in.+ """+ runs = {}+ try:+ with open(_runlog_path(), "r", encoding="utf-8") as f:+ for line in f:+ try:+ r = json.loads(line)+ except Exception:+ continue+ d = runs.setdefault(r.get("verb", "?"), {"secs": [], "fail": 0})+ d["secs"].append(float(r.get("seconds") or 0))+ if not r.get("ok"):+ d["fail"] += 1+ except Exception:+ return {}+ out = {}+ for verb, d in runs.items():+ s = d["secs"]+ out[verb] = {"count": len(s), "p50": _pct(s, 0.5), "p90": _pct(s, 0.9),+ "max": max(s) if s else None, "failures": d["fail"]}+ return dict(sorted(out.items(), key=lambda kv: -(kv[1]["p90"] or 0))[:limit])