190 lines
6.4 KiB
Rust
190 lines
6.4 KiB
Rust
//! 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"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|