Skip to main content

ralph_core/diagnostics/
hook_runs.rs

1use crate::hooks::{HookRunResult, HookStreamOutput, HookSuspendMode};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::fs::{File, OpenOptions};
5use std::io::{BufWriter, Write};
6use std::path::Path;
7
8/// Final outcome category for a hook invocation.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum HookDisposition {
12    Pass,
13    Warn,
14    Block,
15    Suspend,
16}
17
18/// Structured diagnostics record persisted for each hook invocation.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct HookRunTelemetryEntry {
21    pub timestamp: DateTime<Utc>,
22    pub loop_id: String,
23    pub phase_event: String,
24    pub hook_name: String,
25    pub started_at: DateTime<Utc>,
26    pub ended_at: DateTime<Utc>,
27    pub duration_ms: u64,
28    pub exit_code: Option<i32>,
29    pub timed_out: bool,
30    pub stdout: HookStreamOutput,
31    pub stderr: HookStreamOutput,
32    pub disposition: HookDisposition,
33    pub suspend_mode: HookSuspendMode,
34    pub retry_attempt: u32,
35    pub retry_max_attempts: u32,
36}
37
38impl HookRunTelemetryEntry {
39    /// Creates a telemetry record from executor output and lifecycle metadata.
40    #[must_use]
41    pub fn from_run_result(
42        loop_id: impl Into<String>,
43        phase_event: impl Into<String>,
44        hook_name: impl Into<String>,
45        disposition: HookDisposition,
46        suspend_mode: HookSuspendMode,
47        retry_attempt: u32,
48        retry_max_attempts: u32,
49        run_result: &HookRunResult,
50    ) -> Self {
51        Self {
52            timestamp: Utc::now(),
53            loop_id: loop_id.into(),
54            phase_event: phase_event.into(),
55            hook_name: hook_name.into(),
56            started_at: run_result.started_at,
57            ended_at: run_result.ended_at,
58            duration_ms: run_result.duration_ms,
59            exit_code: run_result.exit_code,
60            timed_out: run_result.timed_out,
61            stdout: run_result.stdout.clone(),
62            stderr: run_result.stderr.clone(),
63            disposition,
64            suspend_mode,
65            retry_attempt,
66            retry_max_attempts,
67        }
68    }
69}
70
71/// JSONL writer for hook invocation telemetry (`hook-runs.jsonl`).
72pub struct HookRunLogger {
73    writer: BufWriter<File>,
74}
75
76impl HookRunLogger {
77    pub fn new(session_dir: &Path) -> std::io::Result<Self> {
78        let log_file = session_dir.join("hook-runs.jsonl");
79        let file = OpenOptions::new()
80            .create(true)
81            .append(true)
82            .open(log_file)?;
83
84        Ok(Self {
85            writer: BufWriter::new(file),
86        })
87    }
88
89    pub fn log(&mut self, entry: &HookRunTelemetryEntry) -> std::io::Result<()> {
90        serde_json::to_writer(&mut self.writer, entry)?;
91        self.writer.write_all(b"\n")?;
92        self.writer.flush()?;
93        Ok(())
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use chrono::TimeZone;
101    use std::fs;
102    use tempfile::TempDir;
103
104    fn fixed_time(hour: u32, minute: u32, second: u32) -> DateTime<Utc> {
105        Utc.with_ymd_and_hms(2026, 2, 28, hour, minute, second)
106            .single()
107            .expect("fixed timestamp")
108    }
109
110    fn sample_entry(disposition: HookDisposition) -> HookRunTelemetryEntry {
111        HookRunTelemetryEntry {
112            timestamp: fixed_time(15, 30, 2),
113            loop_id: "loop-1234-abcd".to_string(),
114            phase_event: "pre.loop.start".to_string(),
115            hook_name: "env-guard".to_string(),
116            started_at: fixed_time(15, 30, 1),
117            ended_at: fixed_time(15, 30, 2),
118            duration_ms: 923,
119            exit_code: Some(0),
120            timed_out: false,
121            stdout: HookStreamOutput {
122                content: "hook-stdout".to_string(),
123                truncated: false,
124            },
125            stderr: HookStreamOutput {
126                content: "hook-stderr".to_string(),
127                truncated: true,
128            },
129            disposition,
130            suspend_mode: HookSuspendMode::RetryBackoff,
131            retry_attempt: 2,
132            retry_max_attempts: 4,
133        }
134    }
135
136    #[test]
137    fn hook_disposition_serializes_to_snake_case() {
138        let variants = [
139            (HookDisposition::Pass, "pass"),
140            (HookDisposition::Warn, "warn"),
141            (HookDisposition::Block, "block"),
142            (HookDisposition::Suspend, "suspend"),
143        ];
144
145        for (disposition, expected) in variants {
146            let serialized = serde_json::to_string(&disposition).expect("serialize disposition");
147            assert_eq!(serialized, format!("\"{expected}\""));
148
149            let parsed: HookDisposition =
150                serde_json::from_str(&serialized).expect("deserialize disposition");
151            assert_eq!(parsed, disposition);
152        }
153    }
154
155    #[test]
156    fn telemetry_entry_serializes_required_fields() {
157        let entry = sample_entry(HookDisposition::Pass);
158        let value = serde_json::to_value(&entry).expect("serialize telemetry entry");
159
160        for field in [
161            "timestamp",
162            "loop_id",
163            "phase_event",
164            "hook_name",
165            "started_at",
166            "ended_at",
167            "duration_ms",
168            "exit_code",
169            "timed_out",
170            "stdout",
171            "stderr",
172            "disposition",
173            "suspend_mode",
174            "retry_attempt",
175            "retry_max_attempts",
176        ] {
177            assert!(
178                value.get(field).is_some(),
179                "serialized entry missing '{field}'"
180            );
181        }
182
183        assert_eq!(value["phase_event"], "pre.loop.start");
184        assert_eq!(value["hook_name"], "env-guard");
185        assert_eq!(value["duration_ms"], 923);
186        assert_eq!(value["disposition"], "pass");
187        assert_eq!(value["stdout"]["content"], "hook-stdout");
188        assert_eq!(value["stdout"]["truncated"], false);
189        assert_eq!(value["stderr"]["content"], "hook-stderr");
190        assert_eq!(value["stderr"]["truncated"], true);
191        assert_eq!(value["suspend_mode"], "retry_backoff");
192        assert_eq!(value["retry_attempt"], 2);
193        assert_eq!(value["retry_max_attempts"], 4);
194    }
195
196    #[test]
197    fn from_run_result_maps_hook_runtime_fields() {
198        let run_result = HookRunResult {
199            started_at: fixed_time(16, 0, 0),
200            ended_at: fixed_time(16, 0, 2),
201            duration_ms: 2000,
202            exit_code: Some(17),
203            timed_out: true,
204            stdout: HookStreamOutput {
205                content: "captured-stdout".to_string(),
206                truncated: true,
207            },
208            stderr: HookStreamOutput {
209                content: "captured-stderr".to_string(),
210                truncated: false,
211            },
212        };
213
214        let timestamp_before = Utc::now();
215        let entry = HookRunTelemetryEntry::from_run_result(
216            "loop-777",
217            "post.iteration.start",
218            "manual-gate",
219            HookDisposition::Block,
220            HookSuspendMode::WaitThenRetry,
221            2,
222            2,
223            &run_result,
224        );
225        let timestamp_after = Utc::now();
226
227        assert_eq!(entry.loop_id, "loop-777");
228        assert_eq!(entry.phase_event, "post.iteration.start");
229        assert_eq!(entry.hook_name, "manual-gate");
230        assert_eq!(entry.started_at, run_result.started_at);
231        assert_eq!(entry.ended_at, run_result.ended_at);
232        assert_eq!(entry.duration_ms, run_result.duration_ms);
233        assert_eq!(entry.exit_code, run_result.exit_code);
234        assert_eq!(entry.timed_out, run_result.timed_out);
235        assert_eq!(entry.stdout.content, run_result.stdout.content);
236        assert_eq!(entry.stdout.truncated, run_result.stdout.truncated);
237        assert_eq!(entry.stderr.content, run_result.stderr.content);
238        assert_eq!(entry.stderr.truncated, run_result.stderr.truncated);
239        assert_eq!(entry.disposition, HookDisposition::Block);
240        assert_eq!(entry.suspend_mode, HookSuspendMode::WaitThenRetry);
241        assert_eq!(entry.retry_attempt, 2);
242        assert_eq!(entry.retry_max_attempts, 2);
243        assert!(entry.timestamp >= timestamp_before);
244        assert!(entry.timestamp <= timestamp_after);
245    }
246
247    #[test]
248    fn hook_run_logger_persists_jsonl_entries() {
249        let temp_dir = TempDir::new().expect("temp dir");
250        let mut logger = HookRunLogger::new(temp_dir.path()).expect("create logger");
251
252        let entry = sample_entry(HookDisposition::Warn);
253        logger.log(&entry).expect("write telemetry entry");
254        drop(logger);
255
256        let content = fs::read_to_string(temp_dir.path().join("hook-runs.jsonl"))
257            .expect("read hook-runs.jsonl");
258        let lines: Vec<_> = content.lines().collect();
259        assert_eq!(lines.len(), 1);
260
261        let parsed: HookRunTelemetryEntry =
262            serde_json::from_str(lines[0]).expect("parse logged telemetry entry");
263        assert_eq!(parsed.loop_id, "loop-1234-abcd");
264        assert_eq!(parsed.phase_event, "pre.loop.start");
265        assert_eq!(parsed.hook_name, "env-guard");
266        assert_eq!(parsed.disposition, HookDisposition::Warn);
267        assert_eq!(parsed.suspend_mode, HookSuspendMode::RetryBackoff);
268        assert_eq!(parsed.retry_attempt, 2);
269        assert_eq!(parsed.retry_max_attempts, 4);
270        assert_eq!(parsed.stdout.content, "hook-stdout");
271        assert_eq!(parsed.stderr.content, "hook-stderr");
272        assert!(parsed.stderr.truncated);
273    }
274
275    #[test]
276    fn hook_run_logger_flushes_on_each_write() {
277        let temp_dir = TempDir::new().expect("temp dir");
278        let mut logger = HookRunLogger::new(temp_dir.path()).expect("create logger");
279
280        logger
281            .log(&sample_entry(HookDisposition::Suspend))
282            .expect("write telemetry entry");
283
284        // Validate flush behavior without dropping logger.
285        let content = fs::read_to_string(temp_dir.path().join("hook-runs.jsonl"))
286            .expect("read hook-runs.jsonl");
287        assert_eq!(content.lines().count(), 1);
288    }
289}