//! Rotated log sink for the chat bot's PTY output. //! //! The chat bot runs Claude Code CLI in a PTY (see //! [`crate::llm::providers::claude_code`]) and previously logged every raw //! PTY line — spawn commands, reader-thread lifecycle, and truncated //! passthrough of each NDJSON line — via [`crate::slog!`], which meant this //! high-volume, low-signal output shared the bounded operational ring buffer //! and `server.log` with everything else, displacing genuinely operational //! lines. This sink gives that PTY output its own daily-rotated file //! (`chatbot-YYYY-MM-DD.log`) instead. use std::fs::OpenOptions; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; /// Number of daily log files to keep on disk before pruning older ones. const KEEP_DAYS: u64 = 7; /// Internal state for the on-disk log: directory and last-written date. struct ChatBotLogState { dir: Option, /// `YYYY-MM-DD` of the last written entry — used to detect day rollover. last_date: String, } /// Daily-rotated on-disk sink for the chat bot's PTY output. pub struct ChatBotLog { state: Mutex, } impl ChatBotLog { fn new() -> Self { Self { state: Mutex::new(ChatBotLogState { dir: None, last_date: String::new(), }), } } /// Set the directory for daily-rotated chat bot log files. /// /// Files are written as `chatbot-YYYY-MM-DD.log` inside `dir`. Files /// older than [`KEEP_DAYS`] are pruned immediately and again on each day /// rollover. Call once at startup after the project root is known. pub fn set_log_dir(&self, dir: PathBuf) { prune_old_logs(&dir, KEEP_DAYS); if let Ok(mut state) = self.state.lock() { state.dir = Some(dir); } } /// Append a line to today's chat bot log file, prefixed with an ISO 8601 /// UTC timestamp. No-ops silently until [`set_log_dir`] has been called. pub fn push_line(&self, message: &str) { let (log_path, prune_dir) = match self.state.lock() { Ok(mut state) => { if let Some(dir) = state.dir.clone() { let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); let path = dir.join(format!("chatbot-{today}.log")); let maybe_prune = if state.last_date != today { state.last_date = today; Some(dir) } else { None }; (Some(path), maybe_prune) } else { (None, None) } } Err(_) => (None, None), }; if let Some(ref dir) = prune_dir { prune_old_logs(dir, KEEP_DAYS); } if let Some(ref path) = log_path { let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { let _ = writeln!(file, "{timestamp} [pty-debug] {message}"); } } } } static GLOBAL: OnceLock = OnceLock::new(); /// Access the process-wide chat bot PTY log sink. pub fn global() -> &'static ChatBotLog { GLOBAL.get_or_init(ChatBotLog::new) } /// Delete daily `chatbot-*.log` files older than `keep_days` from `dir`. fn prune_old_logs(dir: &Path, keep_days: u64) { let cutoff = chrono::Utc::now() .checked_sub_signed(chrono::Duration::days(keep_days as i64)) .map(|t| t.format("%Y-%m-%d").to_string()) .unwrap_or_default(); let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.filter_map(|e| e.ok()) { let path = entry.path(); let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; // Match "chatbot-YYYY-MM-DD.log" if name.starts_with("chatbot-") && name.ends_with(".log") && name.len() == 22 { // SAFETY: "chatbot-" is 8 ASCII bytes, ".log" is 4, total 22 chars // means the middle 10 bytes are the date "YYYY-MM-DD" — all // ASCII, safe to slice. if let Some(date_part) = name.get(8..18) && date_part < cutoff.as_str() { let _ = std::fs::remove_file(&path); } } } } #[cfg(test)] mod tests { use super::*; fn fresh_sink() -> ChatBotLog { ChatBotLog::new() } #[test] fn push_line_before_set_log_dir_is_a_noop() { let sink = fresh_sink(); // Must not panic when no directory has been configured yet. sink.push_line("hello"); } #[test] fn push_line_writes_to_rotated_file() { let tmp = std::env::temp_dir().join(format!( "huskies_chatbot_log_test_{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos() )); std::fs::create_dir_all(&tmp).unwrap(); let sink = fresh_sink(); sink.set_log_dir(tmp.clone()); sink.push_line("raw line: {\"type\":\"assistant\"}"); let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); let path = tmp.join(format!("chatbot-{today}.log")); let contents = std::fs::read_to_string(&path).unwrap(); let _ = std::fs::remove_dir_all(&tmp); assert!(contents.contains("[pty-debug]")); assert!(contents.contains("raw line: {\"type\":\"assistant\"}")); } #[test] fn does_not_write_into_shared_ring_buffer() { let tmp = std::env::temp_dir().join(format!( "huskies_chatbot_log_isolation_test_{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos() )); std::fs::create_dir_all(&tmp).unwrap(); let sink = fresh_sink(); sink.set_log_dir(tmp.clone()); let marker = "chatbot_isolation_marker_9f31a"; sink.push_line(marker); let _ = std::fs::remove_dir_all(&tmp); let ring_hits = crate::log_buffer::global().get_recent(1000, Some(marker), None); assert!( ring_hits.is_empty(), "chat bot PTY output must not land in the shared server log ring buffer" ); } }