1//! The decision log: every judgment and what became of it, appended as one 2//! JSON object per line, so thresholds can be tuned from what actually 3//! happened rather than from guesses. 4 5use std::fs::{File, OpenOptions}; 6use std::io::{Read, Seek, SeekFrom, Write}; 7use std::path::PathBuf; 8use std::sync::Mutex; 9 10use jevhooks_events::Verdict; 11use serde::{Deserialize, Serialize}; 12use serde_json::Value; 13 14/// One deciding event and the daemon's answer to it. 15#[derive(Clone, Debug, Serialize, Deserialize)] 16pub struct DecisionRecord { 17 /// Seconds since the epoch. 18 pub time: f64, 19 pub session: String, 20 /// `bash` or `stop`. 21 pub kind: String, 22 /// What was judged: the command, or the end of the final message. 23 pub subject: String, 24 /// Present for a tool call, so its outcome can be joined to it. 25 pub tool_use_id: Option<String>, 26 pub verdict: Verdict, 27 pub line: String, 28 /// Jev's answers to the judgment's questions, with every option's 29 /// probability; absent when Jev was not asked or did not answer. 30 pub answers: Option<Value>, 31 /// The whole decision, Jev included. 32 pub ms: u64, 33 /// Jev's own round trip, when it answered. 34 pub jev_ms: Option<u64>, 35 pub input_tokens: Option<u64>, 36 pub usd: Option<f64>, 37 pub attempts: Option<u32>, 38 pub request_id: Option<String>, 39} 40 41/// What became of a judged tool call. 42#[derive(Clone, Debug, Serialize, Deserialize)] 43pub struct OutcomeRecord { 44 pub time: f64, 45 pub session: String, 46 pub tool_use_id: String, 47 /// `ran`, `failed`, or `denied` (the user or a rule refused it). 48 pub outcome: String, 49} 50 51#[derive(Clone, Debug, Serialize, Deserialize)] 52#[serde(tag = "record", rename_all = "lowercase")] 53pub enum Record { 54 Decision(Box<DecisionRecord>), 55 Outcome(OutcomeRecord), 56} 57 58pub struct DecisionLog { 59 path: PathBuf, 60 file: Mutex<File>, 61} 62 63/// How far back `recent` looks; a line is a few hundred bytes. 64const TAIL_BYTES: u64 = 512 * 1024; 65 66impl DecisionLog { 67 pub fn open(path: PathBuf) -> std::io::Result<Self> { 68 let file = OpenOptions::new().create(true).append(true).open(&path)?; 69 Ok(Self { path, file: Mutex::new(file) }) 70 } 71 72 pub fn path(&self) -> &std::path::Path { 73 &self.path 74 } 75 76 /// Appends one record. A log that cannot be written is reported, and the 77 /// decision it describes stands: the log is evidence, not a guard. 78 pub fn append(&self, record: &Record) { 79 let mut line = match serde_json::to_vec(record) { 80 Ok(line) => line, 81 Err(e) => return eprintln!("jevhooks: decision not logged: {e}"), 82 }; 83 line.push(b'\n'); 84 let mut file = self.file.lock().unwrap_or_else(std::sync::PoisonError::into_inner); 85 if let Err(e) = file.write_all(&line) { 86 eprintln!("jevhooks: decision not logged to {}: {e}", self.path.display()); 87 } 88 } 89 90 /// The last `count` records, oldest first. 91 pub fn recent(&self, count: usize) -> std::io::Result<Vec<Value>> { 92 let mut file = File::open(&self.path)?; 93 let len = file.metadata()?.len(); 94 let from = len.saturating_sub(TAIL_BYTES); 95 file.seek(SeekFrom::Start(from))?; 96 let mut text = String::new(); 97 file.take(TAIL_BYTES).read_to_string(&mut text).or_else(|e| { 98 // A tail that starts inside a multi-byte character is not UTF-8; 99 // nothing in it is worth an error. 100 if e.kind() == std::io::ErrorKind::InvalidData { Ok(0) } else { Err(e) } 101 })?; 102 let mut lines: Vec<&str> = text.lines().collect(); 103 if from > 0 && !lines.is_empty() { 104 lines.remove(0); // the first line of a tail is usually cut 105 } 106 let start = lines.len().saturating_sub(count); 107 Ok(lines[start..].iter().filter_map(|line| serde_json::from_str(line).ok()).collect()) 108 } 109} 110 111pub fn now() -> f64 { 112 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) 113}