@@ -21,38 +21,89 @@ use crate::core::metadata::summarize; |
| 21 | 21 | use crate::core::schema::{ContentBlock, Message, RawEvent, SessionDetail, Usage}; |
| 22 | 22 | |
| 23 | 23 | pub fn read_session(path: &Path, project_id: &str) -> CoreResult<SessionDetail> { |
| 24 | + read_session_limited(path, project_id, None) |
| 25 | +} |
| 26 | + |
| 27 | +/// Read a session, optionally capping the result to the most recent |
| 28 | +/// `limit` messages. Required for host performance on very large |
| 29 | +/// sessions — one user's armfortas session is 171 MB of JSONL and |
| 30 | +/// that's enough to hang the main thread for seconds during IPC |
| 31 | +/// deserialization. Passing `Some(N)` streams the whole file but |
| 32 | +/// only retains the tail, so the wire payload stays bounded. |
| 33 | +/// |
| 34 | +/// The returned [`SessionDetail`] is identical in shape regardless |
| 35 | +/// of whether `limit` was set; truncation is invisible to the |
| 36 | +/// frontend except that `messages.len() < summary.message_count`. |
| 37 | +pub fn read_session_limited( |
| 38 | + path: &Path, |
| 39 | + project_id: &str, |
| 40 | + limit: Option<usize>, |
| 41 | +) -> CoreResult<SessionDetail> { |
| 24 | 42 | let summary = summarize(path, project_id)?; |
| 25 | | - let messages = read_messages(path)?; |
| 43 | + let messages = read_messages(path, limit)?; |
| 26 | 44 | Ok(SessionDetail { summary, messages }) |
| 27 | 45 | } |
| 28 | 46 | |
| 29 | | -fn read_messages(path: &Path) -> CoreResult<Vec<Message>> { |
| 47 | +fn read_messages(path: &Path, limit: Option<usize>) -> CoreResult<Vec<Message>> { |
| 30 | 48 | let file = File::open(path)?; |
| 31 | 49 | let reader = BufReader::new(file); |
| 32 | | - let mut out = Vec::new(); |
| 33 | 50 | let mut fallback_counter: u32 = 0; |
| 34 | 51 | |
| 35 | | - for line in reader.lines() { |
| 36 | | - let line = match line { |
| 37 | | - Ok(l) => l, |
| 38 | | - Err(_) => continue, |
| 39 | | - }; |
| 40 | | - if line.is_empty() { |
| 41 | | - continue; |
| 42 | | - } |
| 43 | | - let ev: RawEvent = match serde_json::from_str(&line) { |
| 44 | | - Ok(e) => e, |
| 45 | | - Err(_) => continue, |
| 46 | | - }; |
| 47 | | - if ev.is_sidechain.unwrap_or(false) { |
| 48 | | - continue; |
| 52 | + // When a cap is set, keep a rolling tail so we never allocate a |
| 53 | + // `Vec<Message>` bigger than the cap + 1. This keeps memory |
| 54 | + // bounded on multi-hundred-megabyte files. |
| 55 | + match limit { |
| 56 | + Some(cap) if cap > 0 => { |
| 57 | + let mut tail: std::collections::VecDeque<Message> = |
| 58 | + std::collections::VecDeque::with_capacity(cap); |
| 59 | + for line in reader.lines() { |
| 60 | + let line = match line { |
| 61 | + Ok(l) => l, |
| 62 | + Err(_) => continue, |
| 63 | + }; |
| 64 | + if line.is_empty() { |
| 65 | + continue; |
| 66 | + } |
| 67 | + let ev: RawEvent = match serde_json::from_str(&line) { |
| 68 | + Ok(e) => e, |
| 69 | + Err(_) => continue, |
| 70 | + }; |
| 71 | + if ev.is_sidechain.unwrap_or(false) { |
| 72 | + continue; |
| 73 | + } |
| 74 | + if let Some(msg) = raw_to_message(ev, &mut fallback_counter) { |
| 75 | + if tail.len() == cap { |
| 76 | + tail.pop_front(); |
| 77 | + } |
| 78 | + tail.push_back(msg); |
| 79 | + } |
| 80 | + } |
| 81 | + Ok(tail.into_iter().collect()) |
| 49 | 82 | } |
| 50 | | - if let Some(msg) = raw_to_message(ev, &mut fallback_counter) { |
| 51 | | - out.push(msg); |
| 83 | + _ => { |
| 84 | + let mut out = Vec::new(); |
| 85 | + for line in reader.lines() { |
| 86 | + let line = match line { |
| 87 | + Ok(l) => l, |
| 88 | + Err(_) => continue, |
| 89 | + }; |
| 90 | + if line.is_empty() { |
| 91 | + continue; |
| 92 | + } |
| 93 | + let ev: RawEvent = match serde_json::from_str(&line) { |
| 94 | + Ok(e) => e, |
| 95 | + Err(_) => continue, |
| 96 | + }; |
| 97 | + if ev.is_sidechain.unwrap_or(false) { |
| 98 | + continue; |
| 99 | + } |
| 100 | + if let Some(msg) = raw_to_message(ev, &mut fallback_counter) { |
| 101 | + out.push(msg); |
| 102 | + } |
| 103 | + } |
| 104 | + Ok(out) |
| 52 | 105 | } |
| 53 | 106 | } |
| 54 | | - |
| 55 | | - Ok(out) |
| 56 | 107 | } |
| 57 | 108 | |
| 58 | 109 | /// Convert one raw event into a timeline message. Returns `None` for |
@@ -460,6 +511,53 @@ mod tests { |
| 460 | 511 | assert_eq!(text, "main"); |
| 461 | 512 | } |
| 462 | 513 | |
| 514 | + #[test] |
| 515 | + fn limit_returns_tail_and_preserves_order() { |
| 516 | + let tmp = tempdir().unwrap(); |
| 517 | + let path = tmp.path().join("s.jsonl"); |
| 518 | + let mut lines: Vec<String> = Vec::new(); |
| 519 | + for i in 0..10 { |
| 520 | + lines.push(format!( |
| 521 | + "{{\"type\":\"user\",\"uuid\":\"u{i}\",\"timestamp\":\"2026-04-11T00:55:{:02}.000Z\",\"sessionId\":\"abc\",\"message\":{{\"role\":\"user\",\"content\":\"msg{i}\"}}}}", |
| 522 | + i |
| 523 | + )); |
| 524 | + } |
| 525 | + write_fixture( |
| 526 | + &path, |
| 527 | + &lines.iter().map(|s| s.as_str()).collect::<Vec<_>>(), |
| 528 | + ); |
| 529 | + |
| 530 | + let detail = read_session_limited(&path, "-Users-me-repo", Some(3)).unwrap(); |
| 531 | + assert_eq!(detail.messages.len(), 3); |
| 532 | + // Summary still reflects total message count. |
| 533 | + assert_eq!(detail.summary.message_count, 10); |
| 534 | + let texts: Vec<&str> = detail |
| 535 | + .messages |
| 536 | + .iter() |
| 537 | + .filter_map(|m| match m { |
| 538 | + Message::User { text, .. } => Some(text.as_str()), |
| 539 | + _ => None, |
| 540 | + }) |
| 541 | + .collect(); |
| 542 | + assert_eq!(texts, vec!["msg7", "msg8", "msg9"]); |
| 543 | + } |
| 544 | + |
| 545 | + #[test] |
| 546 | + fn limit_zero_is_treated_as_unlimited() { |
| 547 | + // 0 is a weird edge case; we treat it as "no cap" so a |
| 548 | + // caller mishandling its limit arg still gets data. |
| 549 | + let tmp = tempdir().unwrap(); |
| 550 | + let path = tmp.path().join("s.jsonl"); |
| 551 | + write_fixture( |
| 552 | + &path, |
| 553 | + &[ |
| 554 | + r#"{"type":"user","uuid":"u1","timestamp":"2026-04-11T00:55:35.000Z","sessionId":"abc","message":{"role":"user","content":"hi"}}"#, |
| 555 | + ], |
| 556 | + ); |
| 557 | + let detail = read_session_limited(&path, "-Users-me-repo", Some(0)).unwrap(); |
| 558 | + assert_eq!(detail.messages.len(), 1); |
| 559 | + } |
| 560 | + |
| 463 | 561 | #[test] |
| 464 | 562 | fn survives_partial_last_line() { |
| 465 | 563 | let tmp = tempdir().unwrap(); |