Skip to main content

magi_code/sessions/
read.rs

1use super::event::SessionEvent;
2use super::manager::Session;
3use sha2::{Digest, Sha256};
4use std::{
5    collections::VecDeque,
6    fs,
7    io::{BufRead, BufReader, Read, Seek, SeekFrom},
8    path::PathBuf,
9};
10pub fn validate_session_id(id: String) -> anyhow::Result<String> {
11    if id.is_empty() {
12        anyhow::bail!("session id must not be empty");
13    }
14    if id == "." || id == ".." || id.contains("..") {
15        anyhow::bail!("session id must not contain '..'");
16    }
17    if id.contains('/') || id.contains('\\') {
18        anyhow::bail!("session id must not contain path separators");
19    }
20    if PathBuf::from(&id).is_absolute() {
21        anyhow::bail!("session id must not be an absolute path");
22    }
23    if !id
24        .chars()
25        .all(|character| character.is_ascii_alphanumeric() || character == '_' || character == '-')
26    {
27        anyhow::bail!("session id must match [A-Za-z0-9_-]+");
28    }
29    Ok(id)
30}
31
32fn open_session_file(session: &Session) -> anyhow::Result<fs::File> {
33    let root = session
34        .path
35        .parent()
36        .ok_or_else(|| anyhow::anyhow!("session file has no parent"))?;
37    super::store::open_existing_primary(root, &session.id)?
38        .ok_or_else(|| anyhow::anyhow!("session JSONL is missing"))
39}
40
41#[derive(Debug)]
42pub(crate) enum BoundedReadError {
43    BudgetExceeded(String),
44}
45
46impl std::fmt::Display for BoundedReadError {
47    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            Self::BudgetExceeded(message) => formatter.write_str(message),
50        }
51    }
52}
53
54impl std::error::Error for BoundedReadError {}
55
56fn budget_error(message: impl Into<String>) -> anyhow::Error {
57    anyhow::Error::new(BoundedReadError::BudgetExceeded(message.into()))
58}
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub(crate) struct SessionReadDiagnostic {
61    pub(crate) line: usize,
62    pub(crate) message: String,
63}
64
65#[derive(Debug, Clone, PartialEq)]
66pub(crate) struct TolerantSessionEvents {
67    pub(crate) events: Vec<SessionEvent>,
68    pub(crate) diagnostics: Vec<SessionReadDiagnostic>,
69    pub(crate) cutoff_bytes: u64,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub(crate) struct SessionReadStats {
74    pub lines_read: usize,
75    pub bytes_read: usize,
76    pub content_digest: [u8; 32],
77}
78
79const MAX_TOLERANT_READ_DIAGNOSTICS: usize = 64;
80
81fn push_tolerant_read_diagnostic(
82    diagnostics: &mut Vec<SessionReadDiagnostic>,
83    omitted_count: &mut usize,
84    diagnostic: SessionReadDiagnostic,
85) {
86    if diagnostics.len() < MAX_TOLERANT_READ_DIAGNOSTICS {
87        diagnostics.push(diagnostic);
88    } else {
89        *omitted_count = omitted_count.saturating_add(1);
90    }
91}
92
93fn finalize_tolerant_read_diagnostics(
94    mut diagnostics: Vec<SessionReadDiagnostic>,
95    omitted_count: usize,
96) -> Vec<SessionReadDiagnostic> {
97    if omitted_count == 0 {
98        return diagnostics;
99    }
100    if diagnostics.len() == MAX_TOLERANT_READ_DIAGNOSTICS {
101        diagnostics.pop();
102    }
103    diagnostics.push(SessionReadDiagnostic {
104        line: 0,
105        message: format!(
106            "omitted {omitted_count} additional session JSONL diagnostics after cap of {MAX_TOLERANT_READ_DIAGNOSTICS}"
107        ),
108    });
109    diagnostics
110}
111
112pub(crate) const MAX_METADATA_VISIT_LINES: usize = 100_000;
113pub(crate) const MAX_METADATA_VISIT_BYTES: usize = 64 * 1024 * 1024;
114
115impl Session {
116    #[cfg(unix)]
117    pub(crate) fn open_discovery_file(&self) -> anyhow::Result<fs::File> {
118        open_session_file(self)
119    }
120
121    pub(crate) fn read_events_tolerant_bounded(
122        &self,
123        max_lines: usize,
124        max_bytes: usize,
125    ) -> anyhow::Result<TolerantSessionEvents> {
126        Ok(self
127            .read_events_tolerant_bounded_with_stats(max_lines, max_bytes)?
128            .0)
129    }
130
131    pub(crate) fn read_events_tolerant_bounded_with_stats(
132        &self,
133        max_lines: usize,
134        max_bytes: usize,
135    ) -> anyhow::Result<(TolerantSessionEvents, SessionReadStats)> {
136        let mut events = Vec::new();
137        let (diagnostics, cutoff_bytes, stats) =
138            self.visit_events_tolerant_bounded_with_stats(max_lines, max_bytes, |event| {
139                events.push(event)
140            })?;
141        Ok((
142            TolerantSessionEvents {
143                events,
144                diagnostics,
145                cutoff_bytes: cutoff_bytes as u64,
146            },
147            stats,
148        ))
149    }
150
151    pub(crate) fn history_bytes(&self) -> anyhow::Result<u64> {
152        let root = self
153            .path
154            .parent()
155            .ok_or_else(|| anyhow::anyhow!("session file has no parent"))?;
156        let Some(file) = super::store::open_existing_primary(root, &self.id)? else {
157            return Ok(0);
158        };
159        Ok(file.metadata()?.len())
160    }
161
162    pub(crate) fn content_fingerprint_bounded(
163        &self,
164        max_bytes: usize,
165    ) -> anyhow::Result<([u8; 32], usize)> {
166        validate_session_id(self.id.clone())?;
167        if !self.path.exists() {
168            return Ok((Sha256::digest([]).into(), 0));
169        }
170        let file = open_session_file(self)?;
171        let mut reader = file.take(
172            u64::try_from(max_bytes)
173                .unwrap_or(u64::MAX)
174                .saturating_add(1),
175        );
176        let mut digest = Sha256::new();
177        let mut total_bytes = 0usize;
178        let mut buffer = [0u8; 8192];
179        loop {
180            let read = reader.read(&mut buffer)?;
181            if read == 0 {
182                break;
183            }
184            total_bytes = total_bytes.saturating_add(read);
185            if total_bytes > max_bytes {
186                return Err(budget_error(format!(
187                    "session JSONL tolerant read limit exceeded: {max_bytes} bytes"
188                )));
189            }
190            digest.update(&buffer[..read]);
191        }
192        Ok((digest.finalize().into(), total_bytes))
193    }
194
195    pub(crate) fn visit_events_tolerant_bounded(
196        &self,
197        max_lines: usize,
198        max_bytes: usize,
199        visit: impl FnMut(SessionEvent),
200    ) -> anyhow::Result<(Vec<SessionReadDiagnostic>, usize)> {
201        let (diagnostics, bytes, _) =
202            self.visit_events_tolerant_bounded_with_stats(max_lines, max_bytes, visit)?;
203        Ok((diagnostics, bytes))
204    }
205
206    fn visit_events_tolerant_bounded_with_stats(
207        &self,
208        max_lines: usize,
209        max_bytes: usize,
210        mut visit: impl FnMut(SessionEvent),
211    ) -> anyhow::Result<(Vec<SessionReadDiagnostic>, usize, SessionReadStats)> {
212        validate_session_id(self.id.clone())?;
213        if !self.path.exists() {
214            return Ok((
215                Vec::new(),
216                0,
217                SessionReadStats {
218                    lines_read: 0,
219                    bytes_read: 0,
220                    content_digest: Sha256::digest([]).into(),
221                },
222            ));
223        }
224        let file = open_session_file(self)?;
225        let mut reader = BufReader::new(file);
226        let mut diagnostics = Vec::new();
227        let mut omitted_diagnostics = 0usize;
228        let mut total_bytes = 0usize;
229        let mut lines_read = 0usize;
230        let mut digest = Sha256::new();
231        let mut line = Vec::new();
232        for line_number in 1..=max_lines {
233            line.clear();
234            let remaining = max_bytes.saturating_sub(total_bytes);
235            if remaining == 0 {
236                if !reader.fill_buf()?.is_empty() {
237                    return Err(budget_error(format!(
238                        "session JSONL tolerant read limit exceeded: {max_bytes} bytes"
239                    )));
240                }
241                break;
242            }
243            let read = (&mut reader)
244                .take(
245                    u64::try_from(remaining)
246                        .unwrap_or(u64::MAX)
247                        .saturating_add(1),
248                )
249                .read_until(b'\n', &mut line)?;
250            if read == 0 {
251                break;
252            }
253            if read > remaining {
254                return Err(budget_error(format!(
255                    "session JSONL tolerant read limit exceeded at line {line_number}: {max_bytes} bytes"
256                )));
257            }
258            total_bytes = total_bytes.saturating_add(read);
259            lines_read = lines_read.saturating_add(1);
260            digest.update(&line);
261            if line.last() == Some(&b'\n') {
262                line.pop();
263                if line.last() == Some(&b'\r') {
264                    line.pop();
265                }
266            }
267            match serde_json::from_slice::<SessionEvent>(&line) {
268                Ok(event) => visit(event),
269                Err(_) => push_tolerant_read_diagnostic(
270                    &mut diagnostics,
271                    &mut omitted_diagnostics,
272                    SessionReadDiagnostic {
273                        line: line_number,
274                        message: format!(
275                            "operation=replay category=session_jsonl failed to parse session JSONL at line {line_number}"
276                        ),
277                    },
278                ),
279            }
280        }
281        if !reader.fill_buf()?.is_empty() {
282            return Err(budget_error(format!(
283                "session JSONL tolerant read limit exceeded: more than {max_lines} lines or {max_bytes} bytes"
284            )));
285        }
286        let diagnostics = finalize_tolerant_read_diagnostics(diagnostics, omitted_diagnostics);
287        Ok((
288            diagnostics,
289            total_bytes,
290            SessionReadStats {
291                lines_read,
292                bytes_read: total_bytes,
293                content_digest: digest.finalize().into(),
294            },
295        ))
296    }
297
298    pub(crate) fn read_recent_events_tolerant(
299        &self,
300        max_events: usize,
301        max_bytes: usize,
302    ) -> anyhow::Result<TolerantSessionEvents> {
303        validate_session_id(self.id.clone())?;
304        if !self.path.exists() || max_events == 0 || max_bytes == 0 {
305            return Ok(TolerantSessionEvents {
306                events: Vec::new(),
307                diagnostics: Vec::new(),
308                cutoff_bytes: 0,
309            });
310        }
311        let file = open_session_file(self)?;
312        let lines = BufReader::new(file)
313            .split(b'\n')
314            .enumerate()
315            .map(|(index, line)| (index + 1, line));
316        self.collect_recent_events_tolerant_lines(lines, max_events, max_bytes)
317    }
318
319    pub(crate) fn read_recent_events_tolerant_tail(
320        &self,
321        max_events: usize,
322        max_retained_bytes: usize,
323        max_read_bytes: usize,
324    ) -> anyhow::Result<TolerantSessionEvents> {
325        validate_session_id(self.id.clone())?;
326        if max_events == 0 || max_retained_bytes == 0 || max_read_bytes == 0 {
327            anyhow::bail!(
328                "session JSONL tolerant tail read limits must be non-zero: max_events={max_events}, max_retained_bytes={max_retained_bytes}, max_read_bytes={max_read_bytes}"
329            );
330        }
331        if !self.path.exists() {
332            return Ok(TolerantSessionEvents {
333                events: Vec::new(),
334                diagnostics: Vec::new(),
335                cutoff_bytes: 0,
336            });
337        }
338        // Pin the byte window on the same validated handle. In particular, a file that was
339        // small at stat time must not fall back to an unbounded read while a writer appends.
340        let mut file = open_session_file(self)?;
341        let file_len = file.metadata()?.len();
342        let read_bytes = file_len.min(u64::try_from(max_read_bytes).unwrap_or(u64::MAX));
343        let start = file_len - read_bytes;
344        file.seek(SeekFrom::Start(start))?;
345        let mut tail = Vec::with_capacity(read_bytes as usize);
346        file.take(read_bytes).read_to_end(&mut tail)?;
347
348        let first_complete_line = if start == 0 {
349            0
350        } else {
351            tail.iter()
352                .position(|byte| *byte == b'\n')
353                .map_or(tail.len(), |index| index + 1)
354        };
355        let tail = &tail[first_complete_line..];
356        let mut line_count = 0;
357        let lines = tail
358            .split_inclusive(|byte| *byte == b'\n')
359            .enumerate()
360            .inspect(|_| line_count += 1)
361            .map(|(index, line)| {
362                let line = line.strip_suffix(b"\n").unwrap_or(line);
363                (index + 1, Ok(line.to_vec()))
364            });
365        let mut result =
366            self.collect_recent_events_tolerant_lines(lines, max_events, max_retained_bytes)?;
367        if start > 0 || (line_count > result.events.len() && result.diagnostics.is_empty()) {
368            result.diagnostics.insert(
369            0,
370            SessionReadDiagnostic {
371                line: 0,
372                message: format!(
373                    "operation=recent_context category=session_jsonl bounded tail window; omitted older lines; read final {max_read_bytes} of {file_len} bytes"
374                ),
375            },
376        );
377        }
378        Ok(result)
379    }
380
381    fn collect_recent_events_tolerant_lines(
382        &self,
383        lines: impl IntoIterator<Item = (usize, Result<Vec<u8>, std::io::Error>)>,
384        max_events: usize,
385        max_bytes: usize,
386    ) -> anyhow::Result<TolerantSessionEvents> {
387        let mut retained = VecDeque::new();
388        let mut retained_bytes = 0usize;
389        let mut malformed_bytes = 0usize;
390        let mut diagnostics = Vec::new();
391        let mut omitted_diagnostics = 0usize;
392        for (line_number, line) in lines {
393            match line {
394                Ok(line) => {
395                    let line_bytes = line.len() + 1;
396                    match serde_json::from_slice::<SessionEvent>(&line) {
397                        Ok(event) => {
398                            retained_bytes = retained_bytes.saturating_add(line_bytes);
399                            retained.push_back((event, line_bytes));
400                            while retained.len() > max_events || retained_bytes > max_bytes {
401                                if let Some((_, bytes)) = retained.pop_front() {
402                                    retained_bytes = retained_bytes.saturating_sub(bytes);
403                                } else {
404                                    break;
405                                }
406                            }
407                        }
408                        Err(_) => {
409                            malformed_bytes = malformed_bytes.saturating_add(line_bytes);
410                            if malformed_bytes > max_bytes {
411                                anyhow::bail!(
412                                    "operation=recent_context category=session_jsonl tolerant recent read limit exceeded: malformed bytes > {max_bytes}"
413                                );
414                            }
415                            push_tolerant_read_diagnostic(
416                                &mut diagnostics,
417                                &mut omitted_diagnostics,
418                                SessionReadDiagnostic {
419                                    line: line_number,
420                                    message: format!(
421                                        "operation=recent_context category=session_jsonl failed to parse session JSONL at line {line_number}"
422                                    ),
423                                },
424                            );
425                        }
426                    }
427                }
428                Err(_) => push_tolerant_read_diagnostic(
429                    &mut diagnostics,
430                    &mut omitted_diagnostics,
431                    SessionReadDiagnostic {
432                        line: line_number,
433                        message: format!(
434                            "operation=recent_context category=session_jsonl read failure at line {line_number}"
435                        ),
436                    },
437                ),
438            }
439        }
440        Ok(TolerantSessionEvents {
441            events: retained.into_iter().map(|(event, _)| event).collect(),
442            diagnostics: finalize_tolerant_read_diagnostics(diagnostics, omitted_diagnostics),
443            cutoff_bytes: 0,
444        })
445    }
446}