log.rsannotatedlog.rssource113 lines · 3.9 KB · raw

The decision log: every judgment and what became of it, appended as one JSON object per line, so thresholds can be tuned from what actually happened rather than from guesses.

5use std::fs::{File, OpenOptions};
6use std::io::{Read, Seek, SeekFrom, Write};
7use std::path::PathBuf;
8use std::sync::Mutex;
10use jevhooks_events::Verdict;
11use serde::{Deserialize, Serialize};
12use serde_json::Value;

One deciding event and the daemon's answer to it.

15#[derive(Clone, Debug, Serialize, Deserialize)]
16pub struct DecisionRecord {

Seconds since the epoch.

18    pub time: f64,
19    pub session: String,

bash or stop.

21    pub kind: String,

What was judged: the command, or the end of the final message.

23    pub subject: String,

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,

Jev's answers to the judgment's questions, with every option's probability; absent when Jev was not asked or did not answer.

30    pub answers: Option<Value>,

The whole decision, Jev included.

32    pub ms: u64,

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}

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,

ran, failed, or denied (the user or a rule refused it).

48    pub outcome: String,
49}
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}

How far back recent looks; a line is a few hundred bytes.

64const TAIL_BYTES: u64 = 512 * 1024;
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    }

Appends one record. A log that cannot be written is reported, and the 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    }

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}
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}