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#[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#[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 #[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
71pub 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 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}