Skip to main content

kimetsu_brain/
trace.rs

1use std::fs::{self, File, OpenOptions};
2use std::io::{BufRead, BufReader, Write};
3use std::path::{Path, PathBuf};
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use kimetsu_core::KimetsuResult;
7use kimetsu_core::event::Event;
8use kimetsu_core::ids::RunId;
9use kimetsu_core::paths::ProjectPaths;
10
11#[derive(Debug, Clone)]
12pub struct RunPaths {
13    pub run_dir: PathBuf,
14    pub trace_jsonl: PathBuf,
15    pub artifacts_dir: PathBuf,
16    pub patch_plans_dir: PathBuf,
17    pub final_report: PathBuf,
18    pub run_log: PathBuf,
19}
20
21impl RunPaths {
22    pub fn new(paths: &ProjectPaths, run_id: RunId) -> Self {
23        let run_dir = paths.runs_dir.join(run_id.to_string());
24        Self {
25            trace_jsonl: run_dir.join("trace.jsonl"),
26            artifacts_dir: run_dir.join("artifacts"),
27            patch_plans_dir: run_dir.join("patch_plans"),
28            final_report: run_dir.join("final_report.md"),
29            run_log: run_dir.join("kimetsu.log"),
30            run_dir,
31        }
32    }
33
34    pub fn create_dirs(&self) -> KimetsuResult<()> {
35        fs::create_dir_all(&self.artifacts_dir)?;
36        fs::create_dir_all(&self.patch_plans_dir)?;
37        Ok(())
38    }
39}
40
41pub struct TraceWriter {
42    file: File,
43}
44
45impl TraceWriter {
46    pub fn create(paths: &ProjectPaths, run_id: RunId) -> KimetsuResult<(Self, RunPaths)> {
47        let run_paths = RunPaths::new(paths, run_id);
48        run_paths.create_dirs()?;
49        let file = OpenOptions::new()
50            .create(true)
51            .append(true)
52            .open(&run_paths.trace_jsonl)?;
53
54        // Opportunistic GC: prune old sibling run dirs when a new one is
55        // created (rare — only real agent runs). Runs only when
56        // KIMETSU_RUNS_GC != "0".  Best-effort: never fails the run.
57        if std::env::var("KIMETSU_RUNS_GC").as_deref() != Ok("0") {
58            gc_old_runs(
59                &paths.runs_dir,
60                Duration::from_secs(30 * 24 * 3600), // 30 days
61                20,                                  // keep newest 20
62            );
63        }
64
65        Ok((Self { file }, run_paths))
66    }
67
68    pub fn append(&mut self, event: &Event, fsync: bool) -> KimetsuResult<()> {
69        serde_json::to_writer(&mut self.file, event)?;
70        self.file.write_all(b"\n")?;
71        self.file.flush()?;
72        if fsync {
73            self.file.sync_data()?;
74        }
75        Ok(())
76    }
77}
78
79pub fn read_trace(trace_jsonl: &Path) -> KimetsuResult<Vec<Event>> {
80    let file = File::open(trace_jsonl)?;
81    let mut reader = BufReader::new(file);
82    let mut events = Vec::new();
83    let mut line = String::new();
84    let mut line_number = 0usize;
85
86    loop {
87        line.clear();
88        let bytes_read = reader.read_line(&mut line)?;
89        if bytes_read == 0 {
90            break;
91        }
92        line_number += 1;
93
94        let trimmed = line.trim();
95        if trimmed.is_empty() {
96            continue;
97        }
98
99        match serde_json::from_str::<Event>(trimmed) {
100            Ok(event) => events.push(event),
101            Err(err) => {
102                if !line.ends_with('\n') {
103                    eprintln!(
104                        "warning: ignoring invalid trailing JSONL line in {}: {err}",
105                        trace_jsonl.display()
106                    );
107                    break;
108                }
109
110                return Err(format!(
111                    "invalid JSONL at {}:{}: {err}",
112                    trace_jsonl.display(),
113                    line_number
114                )
115                .into());
116            }
117        }
118    }
119
120    Ok(events)
121}
122
123pub fn discover_traces(paths: &ProjectPaths) -> KimetsuResult<Vec<PathBuf>> {
124    if !paths.runs_dir.exists() {
125        return Ok(Vec::new());
126    }
127
128    let mut traces = Vec::new();
129    for entry in fs::read_dir(&paths.runs_dir)? {
130        let entry = entry?;
131        if !entry.file_type()?.is_dir() {
132            continue;
133        }
134
135        let trace = entry.path().join("trace.jsonl");
136        if trace.exists() {
137            traces.push(trace);
138        }
139    }
140
141    traces.sort();
142    Ok(traces)
143}
144
145pub fn read_all_traces(paths: &ProjectPaths) -> KimetsuResult<Vec<Event>> {
146    let mut events = Vec::new();
147    for trace in discover_traces(paths)? {
148        events.extend(read_trace(&trace)?);
149    }
150
151    events.sort_by(|left, right| {
152        left.event_id
153            .0
154            .cmp(&right.event_id.0)
155            .then_with(|| left.ts.cmp(&right.ts))
156    });
157    events.dedup_by_key(|event| event.event_id);
158    Ok(events)
159}
160
161// ---------------------------------------------------------------------------
162// Auto-GC: opportunistic pruning of old run dirs on new-run creation
163// ---------------------------------------------------------------------------
164
165/// Extract the run-start timestamp (Unix ms) from a ULID directory name.
166/// Returns `None` when the string is not a valid ULID.
167fn ulid_timestamp_ms(name: &str) -> Option<u64> {
168    name.parse::<ulid::Ulid>().ok().map(|u| u.timestamp_ms())
169}
170
171/// Pure selection function for auto-GC.
172///
173/// Given a slice of `(name, ts_ms)` run entries (the caller must sort them
174/// newest-first before calling), returns the indices of entries that should
175/// be removed according to the policy:
176///
177/// * The newest `keep` entries are always protected (indices `0..keep`).
178/// * Beyond that, entries whose `ts_ms` is older than `now_ms - max_age`
179///   are selected for removal.
180/// * Empty input → empty output.
181pub fn select_old_runs(
182    runs: &[(&str, u64)],
183    now_ms: u64,
184    max_age: Duration,
185    keep: usize,
186) -> Vec<usize> {
187    let cutoff_ms = now_ms.saturating_sub(max_age.as_millis() as u64);
188    runs.iter()
189        .enumerate()
190        .filter_map(|(idx, (_name, ts_ms))| {
191            if idx < keep {
192                return None; // always protected
193            }
194            if *ts_ms < cutoff_ms { Some(idx) } else { None }
195        })
196        .collect()
197}
198
199/// Opportunistic GC: scan `runs_dir` for ULID-named subdirs, remove those
200/// older than `max_age` while always keeping the `keep` newest.
201///
202/// Best-effort: per-directory errors are swallowed and never propagate to
203/// the caller. The function is a no-op when `runs_dir` doesn't exist.
204pub fn gc_old_runs(runs_dir: &Path, max_age: Duration, keep: usize) {
205    let Ok(rd) = fs::read_dir(runs_dir) else {
206        return;
207    };
208
209    let now_ms = SystemTime::now()
210        .duration_since(UNIX_EPOCH)
211        .map(|d| d.as_millis() as u64)
212        .unwrap_or(0);
213
214    // Collect (name, ts_ms, path) for each subdirectory.
215    let mut entries: Vec<(String, u64, PathBuf)> = rd
216        .flatten()
217        .filter(|e| e.path().is_dir())
218        .map(|e| {
219            let path = e.path();
220            let name = e.file_name().to_string_lossy().into_owned();
221            let ts_ms = ulid_timestamp_ms(&name).unwrap_or_else(|| {
222                e.metadata()
223                    .ok()
224                    .and_then(|m| m.modified().ok())
225                    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
226                    .map(|d| d.as_millis() as u64)
227                    .unwrap_or(0)
228            });
229            (name, ts_ms, path)
230        })
231        .collect();
232
233    // Sort newest-first so the protection guard is correct.
234    entries.sort_by_key(|(_, ts, _)| std::cmp::Reverse(*ts));
235
236    // Build the slim (name, ts_ms) slice for the pure selection fn.
237    let slim: Vec<(&str, u64)> = entries
238        .iter()
239        .map(|(name, ts, _)| (name.as_str(), *ts))
240        .collect();
241
242    let to_remove = select_old_runs(&slim, now_ms, max_age, keep);
243    for idx in to_remove {
244        let _ = fs::remove_dir_all(&entries[idx].2);
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Tests
250// ---------------------------------------------------------------------------
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use kimetsu_core::ids::RunId;
256    use kimetsu_core::paths::ProjectPaths;
257    use std::sync::Mutex;
258
259    /// Process-wide mutex so env-mutating tests don't race.
260    fn env_lock() -> &'static Mutex<()> {
261        static LOCK: Mutex<()> = Mutex::new(());
262        &LOCK
263    }
264
265    // ── select_old_runs: pure unit tests ────────────────────────────────────
266
267    #[test]
268    fn select_empty_returns_empty() {
269        let result = select_old_runs(&[], 1_000_000_000, Duration::from_secs(1), 0);
270        assert!(result.is_empty());
271    }
272
273    #[test]
274    fn select_newer_than_cutoff_not_selected() {
275        let now_ms: u64 = 1_000_000_000;
276        let max_age = Duration::from_secs(3 * 24 * 3600); // 3 days
277        let cutoff_ms = now_ms - max_age.as_millis() as u64;
278        // All runs are newer than the cutoff.
279        let runs = vec![
280            ("run-1", cutoff_ms + 10_000),
281            ("run-2", cutoff_ms + 5_000),
282            ("run-3", cutoff_ms + 1_000),
283        ];
284        let result = select_old_runs(&runs, now_ms, max_age, 0);
285        assert!(
286            result.is_empty(),
287            "runs newer than cutoff must not be selected"
288        );
289    }
290
291    #[test]
292    fn select_older_than_cutoff_selected() {
293        let now_ms: u64 = 1_000_000_000;
294        let max_age = Duration::from_secs(3 * 24 * 3600); // 3 days
295        let cutoff_ms = now_ms - max_age.as_millis() as u64;
296        // All runs older than the cutoff, no keep protection.
297        let runs = vec![("run-1", cutoff_ms - 1_000), ("run-2", cutoff_ms - 5_000)];
298        let result = select_old_runs(&runs, now_ms, max_age, 0);
299        assert_eq!(result, vec![0, 1], "both old runs should be selected");
300    }
301
302    #[test]
303    fn select_keep_protects_newest() {
304        let now_ms: u64 = 1_000_000_000;
305        // A large max_age so everything qualifies on age.
306        let max_age = Duration::from_secs(1);
307        // Slice already sorted newest-first.
308        let runs = vec![
309            ("run-a", 900),
310            ("run-b", 800),
311            ("run-c", 700),
312            ("run-d", 600),
313        ];
314        // Keep 2 → protect indices 0 and 1.
315        let result = select_old_runs(&runs, now_ms, max_age, 2);
316        assert_eq!(result, vec![2, 3]);
317    }
318
319    #[test]
320    fn select_keep_larger_than_slice_selects_nothing() {
321        let now_ms: u64 = 1_000_000_000;
322        let max_age = Duration::from_secs(1);
323        let runs = vec![("run-1", 100), ("run-2", 50)];
324        let result = select_old_runs(&runs, now_ms, max_age, 10);
325        assert!(
326            result.is_empty(),
327            "keep >= slice length must protect everything"
328        );
329    }
330
331    #[test]
332    fn select_mixed_age_and_keep() {
333        // now = 10 days in ms, max_age = 2 days.
334        // runs sorted newest-first:
335        //   idx 0: 9-day-old  → protected by keep=2
336        //   idx 1: 8-day-old  → protected by keep=2
337        //   idx 2: 5-day-old  → older than 2d but inside keep? No, idx=2 ≥ keep=2
338        //   idx 3: 1-day-old  → newer than cutoff (1d < 2d)
339        //   idx 4: 3-day-old  → older than 2d, idx=4 ≥ keep=2 → selected
340        let day_ms = 24u64 * 3600 * 1_000;
341        let now_ms = 10 * day_ms;
342        let max_age = Duration::from_secs(2 * 24 * 3600);
343        let runs = vec![
344            ("r0", now_ms - 9 * day_ms),
345            ("r1", now_ms - 8 * day_ms),
346            ("r2", now_ms - 5 * day_ms),
347            ("r3", now_ms - day_ms),
348            ("r4", now_ms - 3 * day_ms),
349        ];
350        // keep=2 → protect r0, r1
351        // cutoff = now - 2d → r3 (1d old) is newer, not selected
352        //                    r2 (5d old), r4 (3d old) → both older, selected
353        let result = select_old_runs(&runs, now_ms, max_age, 2);
354        assert_eq!(result, vec![2, 4]);
355    }
356
357    // ── gc_old_runs: filesystem tests ───────────────────────────────────────
358
359    /// Create a temp runs dir with N fake run subdirs.
360    /// `age_ms_offsets` is the list of (dir-suffix, ms-before-now).
361    /// Uses real ULID-like names with a fake timestamp encoded by creating
362    /// the dirs but naming them with synthetic names + storing age via
363    /// mtime manipulation (not feasible portably) — instead we test using
364    /// real ULID dirs where we can control timestamps by calling
365    /// gc_old_runs with an adjusted `now_ms` analog.
366    ///
367    /// Since gc_old_runs computes its own now_ms internally, we test
368    /// indirectly via the pure function + a filesystem integration test
369    /// that checks removal correctness by making ALL dirs "very old" or
370    /// "very new" via ULID timestamp manipulation — which we can't do
371    /// after the fact.
372    ///
373    /// Instead: we test gc_old_runs via non-ULID dirs whose mtime IS the
374    /// encoded age.  We use a large max_age (e.g., 365 days) so only
375    /// truly ancient dirs are removed; all freshly-created dirs survive.
376    #[test]
377    fn gc_fresh_dirs_all_survive() {
378        let tmp = std::env::temp_dir().join(format!("kimetsu-gc-fresh-{}", RunId::new()));
379        fs::create_dir_all(&tmp).unwrap();
380
381        // Create 5 fresh "run" subdirs.
382        for i in 0..5u32 {
383            fs::create_dir_all(tmp.join(format!("run-{i}"))).unwrap();
384        }
385
386        // max_age = 30 days; fresh dirs are <1 second old → all survive.
387        gc_old_runs(&tmp, Duration::from_secs(30 * 24 * 3600), 20);
388
389        let remaining: Vec<_> = fs::read_dir(&tmp)
390            .unwrap()
391            .flatten()
392            .filter(|e| e.path().is_dir())
393            .collect();
394        assert_eq!(remaining.len(), 5, "all 5 fresh dirs should survive");
395
396        let _ = fs::remove_dir_all(&tmp);
397    }
398
399    #[test]
400    fn gc_env_zero_disables_gc() {
401        let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner());
402        let tmp = std::env::temp_dir().join(format!("kimetsu-gc-env0-{}", RunId::new()));
403        fs::create_dir_all(&tmp).unwrap();
404
405        for i in 0..3u32 {
406            fs::create_dir_all(tmp.join(format!("run-{i}"))).unwrap();
407        }
408
409        // With KIMETSU_RUNS_GC=0 the TraceWriter::create branch skips GC.
410        // We can test the env skip by asserting that gc_old_runs is NOT
411        // called — but the easiest check is the opt-out in TraceWriter::create.
412        // Here we test that even if gc_old_runs is called with an absurdly
413        // small max_age + keep=0, the env guard in TraceWriter is the wall.
414        // We test TraceWriter integration below; here we just confirm that
415        // gc_old_runs itself with keep=3 protects all 3 runs.
416        gc_old_runs(&tmp, Duration::from_nanos(1), 3); // keep=3 protects all
417        let remaining: usize = fs::read_dir(&tmp)
418            .unwrap()
419            .flatten()
420            .filter(|e| e.path().is_dir())
421            .count();
422        assert_eq!(remaining, 3, "keep=3 should protect all 3 dirs");
423
424        let _ = fs::remove_dir_all(&tmp);
425    }
426
427    #[test]
428    fn trace_writer_create_env_zero_skips_gc() {
429        let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner());
430
431        let root = std::env::temp_dir().join(format!("kimetsu-gc-tw-{}", RunId::new()));
432        fs::create_dir_all(&root).unwrap();
433        kimetsu_core::paths::git_init_boundary(&root);
434        kimetsu_brain_init_for_test(&root);
435
436        let paths = ProjectPaths::at_root(&root);
437
438        // Create a "sibling" run dir.
439        fs::create_dir_all(paths.runs_dir.join("old-sibling")).unwrap();
440
441        unsafe { std::env::set_var("KIMETSU_RUNS_GC", "0") };
442        let run_id = RunId::new();
443        let (_tw, run_paths) = TraceWriter::create(&paths, run_id).expect("create");
444        // With GC=0, the sibling must NOT be removed.
445        assert!(
446            paths.runs_dir.join("old-sibling").exists(),
447            "GC=0 must leave old-sibling untouched"
448        );
449        // The just-created run must exist.
450        assert!(run_paths.run_dir.exists(), "new run dir must exist");
451        unsafe { std::env::remove_var("KIMETSU_RUNS_GC") };
452
453        let _ = fs::remove_dir_all(&root);
454    }
455
456    #[test]
457    fn trace_writer_create_new_run_survives_gc() {
458        let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner());
459
460        let root = std::env::temp_dir().join(format!("kimetsu-gc-survive-{}", RunId::new()));
461        fs::create_dir_all(&root).unwrap();
462        kimetsu_core::paths::git_init_boundary(&root);
463        kimetsu_brain_init_for_test(&root);
464
465        let paths = ProjectPaths::at_root(&root);
466
467        // Ensure GC is enabled.
468        unsafe { std::env::remove_var("KIMETSU_RUNS_GC") };
469
470        let run_id = RunId::new();
471        let (_tw, run_paths) = TraceWriter::create(&paths, run_id).expect("create");
472
473        // The newly-created run dir must always survive (it's the newest).
474        assert!(
475            run_paths.run_dir.exists(),
476            "just-created run dir must survive GC"
477        );
478
479        let _ = fs::remove_dir_all(&root);
480    }
481
482    // Helper: initialize only the runs_dir (no full project.toml / brain.db needed
483    // for trace tests).
484    fn kimetsu_brain_init_for_test(root: &Path) {
485        let kimetsu_dir = root.join(".kimetsu");
486        fs::create_dir_all(kimetsu_dir.join("runs")).unwrap();
487    }
488}