Files
huskies/server/src/chatbot_log.rs
T

190 lines
6.4 KiB
Rust
Raw Normal View History

//! 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<PathBuf>,
/// `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<ChatBotLogState>,
}
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<ChatBotLog> = 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"
);
}
}