← Commit history

fusion_log_tail (standard log_tail)

Colby Knox ·619f985d0a ·12d ago ·parent 1370c4b
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'")