← Commit history

log_tail: entries, next, truncated

Colby Knox ·846f879230 ·12d ago ·parent 619f985
5 files changed +30−15
CHANGELOG.md+2−2
@@ -6,8 +6,8 @@ Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/ ## 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+  `entries` [{level, text}] (alert, error, warning, info; `errorsOnly` keeps all but info), at most `maxBytes`+  (default 200000), `next` for the next read, `truncated`, 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)
contracts/describe.json+13−7
@@ -165,8 +165,8 @@     {       "example": {},       "input": {-        "errorsOnly": "bool: only error lines and alert toasts",-        "maxBytes": "int, default 400000",+        "errorsOnly": "bool: only errors, warnings and alert toasts",+        "maxBytes": "int, default 200000",         "sinceOffset": "int: byte offset into Fusion's log (the standard's log_tail): every line since it",         "sinceSec": "optional float (default 300)"       },@@ -174,10 +174,13 @@       "name": "fusion_alerts",       "output": {         "alerts": "[{at,text}]",+        "entries": "[{level, text}] (with sinceOffset)",         "lines": "[str] (with sinceOffset)",         "logFile": "str",+        "next": "int",         "nextOffset": "int: pass as sinceOffset next time",-        "rotated": "bool: a new log was started, so the read began at 0"+        "rotated": "bool: a new log was started, so the read began at 0",+        "truncated": "bool"       },       "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.",@@ -3666,18 +3669,21 @@         "sinceOffset": 0       },       "input": {-        "errorsOnly": "bool",-        "maxBytes": "int, default 400000",+        "errorsOnly": "bool: only errors, warnings and alert toasts",+        "maxBytes": "int, default 200000",         "sinceOffset": "int, default 0 (the start of the current log)"       },       "longRunning": false,       "name": "fusion_log_tail",       "output": {         "alerts": "[{at,text}]",+        "entries": "[{level: alert|error|warning|info, text}] newest last",         "lines": "[str]",         "logFile": "str",-        "nextOffset": "int",-        "rotated": "bool"+        "next": "int: pass as sinceOffset next time",+        "nextOffset": "int (same as next)",+        "rotated": "bool",+        "truncated": "bool: more was written than maxBytes"       },       "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.",
describe.py+2−2
@@ -808,9 +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)", "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, {}),+     {"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 200000", "errorsOnly": "bool: only errors, warnings and alert toasts"}, {"alerts": "[{at,text}]", "entries": "[{level, text}] (with sinceOffset)", "next": "int", "truncated": "bool", "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}),+     {"sinceOffset": "int, default 0 (the start of the current log)", "maxBytes": "int, default 200000", "errorsOnly": "bool: only errors, warnings and alert toasts"}, {"entries": "[{level: alert|error|warning|info, text}] newest last", "next": "int: pass as sinceOffset next time", "truncated": "bool: more was written than maxBytes", "rotated": "bool", "alerts": "[{at,text}]", "lines": "[str]", "nextOffset": "int (same as next)", "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+12−3
@@ -806,6 +806,12 @@ pub(crate) fn native_alerts(h: &dyn HostBoth, since_sec: f64) -> Value { native_ /// 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.+/// A log line's level as the standard's log_tail names it.+fn log_level(l: &str) -> &'static str {+    let low = l.to_lowercase();+    if l.contains("New Alert message") { "alert" } else if low.contains("error") || low.contains("exception") || low.contains("fail") { "error" } else if low.contains("warn") { "warning" } else { "info" }+}+ 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) };@@ -818,7 +824,7 @@ fn log_tail_text(path: &std::path::Path, since: u64, max: u64, errors_only: bool     // 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();+    let lines = text.lines().filter(|l| !l.trim().is_empty()).filter(|l| !errors_only || log_level(l) != "info").map(|l| l.chars().take(600).collect()).collect();     (lines, start + cut as u64, rotated) } @@ -859,10 +865,12 @@ pub(crate) fn native_alerts_args(h: &dyn HostBoth, since_sec: f64, args: &Value)     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 max = args["maxBytes"].as_u64().unwrap_or(200_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",+        let truncated = std::fs::metadata(&path).map(|m| m.len() > next).unwrap_or(false);+        let entries: Vec<Value> = lines.iter().map(|l| json!({"level": log_level(l), "text": l})).collect();+        return json!({"success": true, "mutated": false, "entries": entries, "next": next, "truncated": truncated, "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);@@ -884,6 +892,7 @@ mod log_tail_tests {         assert!(none.is_empty() && n2 == next);         let (errs, _, _) = super::log_tail_text(&p, 0, 400_000, true);         assert_eq!(errs.len(), 2);+        assert_eq!((super::log_level("x New Alert message. y"), super::log_level("a warning"), super::log_level("ok")), ("alert", "warning", "info"));         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);
tests/run_verb_tests.py+1−1
@@ -432,7 +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_log_tail", "daily", {"sinceOffset": 0, "maxBytes": 20000, "errorsOnly": True}, {"ok": True, "fields": ["entries", "next", "truncated"]}, "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'")