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
fusion_log_tail (standard log_tail)
6 files changed
+100−7
CHANGELOG.md+7@@ -3,6 +3,13 @@ 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.13.29 (unreleased)++- `fusion_log_tail` (the standard's log_tail, agreed on eda-bridge-standard#29): Fusion's own log since a byte offset,+ every line or with `errorsOnly` only error lines and alert toasts, at most `maxBytes`, with `nextOffset` for the next+ read and `rotated` when Fusion started a new log. `fusion_alerts` takes the same `sinceOffset` cursor and now+ always answers `nextOffset`.+ ## 1.13.28 (2026-10-30) - `fusion_verb_times` and `fusion_prewarm` answer the standard's shapes (standard 0.26.0, #29). verb_times: `verbs` is
bridge.json+1@@ -192,6 +192,7 @@ "fusion_list_nets", "fusion_list_text_commands", "fusion_live_feed",+ "fusion_log_tail", "fusion_make_3d_package", "fusion_make_showcase_project", "fusion_make_test_project",
contracts/describe.json+31−1@@ -165,12 +165,19 @@ { "example": {}, "input": {+ "errorsOnly": "bool: only error lines and alert toasts",+ "maxBytes": "int, default 400000",+ "sinceOffset": "int: byte offset into Fusion's log (the standard's log_tail): every line since it", "sinceSec": "optional float (default 300)" }, "longRunning": false, "name": "fusion_alerts", "output": {- "alerts": "[{at,text}]"+ "alerts": "[{at,text}]",+ "lines": "[str] (with sinceOffset)",+ "logFile": "str",+ "nextOffset": "int: pass as sinceOffset next time",+ "rotated": "bool: a new log was started, so the read began at 0" }, "statusVerb": null, "summary": "Fusion's OWN alert toasts, read from its log. Fusion draws error/warning toasts in CEF: they are invisible to Win32 enumeration AND to UIA, so a verb can look successful while Fusion is visibly complaining. This reads them, newest last.",@@ -3653,6 +3660,29 @@ "summary": "A ~1 fps look INTO Fusion while the bridge drives it, kept as a BOX-SIDE ring buffer (~10 min). Each tick records the Fusion main window (kind:main - the video) and every owned popup over it (kind:popup - dialogs, Browser/tool panels, the Adom banner; Fusion draws these as separate windows a main capture omits), each a path on this machine tied to its tick. Drain from the container with {action:frames, since:<tick>}, read_file each path, and publish where a human looks - tools/live_feed_drain.py does exactly that into shotlog. Proof frames are NOT here: fusion_prove_result evidence[] carries kind:proof with step labels, so the three kinds are distinguishable at the source. Exists so a human can see open tabs and dialogs during boot or a restart instead of guessing.", "timeoutSeconds": 15 },+ {+ "example": {+ "errorsOnly": true,+ "sinceOffset": 0+ },+ "input": {+ "errorsOnly": "bool",+ "maxBytes": "int, default 400000",+ "sinceOffset": "int, default 0 (the start of the current log)"+ },+ "longRunning": false,+ "name": "fusion_log_tail",+ "output": {+ "alerts": "[{at,text}]",+ "lines": "[str]",+ "logFile": "str",+ "nextOffset": "int",+ "rotated": "bool"+ },+ "statusVerb": null,+ "summary": "What Fusion wrote to its own log since a byte offset (the standard's log_tail): every line, or with errorsOnly only error lines and alert toasts, plus the alerts among them and nextOffset for the next read. Catches a verb that looked successful while Fusion complained: Fusion draws its toasts in CEF, which neither window enumeration nor UI Automation sees. The same reader as fusion_alerts.",+ "timeoutSeconds": 30+ }, { "example": { "captureLabel": "R_4k7",
describe.py+3−1@@ -808,7 +808,9 @@ _T = [ ("fusion_board_info", "Raw board geometry XML (copper/arcs/lines) for the open board.", {}, {"rawXml": "str"}, 60, None, False, {}), ("fusion_alerts", "Fusion's OWN alert toasts, read from its log. Fusion draws error/warning toasts in CEF: they are invisible to Win32 enumeration AND to UIA, so a verb can look successful while Fusion is visibly complaining. This reads them, newest last.",- {"sinceSec": "optional float (default 300)"}, {"alerts": "[{at,text}]"}, 30, None, False, {}),+ {"sinceSec": "optional float (default 300)", "sinceOffset": "int: byte offset into Fusion's log (the standard's log_tail): every line since it", "maxBytes": "int, default 400000", "errorsOnly": "bool: only error lines and alert toasts"}, {"alerts": "[{at,text}]", "lines": "[str] (with sinceOffset)", "nextOffset": "int: pass as sinceOffset next time", "rotated": "bool: a new log was started, so the read began at 0", "logFile": "str"}, 30, None, False, {}),+ ("fusion_log_tail", "What Fusion wrote to its own log since a byte offset (the standard's log_tail): every line, or with errorsOnly only error lines and alert toasts, plus the alerts among them and nextOffset for the next read. Catches a verb that looked successful while Fusion complained: Fusion draws its toasts in CEF, which neither window enumeration nor UI Automation sees. The same reader as fusion_alerts.",+ {"sinceOffset": "int, default 0 (the start of the current log)", "maxBytes": "int, default 400000", "errorsOnly": "bool"}, {"lines": "[str]", "nextOffset": "int", "rotated": "bool", "alerts": "[{at,text}]", "logFile": "str"}, 30, None, False, {"sinceOffset": 0, "errorsOnly": True}), ("fusion_web_signin", "Sign a pup browser window into an Autodesk WEB surface (library.io, the hub portal at <hub>.autodesk360.com, Autodesk Account) via Google SSO. Fusion's DESKTOP app being signed in does NOT sign these in - they are separate web sessions. NEVER guesses a credential: a missing password or a 2FA challenge comes back as a typed stop (human_credential_required / second_factor_required / google_sso_unavailable) naming the one-time command the USER runs. The password never passes through this bridge or the AI - it is stored once with ab's credential_set and filled by pup in the background.", {"url": "required str (e.g. https://library.io/)", "sessionId": "optional str (default autodesk-web)", "provider": "optional str google|password (default google)", "username": "optional str (for the Google account chooser)"}, {"success": "bool", "errorCode": "str?", "url": "str", "steps": "[...]"}, 240, _STATUS, True, {"url": "https://library.io/"}),
rust/bridge/src/main.rs+57−5@@ -679,9 +679,11 @@ fn main() { } // Native since Phase 2: Fusion's own alert toasts from its AppLogFile, the // same answer the Python bridge gives (parser golden-tested against it).- "fusion_alerts" | "alerts" => {+ v @ ("fusion_alerts" | "alerts" | "fusion_log_tail" | "log_tail") => { let since = parsed["args"]["sinceSec"].as_f64().unwrap_or(300.0).max(1.0);- native_alerts(&**h, since)+ let mut a = parsed["args"].clone();+ if v.ends_with("log_tail") && a["sinceOffset"].is_null() { a["sinceOffset"] = json!(0); }+ native_alerts_args(&**h, since, &a) } // Native since Phase 2: the background-action ledger, a file the bridge appends. "fusion_action_ledger" | "action_ledger" => native_action_ledger(&**h, &parsed["args"]),@@ -799,10 +801,31 @@ fn proxy_with_timeout(sidecar: &Arc<Mutex<Option<Sidecar>>>, raw: &str, caller: /// no top-level window, so dialog enumeration cannot see them; its AppLogFile can. The log's /// stamps are LOCAL time, so the cutoff is computed in the same convention (the Python bridge /// compares `strptime(...).timestamp()` with `time.time()`, which is the same thing).-pub(crate) fn native_alerts(h: &dyn HostBoth, since_sec: f64) -> Value {+pub(crate) fn native_alerts(h: &dyn HostBoth, since_sec: f64) -> Value { native_alerts_args(h, since_sec, &Value::Null) }++/// The standard's log_tail on Fusion's AppLogFile: with sinceOffset, every line written since that+/// byte offset (errorsOnly: only error lines and alert toasts), at most maxBytes, and nextOffset to+/// pass next time. A log shorter than the offset was replaced by a new session's, so it reads from 0.+fn log_tail_text(path: &std::path::Path, since: u64, max: u64, errors_only: bool) -> (Vec<String>, u64, bool) {+ use std::io::{Read, Seek, SeekFrom};+ let Ok(mut f) = std::fs::File::open(path) else { return (vec![], since, false) };+ let size = f.metadata().map(|m| m.len()).unwrap_or(0);+ let rotated = since > size;+ let start = if rotated { 0 } else { since };+ let end = size.min(start + max);+ let mut buf = vec![0u8; (end - start) as usize];+ if f.seek(SeekFrom::Start(start)).is_err() || f.read_exact(&mut buf).is_err() { return (vec![], start, rotated); }+ // stop at the last whole line so the next read starts on one+ let cut = if end < size { buf.iter().rposition(|b| *b == b'\n').map(|i| i + 1).unwrap_or(0) } else { buf.len() };+ let text = String::from_utf8_lossy(&buf[..cut]).to_string();+ let lines = text.lines().filter(|l| !l.trim().is_empty()).filter(|l| !errors_only || l.contains("New Alert message") || l.to_lowercase().contains("error")).map(|l| l.chars().take(600).collect()).collect();+ (lines, start + cut as u64, rotated)+}++pub(crate) fn native_alerts_args(h: &dyn HostBoth, since_sec: f64, args: &Value) -> Value { let hint = "Fusion draws many errors as in-canvas CEF overlays with NO top-level window, so check_dialogs alone cannot see them; these come from Fusion's own AppLogFile 'New Alert message' entries. An empty list means Fusion raised no toast in the window - not that a command succeeded."; let Some(dir) = h.fusion_log_dir() else {- return json!({"success": true, "alerts": [], "logFile": Value::Null, "shell": "rust",+ return json!({"success": true, "alerts": [], "lines": [], "nextOffset": 0, "logFile": Value::Null, "shell": "rust", "diagnostics": {"why": "no AppLogFile*.log found under any base"}, "_hint": hint}); }; // newest AppLogFile*.log in that dir@@ -834,12 +857,41 @@ pub(crate) fn native_alerts(h: &dyn HostBoth, since_sec: f64) -> Value { })().unwrap_or_default(); let now_local_as_utc = chrono::Utc::now().timestamp() + chrono::Local::now().offset().local_minus_utc() as i64; let cutoff = now_local_as_utc - since_sec as i64;+ let log_file = path.to_string_lossy().replace('\\', "/");+ if let Some(off) = args["sinceOffset"].as_u64() {+ let max = args["maxBytes"].as_u64().unwrap_or(400_000).clamp(1_000, 4_000_000);+ let (lines, next, rotated) = log_tail_text(&path, off, max, args["errorsOnly"].as_bool() == Some(true));+ let alerts = fusion_core::alerts::parse_alert_lines(lines.iter().map(|s| s.as_str()), i64::MIN);+ return json!({"success": true, "mutated": false, "lines": lines, "nextOffset": next, "rotated": rotated, "alerts": alerts, "logFile": log_file, "shell": "rust",+ "_hint": "Fusion's own log since sinceOffset (the standard's log_tail). Pass nextOffset next time; rotated:true means Fusion started a new log, so this read began at 0. alerts are the toasts among the lines."});+ } let alerts = fusion_core::alerts::parse_alert_lines(text.lines(), cutoff);- json!({"success": true, "alerts": alerts, "logFile": path.to_string_lossy().replace('\\', "/"), "shell": "rust",+ let next = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);+ json!({"success": true, "alerts": alerts, "logFile": log_file, "nextOffset": next, "shell": "rust", "diagnostics": {"baseFound": dir.to_string_lossy()}, "_hint": hint}) } +#[cfg(test)]+mod log_tail_tests {+ #[test]+ fn a_cursor_reads_each_line_once_and_restarts_on_a_new_log() {+ let p = std::env::temp_dir().join(format!("adom-logtail-{}.log", std::process::id()));+ std::fs::write(&p, "20261030T120000 start\n20261030T120001 New Alert message. Short Message: Bad thing\n20261030T120002 error: x\n").unwrap();+ let (all, next, rot) = super::log_tail_text(&p, 0, 400_000, false);+ assert_eq!((all.len(), rot), (3, false));+ let (none, n2, _) = super::log_tail_text(&p, next, 400_000, false);+ assert!(none.is_empty() && n2 == next);+ let (errs, _, _) = super::log_tail_text(&p, 0, 400_000, true);+ assert_eq!(errs.len(), 2);+ let (part, n3, _) = super::log_tail_text(&p, 0, 30, false);+ assert_eq!((part.len(), n3), (1, 22)); // only the whole first line+ let (again, _, rot) = super::log_tail_text(&p, 10_000, 400_000, false);+ assert!(rot && again.len() == 3);+ let _ = std::fs::remove_file(&p);+ }+}+ /// `fusion_action_ledger`: READ-ONLY view of `<state>/actions.jsonl`, every background action /// the bridge took on the host app (update clicks, seat takeovers, dialog dismissals, doc /// closes, WM_CLOSEs), so a caller on a relay is not blind to it. Same filters and shape as
tests/run_verb_tests.py+1@@ -432,6 +432,7 @@ for v in ("fusion_stop", "fusion_kill", "fusion_signin", "fusion_signin_2fa", "f t("fusion_action_ledger", "daily", {"limit": 5}, {"ok": True}) t("fusion_addin_status", "daily", {}, {"ok": True}) t("fusion_alerts", "daily", {"sinceSec": 300}, {"ok": True, "fields": ["alerts"]})+t("fusion_log_tail", "daily", {"sinceOffset": 0, "maxBytes": 20000, "errorsOnly": True}, {"ok": True, "fields": ["lines", "nextOffset"]}, "read-only: Fusion's own log since an offset") t("fusion_verb_times", "daily", {"limit": 20}, {"ok": True, "fields": ["verbs", "since"]}) t("fusion_live_feed", "daily", {"action": "poll"}, {"ok": True}, "one poll of the feed; no waiting") t("fusion_prove_result", "daily", {}, {"okOrCode": "no_prove_run"}, "shape only: the last completed run, or an honest 'none yet'")