diff --git a/code-rs/Cargo.lock b/code-rs/Cargo.lock index d15926efcd1..2b4878142c2 100644 --- a/code-rs/Cargo.lock +++ b/code-rs/Cargo.lock @@ -1288,6 +1288,7 @@ dependencies = [ "dunce", "env-flags", "eventsource-stream", + "filetime", "fs2", "futures", "htmd", @@ -1298,6 +1299,7 @@ dependencies = [ "libc", "maplit", "mime_guess", + "once_cell", "openssl-sys", "os_info", "portable-pty", diff --git a/code-rs/Cargo.toml b/code-rs/Cargo.toml index 17efdae2740..83e8fa5171b 100644 --- a/code-rs/Cargo.toml +++ b/code-rs/Cargo.toml @@ -114,6 +114,7 @@ env-flags = "0.1.1" env_logger = "0.11.5" escargot = "0.5" eventsource-stream = "0.2.3" +filetime = "0.2" futures = "0.3" icu_decimal = "2.0.0" icu_locale_core = "2.0.0" diff --git a/code-rs/core/Cargo.toml b/code-rs/core/Cargo.toml index e594c6775fa..0911c4bbdd6 100644 --- a/code-rs/core/Cargo.toml +++ b/code-rs/core/Cargo.toml @@ -102,7 +102,9 @@ windows-sys = { version = "0.61.2", features = [ ] } [dev-dependencies] +filetime = { workspace = true } maplit = { workspace = true } +once_cell = { workspace = true } pretty_assertions = { workspace = true } tokio-test = { workspace = true } wiremock = { workspace = true } diff --git a/code-rs/core/src/rollout/list.rs b/code-rs/core/src/rollout/list.rs index 33d189a308f..30cc64efaf0 100644 --- a/code-rs/core/src/rollout/list.rs +++ b/code-rs/core/src/rollout/list.rs @@ -11,6 +11,7 @@ use time::OffsetDateTime; use time::PrimitiveDateTime; use time::format_description::FormatItem; use time::macros::format_description; +use time::format_description::well_known::Rfc3339; use uuid::Uuid; use super::SESSIONS_SUBDIR; @@ -47,6 +48,8 @@ pub struct ConversationItem { pub created_at: Option, /// RFC3339 timestamp string for the most recent response in the tail, if available. pub updated_at: Option, + /// RFC3339 timestamp string for the file's last modification time, if available. + pub modified_at: Option, } #[derive(Default)] @@ -147,7 +150,7 @@ async fn traverse_directories_for_paths( anchor: Option, allowed_sources: &[SessionSource], ) -> io::Result { - let mut items: Vec = Vec::with_capacity(page_size); + let mut candidates: Vec<(OffsetDateTime, OffsetDateTime, Uuid, ConversationItem)> = Vec::new(); let mut scanned_files = 0usize; let mut anchor_passed = anchor.is_none(); let (anchor_ts, anchor_id) = match anchor { @@ -171,7 +174,7 @@ async fn traverse_directories_for_paths( if scanned_files >= MAX_SCAN_FILES { break 'outer; } - let mut day_files = collect_files(day_path, |name_str, path| { + let day_files = collect_files(day_path, |name_str, path| { if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") { return None; } @@ -180,11 +183,22 @@ async fn traverse_directories_for_paths( .map(|(ts, id)| (ts, id, name_str.to_string(), path.to_path_buf())) }) .await?; - // Stable ordering within the same second: (timestamp desc, uuid desc) - day_files.sort_by_key(|(ts, sid, _name_str, _path)| (Reverse(*ts), Reverse(*sid))); + let mut day_entries = Vec::with_capacity(day_files.len()); for (ts, sid, _name_str, path) in day_files.into_iter() { + let modified = tokio::fs::metadata(&path) + .await + .ok() + .and_then(|meta| meta.modified().ok()) + .map(OffsetDateTime::from) + .unwrap_or(OffsetDateTime::UNIX_EPOCH); + day_entries.push((modified, ts, sid, path)); + } + day_entries.sort_by_key(|(modified, ts, sid, _)| { + (Reverse(*modified), Reverse(*ts), Reverse(*sid)) + }); + for (modified, ts, sid, path) in day_entries.into_iter() { scanned_files += 1; - if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size { + if scanned_files >= MAX_SCAN_FILES { break 'outer; } if !anchor_passed { @@ -194,9 +208,6 @@ async fn traverse_directories_for_paths( continue; } } - if items.len() == page_size { - break 'outer; - } let summary = read_head_and_tail(&path, HEAD_RECORD_LIMIT, TAIL_RECORD_LIMIT) .await .unwrap_or_default(); @@ -216,19 +227,39 @@ async fn traverse_directories_for_paths( .. } = summary; updated_at = updated_at.or_else(|| created_at.clone()); - items.push(ConversationItem { - path, - head, - tail, - created_at, - updated_at, - }); + let modified_at = if modified == OffsetDateTime::UNIX_EPOCH { + updated_at.clone().or_else(|| created_at.clone()) + } else { + modified.format(&Rfc3339).ok() + }; + candidates.push(( + modified, + ts, + sid, + ConversationItem { + path, + head, + tail, + created_at, + updated_at, + modified_at, + }, + )); } } } } } + candidates.sort_by_key(|(modified, ts, sid, _)| { + (Reverse(*modified), Reverse(*ts), Reverse(*sid)) + }); + + let mut items: Vec = Vec::with_capacity(page_size.min(candidates.len())); + for (_, _, _, item) in candidates.into_iter().take(page_size) { + items.push(item); + } + let next = build_next_cursor(&items); Ok(ConversationsPage { items, diff --git a/code-rs/core/src/rollout/tests.rs b/code-rs/core/src/rollout/tests.rs index 2954a2505f0..232431bbc35 100644 --- a/code-rs/core/src/rollout/tests.rs +++ b/code-rs/core/src/rollout/tests.rs @@ -6,10 +6,12 @@ use std::io::BufWriter; use std::io::Write; use std::path::{Path, PathBuf}; +use filetime::{set_file_mtime, FileTime}; use tempfile::TempDir; use time::OffsetDateTime; use time::PrimitiveDateTime; use time::format_description::FormatItem; +use time::format_description::well_known::Rfc3339; use time::macros::format_description; use uuid::Uuid; @@ -19,6 +21,7 @@ use crate::rollout::list::ConversationsPage; use crate::rollout::list::Cursor; use crate::rollout::list::get_conversation; use crate::rollout::list::get_conversations; +use std::time::{Duration, SystemTime}; use code_protocol::models::{ContentItem, ResponseItem}; use code_protocol::ConversationId; use code_protocol::protocol::{ @@ -426,7 +429,7 @@ async fn test_pagination_cursor() { ]; let expected_cursor1: Cursor = serde_json::from_str(&format!("\"2025-03-04T09-00-00|{u4}\"")).unwrap(); - assert_page_summary(&page1, &expected_page1_items, Some(expected_cursor1.clone()), 3); + assert_page_summary(&page1, &expected_page1_items, Some(expected_cursor1.clone()), 5); let page2 = get_conversations( home, @@ -524,6 +527,69 @@ async fn test_get_conversation_contents() { } } +#[tokio::test] +async fn test_list_conversations_prefers_recent_mtime() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let older_uuid = Uuid::from_u128(0x11); + let newer_uuid = Uuid::from_u128(0x22); + + write_session_file( + home, + "2025-08-01T10-00-00", + older_uuid, + 1, + Some(SessionSource::VSCode), + ) + .unwrap(); + write_session_file( + home, + "2025-09-01T10-00-00", + newer_uuid, + 1, + Some(SessionSource::VSCode), + ) + .unwrap(); + + let older_path = home + .join("sessions") + .join("2025") + .join("08") + .join("01") + .join(format!("rollout-2025-08-01T10-00-00-{older_uuid}.jsonl")); + + let future_time = SystemTime::now() + Duration::from_secs(300); + set_file_mtime(&older_path, FileTime::from_system_time(future_time)).unwrap(); + + let page = get_conversations(home, 1, None, INTERACTIVE_SESSION_SOURCES) + .await + .unwrap(); + + let first = page.items.first().expect("expected at least one session"); + assert_eq!(first.path, older_path); + + let modified_at = first + .modified_at + .as_deref() + .expect("expected modified_at"); + let updated_at = first + .updated_at + .as_deref() + .expect("expected updated_at"); + + let modified_dt = OffsetDateTime::parse(modified_at, &Rfc3339).expect("parse modified_at"); + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + let updated_dt = PrimitiveDateTime::parse(updated_at, format) + .expect("parse updated_at") + .assume_utc(); + assert!( + modified_dt > updated_dt, + "modified_at should reflect newer filesystem mtime" + ); +} + #[tokio::test] async fn test_stable_ordering_same_second_pagination() { let temp = TempDir::new().unwrap();