use std::fs;
use std::io::Write;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
pub const MAX_EVENTS: usize = 1000;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ActivityEvent {
pub ts_ms: i64,
#[serde(flatten)]
pub payload: ActivityPayload,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ActivityPayload {
RuleRecalled {
rule_id: String,
rule_title: String,
score: f32,
took_ms: u64,
},
RuleInjected {
rule_count: u32,
prompt_chars: u32,
intent_summary: String,
},
RuleReinforced {
rule_id: String,
rule_title: String,
prev_strength: f32,
new_strength: f32,
reason: String,
},
RetrievalEmbedding { hits: u32, took_ms: u64 },
EmbedCapReached { cap: u32, used: u32 },
EmbeddingFallback { reason: String },
}
fn now_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
}
fn log_path() -> Option<PathBuf> {
crate::paths::data_home()
.ok()
.map(|dir| dir.join("activity.jsonl"))
}
pub fn record(payload: ActivityPayload) {
let Some(path) = log_path() else {
return;
};
let event = ActivityEvent {
ts_ms: now_ms(),
payload,
};
let Ok(line) = serde_json::to_string(&event) else {
return;
};
let _ = append_with_cap(&path, &line);
}
fn append_with_cap(path: &std::path::Path, line: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let existing = fs::read_to_string(path).unwrap_or_default();
if existing.lines().count() >= MAX_EVENTS {
let mut kept: Vec<&str> = existing.lines().collect();
let drop = kept.len().saturating_sub(MAX_EVENTS - 1);
kept.drain(..drop);
let mut out = kept.join("\n");
if !out.is_empty() {
out.push('\n');
}
out.push_str(line);
out.push('\n');
fs::write(path, out)?;
return Ok(());
}
let mut f = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(f, "{line}")?;
Ok(())
}
pub fn tail(n: usize) -> Vec<ActivityEvent> {
let Some(path) = log_path() else {
return Vec::new();
};
let Ok(raw) = fs::read_to_string(&path) else {
return Vec::new();
};
raw.lines()
.rev()
.take(n)
.filter_map(|l| serde_json::from_str(l).ok())
.collect()
}
pub fn record_to(path: &std::path::Path, payload: ActivityPayload) -> std::io::Result<()> {
let event = ActivityEvent {
ts_ms: now_ms(),
payload,
};
let line = serde_json::to_string(&event)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
append_with_cap(path, &line)
}
pub fn tail_from(path: &std::path::Path, n: usize) -> Vec<ActivityEvent> {
let Ok(raw) = fs::read_to_string(path) else {
return Vec::new();
};
raw.lines()
.rev()
.take(n)
.filter_map(|l| serde_json::from_str(l).ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writer_caps_at_max_events() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("activity.jsonl");
for i in 0..=MAX_EVENTS {
record_to(
&path,
ActivityPayload::RuleRecalled {
rule_id: format!("r{i}"),
rule_title: "t".into(),
score: 0.1,
took_ms: 1,
},
)
.unwrap();
}
let events = tail_from(&path, MAX_EVENTS + 50);
assert_eq!(
events.len(),
MAX_EVENTS,
"file should be capped at {MAX_EVENTS} entries"
);
if let ActivityPayload::RuleRecalled { rule_id, .. } = &events[0].payload {
assert_eq!(rule_id, &format!("r{MAX_EVENTS}"));
} else {
panic!("unexpected payload kind on top");
}
}
#[test]
fn tail_returns_newest_first() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("activity.jsonl");
record_to(
&path,
ActivityPayload::RuleInjected {
rule_count: 1,
prompt_chars: 10,
intent_summary: "first".into(),
},
)
.unwrap();
record_to(
&path,
ActivityPayload::RuleInjected {
rule_count: 2,
prompt_chars: 20,
intent_summary: "second".into(),
},
)
.unwrap();
let events = tail_from(&path, 10);
assert_eq!(events.len(), 2);
if let ActivityPayload::RuleInjected { intent_summary, .. } = &events[0].payload {
assert_eq!(intent_summary, "second");
} else {
panic!("expected RuleInjected on top");
}
}
#[test]
fn embedding_fallback_round_trips_sanitized_reason() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("activity.jsonl");
record_to(
&path,
ActivityPayload::EmbeddingFallback {
reason: "network".into(),
},
)
.unwrap();
let events = tail_from(&path, 10);
assert_eq!(events.len(), 1);
if let ActivityPayload::EmbeddingFallback { reason } = &events[0].payload {
assert_eq!(reason, "network");
} else {
panic!("expected EmbeddingFallback on top");
}
}
}