Skip to main content

harness/context/
jsonl.rs

1use std::path::Path;
2use tokio::io::AsyncWriteExt;
3
4use crate::model::ChatMessage;
5
6/// Load context messages from a JSONL file.
7///
8/// Each line is a `serde_json`-serialised `ChatMessage`. Lines that fail to
9/// parse are skipped with a warning so a single corrupted entry doesn't
10/// prevent the session from resuming.
11///
12/// Returns an empty `Vec` when the file does not exist — callers treat that
13/// as the start of a new session.
14pub async fn load_context(path: &Path) -> Vec<ChatMessage> {
15    let content = match tokio::fs::read_to_string(path).await {
16        Ok(c) => c,
17        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return vec![],
18        Err(e) => {
19            tracing::error!(path = %path.display(), error = %e, "context load failed");
20            return vec![];
21        }
22    };
23    content
24        .lines()
25        .filter(|l| !l.trim().is_empty())
26        .filter_map(|line| {
27            match serde_json::from_str::<ChatMessage>(line) {
28                Ok(msg) => Some(msg),
29                Err(e) => {
30                    tracing::warn!(error = %e, "skipping malformed context line");
31                    None
32                }
33            }
34        })
35        .collect()
36}
37
38/// Append new messages to the context JSONL, creating the file and any
39/// parent directories if needed.
40///
41/// This is called incrementally during a turn (after each User/Assistant/Tool
42/// message is committed) to provide crash resilience.
43pub async fn append_context(path: &Path, messages: &[ChatMessage]) {
44    if messages.is_empty() {
45        return;
46    }
47    if let Some(parent) = path.parent() {
48        if !parent.as_os_str().is_empty() {
49            let _ = tokio::fs::create_dir_all(parent).await;
50        }
51    }
52    let mut file = match tokio::fs::OpenOptions::new()
53        .create(true)
54        .append(true)
55        .open(path)
56        .await
57    {
58        Ok(f) => f,
59        Err(e) => {
60            tracing::error!(path = %path.display(), error = %e, "context append open failed");
61            return;
62        }
63    };
64    for msg in messages {
65        match serde_json::to_string(msg) {
66            Ok(line) => {
67                let _ = file.write_all(line.as_bytes()).await;
68                let _ = file.write_all(b"\n").await;
69            }
70            Err(e) => tracing::warn!(error = %e, "context message serialize failed; skipping"),
71        }
72    }
73}
74
75/// Rewrite the entire context JSONL with the given messages.
76///
77/// Called after compaction fires, replacing the previous (possibly long)
78/// history with a compacted snapshot.
79pub async fn rewrite_context(path: &Path, messages: &[ChatMessage]) {
80    if let Some(parent) = path.parent() {
81        if !parent.as_os_str().is_empty() {
82            let _ = tokio::fs::create_dir_all(parent).await;
83        }
84    }
85    let mut content = String::with_capacity(messages.len() * 128);
86    for msg in messages {
87        match serde_json::to_string(msg) {
88            Ok(line) => {
89                content.push_str(&line);
90                content.push('\n');
91            }
92            Err(e) => tracing::warn!(error = %e, "context message serialize failed; skipping"),
93        }
94    }
95    if let Err(e) = tokio::fs::write(path, &content).await {
96        tracing::error!(path = %path.display(), error = %e, "context rewrite failed");
97    }
98}