//! Lightweight, dependency-free diagnostics sink for the orchestrator rendezvous. //! //! The orchestrator already emits best-effort traces through `eprintln!` (see //! [`crate::orchestrator`]). When IdeA is launched by **clicking the AppImage**, //! that stderr is discarded — so an inter-agent block (an `ask` that never gets //! its `idea_reply`) leaves no retrievable trace. This module mirrors those //! traces to a **persistent log file** the user can hand back for diagnosis, //! while keeping the exact same "zero new dependency, never breaks the //! rendezvous" discipline: every write is best-effort and infallible. //! //! - [`set_log_path`] is called once by the composition root (`app-tauri`) with //! `/logs/idea.log`. Until then (and in tests) writes go to stderr //! only. //! - [`diag`] (and the [`diag!`] macro) timestamps a line with epoch-millis — the //! same clock as the conversation logs' `atMs` — and appends it to the file, //! always also echoing to stderr so a terminal launch and the test suite still //! see it. use std::fs::OpenOptions; use std::io::Write; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; /// The resolved log file path, set once at startup. `None` ⇒ stderr-only. static LOG_PATH: OnceLock = OnceLock::new(); /// Serialises concurrent appends so interleaved rendezvous beacons stay on their /// own lines (the orchestrator handles many connections in parallel). static WRITE_LOCK: Mutex<()> = Mutex::new(()); /// Points the diagnostics sink at `path`, creating its parent directory. /// /// Idempotent and infallible: a second call (or a failure to create the /// directory) is ignored — diagnostics must never break startup. Called by the /// composition root once the app-data directory is known. pub fn set_log_path(path: PathBuf) { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } let _ = LOG_PATH.set(path); } /// Current epoch-millis (same clock as the conversation logs' `atMs`). fn now_ms() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0) } /// Appends one timestamped diagnostic line. Best-effort and infallible: a missing /// path or an I/O error is swallowed (the line still reaches stderr). Never call /// in a hot loop — these are lifecycle beacons, not a metrics stream. pub fn diag(msg: impl std::fmt::Display) { let line = format!("{} {msg}", now_ms()); // Always echo to stderr: keeps a terminal launch and the test suite working, // and preserves the pre-existing `eprintln!` behaviour for these beacons. eprintln!("{line}"); if let Some(path) = LOG_PATH.get() { let _guard = WRITE_LOCK.lock(); if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) { let _ = writeln!(f, "{line}"); } } } /// `diag!("...", ...)` — formats then forwards to [`diag`]. Mirrors `eprintln!`. #[macro_export] macro_rules! diag { ($($arg:tt)*) => { $crate::diag::diag(format!($($arg)*)) }; }