#![allow(warnings, clippy::all)]use codex_utils_path as path_utils;use std::cmp::Reverse;use std::ffi::OsStr;use std::io;use std::num::NonZero;use std::ops::ControlFlow;use std::path::Path;use std::path::PathBuf;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;use super::ARCHIVED_SESSIONS_SUBDIR;use super::SESSIONS_SUBDIR;use super::compression;use crate::protocol::EventMsg;use crate::state_db;use codex_file_search as file_search;use codex_protocol::ThreadId;use codex_protocol::protocol::RolloutItem;use codex_protocol::protocol::RolloutLine;use codex_protocol::protocol::SessionMetaLine;use codex_protocol::protocol::SessionSource;use codex_protocol::protocol::USER_MESSAGE_BEGIN;/// Returned page of thread (thread) summaries.#[derive(Debug, Default, PartialEq)]pub struct ThreadsPage { /// Thread summaries ordered newest first. pub items: Vec<ThreadItem>, /// Opaque pagination token to resume after the last item, or `None` if end. pub next_cursor: Option<Cursor>, /// Total number of files touched while scanning this request. pub num_scanned_files: usize, /// True if a hard scan cap was hit; consider resuming with `next_cursor`. pub reached_scan_cap: bool,}/// Summary information for a thread rollout file.#[derive(Debug, PartialEq, Default)]pub struct ThreadItem { /// Absolute path to the rollout file. pub path: PathBuf, /// Thread ID from session metadata. pub thread_id: Option<ThreadId>, /// First user message captured for this thread, if any. pub first_user_message: Option<String>, /// Best available user-facing preview for discovery and list display. pub preview: Option<String>, /// Working directory from session metadata. pub cwd: Option<PathBuf>, /// Git branch from session metadata. pub git_branch: Option<String>, /// Git commit SHA from session metadata. pub git_sha: Option<String>, /// Git origin URL from session metadata. pub git_origin_url: Option<String>, /// Session source from session metadata. pub source: Option<SessionSource>, /// Immediate control/spawn parent thread id from session metadata. pub parent_thread_id: Option<ThreadId>, /// Random unique nickname from session metadata for AgentControl-spawned sub-agents. pub agent_nickname: Option<String>, /// Role (agent_role) from session metadata for AgentControl-spawned sub-agents. pub agent_role: Option<String>, /// Model provider from session metadata. pub model_provider: Option<String>, /// CLI version from session metadata. pub cli_version: Option<String>, /// RFC3339 timestamp string for when the session was created, if available. /// created_at comes from the filename timestamp with second precision. pub created_at: Option<String>, /// RFC3339 timestamp string for the most recent update (from file mtime). pub updated_at: Option<String>,}#[allow(dead_code)]#[deprecated(note = "use ThreadItem")]pub type ConversationItem = ThreadItem;#[allow(dead_code)]#[deprecated(note = "use ThreadsPage")]pub type ConversationsPage = ThreadsPage;#[derive(Default)]struct HeadTailSummary { saw_session_meta: bool, thread_id: Option<ThreadId>, first_user_message: Option<String>, preview: Option<String>, cwd: Option<PathBuf>, git_branch: Option<String>, git_sha: Option<String>, git_origin_url: Option<String>, source: Option<SessionSource>, parent_thread_id: Option<ThreadId>, agent_nickname: Option<String>, agent_role: Option<String>, model_provider: Option<String>, cli_version: Option<String>, created_at: Option<String>, updated_at: Option<String>,}/// Hard cap to bound worst‑case work per request.const MAX_SCAN_FILES: usize = 10000;const HEAD_RECORD_LIMIT: usize = 10;const USER_EVENT_SCAN_LIMIT: usize = 200;#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum ThreadSortKey { CreatedAt, UpdatedAt,}#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum SortDirection { Asc, Desc,}#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum ThreadListLayout { NestedByDate, Flat,}pub struct ThreadListConfig<'a> { pub allowed_sources: &'a [SessionSource], pub model_providers: Option<&'a [String]>, pub cwd_filters: Option<&'a [PathBuf]>, pub default_provider: &'a str, pub layout: ThreadListLayout,}/// Pagination cursor identifying the timestamp of the last item in a page.#[derive(Debug, Clone, PartialEq, Eq)]pub struct Cursor { ts: OffsetDateTime,}impl Cursor { fn new(ts: OffsetDateTime) -> Self { Self { ts } } pub(crate) fn timestamp(&self) -> OffsetDateTime { self.ts }}/// Keeps track of where a paginated listing left off. As the file scan goes newest -> oldest,/// it ignores everything until it passes the last seen timestamp from the previous page, then/// starts returning results after that. This makes paging stable even if new files show up during/// pagination.struct AnchorState { ts: OffsetDateTime, passed: bool,}impl AnchorState { fn new(anchor: Option<Cursor>) -> Self { match anchor { Some(cursor) => Self { ts: cursor.ts, passed: false, }, None => Self { ts: OffsetDateTime::UNIX_EPOCH, passed: true, }, } } fn should_skip(&mut self, ts: OffsetDateTime, _id: Uuid) -> bool { if self.passed { return false; } if ts < self.ts { self.passed = true; false } else { true } }}/// Visitor interface to customize behavior when visiting each rollout file/// in `walk_rollout_files`.////// We need to apply different logic if we're ultimately going to be returning/// threads ordered by created_at or updated_at.trait RolloutFileVisitor { fn visit( &mut self, ts: OffsetDateTime, id: Uuid, path: PathBuf, scanned: usize, ) -> impl std::future::Future<Output = ControlFlow<()>> + Send;}/// Collects thread items during directory traversal in created_at order,/// applying pagination and filters inline.struct FilesByCreatedAtVisitor<'a> { items: &'a mut Vec<ThreadItem>, page_size: usize, anchor_state: AnchorState, more_matches_available: bool, allowed_sources: &'a [SessionSource], provider_matcher: Option<&'a ProviderMatcher<'a>>, cwd_filters: Option<&'a [PathBuf]>,}impl<'a> RolloutFileVisitor for FilesByCreatedAtVisitor<'a> { async fn visit( &mut self, ts: OffsetDateTime, id: Uuid, path: PathBuf, scanned: usize, ) -> ControlFlow<()> { if scanned >= MAX_SCAN_FILES && self.items.len() >= self.page_size { self.more_matches_available = true; return ControlFlow::Break(()); } if self.anchor_state.should_skip(ts, id) { return ControlFlow::Continue(()); } if self.items.len() == self.page_size { self.more_matches_available = true; return ControlFlow::Break(()); } let updated_at = file_modified_time(&path) .await .unwrap_or(None) .and_then(format_rfc3339); if let Some(item) = build_thread_item( path, self.allowed_sources, self.provider_matcher, self.cwd_filters, updated_at, ) .await { self.items.push(item); } ControlFlow::Continue(()) }}/// Collects lightweight file candidates (path + id + mtime)./// Sorting after mtime happens after all files are collected.struct FilesByUpdatedAtVisitor<'a> { candidates: &'a mut Vec<ThreadCandidate>,}impl<'a> RolloutFileVisitor for FilesByUpdatedAtVisitor<'a> { async fn visit( &mut self, _ts: OffsetDateTime, id: Uuid, path: PathBuf, _scanned: usize, ) -> ControlFlow<()> { let updated_at = file_modified_time(&path).await.unwrap_or(None); self.candidates.push(ThreadCandidate { path, id, updated_at, }); ControlFlow::Continue(()) }}impl serde::Serialize for Cursor { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: serde::Serializer, { let ts_str = self .ts .format(&Rfc3339) .map_err(|e| serde::ser::Error::custom(format!("format error: {e}")))?; serializer.serialize_str(&ts_str) }}impl<'de> serde::Deserialize<'de> for Cursor { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; parse_cursor(&s).ok_or_else(|| serde::de::Error::custom("invalid cursor")) }}impl From<codex_state::Anchor> for Cursor { fn from(anchor: codex_state::Anchor) -> Self { let ts = anchor .ts .timestamp_nanos_opt() .and_then(|nanos| OffsetDateTime::from_unix_timestamp_nanos(nanos as i128).ok()) .unwrap_or(OffsetDateTime::UNIX_EPOCH); Self::new(ts) }}/// Retrieve recorded thread file paths with token pagination. The returned `next_cursor`/// can be supplied on the next call to resume after the last returned item, resilient to/// concurrent new sessions being appended. Ordering is stable by the requested sort key/// (timestamp desc).pub async fn get_threads( codex_home: &Path, page_size: usize, cursor: Option<&Cursor>, sort_key: ThreadSortKey, allowed_sources: &[SessionSource], model_providers: Option<&[String]>, cwd_filters: Option<&[PathBuf]>, default_provider: &str,) -> io::Result<ThreadsPage> { let root = codex_home.join(SESSIONS_SUBDIR); get_threads_in_root( root, page_size, cursor, sort_key, ThreadListConfig { allowed_sources, model_providers, cwd_filters, default_provider, layout: ThreadListLayout::NestedByDate, }, ) .await}pub async fn get_threads_in_root( root: PathBuf, page_size: usize, cursor: Option<&Cursor>, sort_key: ThreadSortKey, config: ThreadListConfig<'_>,) -> io::Result<ThreadsPage> { if !root.exists() { return Ok(ThreadsPage { items: Vec::new(), next_cursor: None, num_scanned_files: 0, reached_scan_cap: false, }); } let anchor = cursor.cloned(); let provider_matcher = config .model_providers .and_then(|filters| ProviderMatcher::new(filters, config.default_provider)); let result = match config.layout { ThreadListLayout::NestedByDate => { traverse_directories_for_paths( root.clone(), page_size, anchor, sort_key, config.allowed_sources, provider_matcher.as_ref(), config.cwd_filters, ) .await? } ThreadListLayout::Flat => { traverse_flat_paths( root.clone(), page_size, anchor, sort_key, config.allowed_sources, provider_matcher.as_ref(), config.cwd_filters, ) .await? } }; Ok(result)}/// Load thread file paths from disk using directory traversal.////// Directory layout: `~/.codex/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl`/// Returned newest (based on sort key) first.async fn traverse_directories_for_paths( root: PathBuf, page_size: usize, anchor: Option<Cursor>, sort_key: ThreadSortKey, allowed_sources: &[SessionSource], provider_matcher: Option<&ProviderMatcher<'_>>, cwd_filters: Option<&[PathBuf]>,) -> io::Result<ThreadsPage> { match sort_key { ThreadSortKey::CreatedAt => { traverse_directories_for_paths_created( root, page_size, anchor, allowed_sources, provider_matcher, cwd_filters, ) .await } ThreadSortKey::UpdatedAt => { traverse_directories_for_paths_updated( root, page_size, anchor, allowed_sources, provider_matcher, cwd_filters, ) .await } }}async fn traverse_flat_paths( root: PathBuf, page_size: usize, anchor: Option<Cursor>, sort_key: ThreadSortKey, allowed_sources: &[SessionSource], provider_matcher: Option<&ProviderMatcher<'_>>, cwd_filters: Option<&[PathBuf]>,) -> io::Result<ThreadsPage> { match sort_key { ThreadSortKey::CreatedAt => { traverse_flat_paths_created( root, page_size, anchor, allowed_sources, provider_matcher, cwd_filters, ) .await } ThreadSortKey::UpdatedAt => { traverse_flat_paths_updated( root, page_size, anchor, allowed_sources, provider_matcher, cwd_filters, ) .await } }}/// Walk the rollout directory tree in reverse chronological order and/// collect items until the page fills or the scan cap is hit.////// Ordering comes from directory/filename sorting, so created_at is derived/// from the filename timestamp. Pagination is handled by the anchor cursor/// so we resume strictly after the last returned `(ts, id)` pair.async fn traverse_directories_for_paths_created( root: PathBuf, page_size: usize, anchor: Option<Cursor>, allowed_sources: &[SessionSource], provider_matcher: Option<&ProviderMatcher<'_>>, cwd_filters: Option<&[PathBuf]>,) -> io::Result<ThreadsPage> { let mut items: Vec<ThreadItem> = Vec::with_capacity(page_size); let mut scanned_files = 0usize; let mut more_matches_available = false; let mut visitor = FilesByCreatedAtVisitor { items: &mut items, page_size, anchor_state: AnchorState::new(anchor), more_matches_available, allowed_sources, provider_matcher, cwd_filters, }; walk_rollout_files(&root, &mut scanned_files, &mut visitor).await?; more_matches_available = visitor.more_matches_available; let reached_scan_cap = scanned_files >= MAX_SCAN_FILES; if reached_scan_cap && !items.is_empty() { more_matches_available = true; } let next = if more_matches_available { build_next_cursor(&items, ThreadSortKey::CreatedAt) } else { None }; Ok(ThreadsPage { items, next_cursor: next, num_scanned_files: scanned_files, reached_scan_cap, })}/// Walk the rollout directory tree to collect files by updated_at, then sort by/// file mtime (updated_at) and apply pagination/filtering in that order.////// Because updated_at is not encoded in filenames, this path must scan all/// files up to the scan cap, then sort and filter by the anchor cursor.////// NOTE: This can be optimized in the future if we store additional state on disk/// to cache updated_at timestamps.async fn traverse_directories_for_paths_updated( root: PathBuf, page_size: usize, anchor: Option<Cursor>, allowed_sources: &[SessionSource], provider_matcher: Option<&ProviderMatcher<'_>>, cwd_filters: Option<&[PathBuf]>,) -> io::Result<ThreadsPage> { let mut items: Vec<ThreadItem> = Vec::with_capacity(page_size); let mut scanned_files = 0usize; let mut anchor_state = AnchorState::new(anchor); let mut more_matches_available = false; let mut candidates = collect_files_by_updated_at(&root, &mut scanned_files).await?; candidates.sort_by_key(|candidate| { let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH); (Reverse(ts), Reverse(candidate.id)) }); for candidate in candidates.into_iter() { let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH); if anchor_state.should_skip(ts, candidate.id) { continue; } if items.len() == page_size { more_matches_available = true; break; } let updated_at_fallback = candidate.updated_at.and_then(format_rfc3339); if let Some(item) = build_thread_item( candidate.path, allowed_sources, provider_matcher, cwd_filters, updated_at_fallback, ) .await { items.push(item); } } let reached_scan_cap = scanned_files >= MAX_SCAN_FILES; if reached_scan_cap && !items.is_empty() { more_matches_available = true; } let next = if more_matches_available { build_next_cursor(&items, ThreadSortKey::UpdatedAt) } else { None }; Ok(ThreadsPage { items, next_cursor: next, num_scanned_files: scanned_files, reached_scan_cap, })}async fn traverse_flat_paths_created( root: PathBuf, page_size: usize, anchor: Option<Cursor>, allowed_sources: &[SessionSource], provider_matcher: Option<&ProviderMatcher<'_>>, cwd_filters: Option<&[PathBuf]>,) -> io::Result<ThreadsPage> { let mut items: Vec<ThreadItem> = Vec::with_capacity(page_size); let mut scanned_files = 0usize; let mut anchor_state = AnchorState::new(anchor); let mut more_matches_available = false; let files = collect_flat_rollout_files(&root, &mut scanned_files).await?; for (ts, id, path) in files.into_iter() { if anchor_state.should_skip(ts, id) { continue; } if items.len() == page_size { more_matches_available = true; break; } let updated_at = file_modified_time(&path) .await .unwrap_or(None) .and_then(format_rfc3339); if let Some(item) = build_thread_item( path, allowed_sources, provider_matcher, cwd_filters, updated_at, ) .await { items.push(item); } } let reached_scan_cap = scanned_files >= MAX_SCAN_FILES; if reached_scan_cap && !items.is_empty() { more_matches_available = true; } let next = if more_matches_available { build_next_cursor(&items, ThreadSortKey::CreatedAt) } else { None }; Ok(ThreadsPage { items, next_cursor: next, num_scanned_files: scanned_files, reached_scan_cap, })}async fn traverse_flat_paths_updated( root: PathBuf, page_size: usize, anchor: Option<Cursor>, allowed_sources: &[SessionSource], provider_matcher: Option<&ProviderMatcher<'_>>, cwd_filters: Option<&[PathBuf]>,) -> io::Result<ThreadsPage> { let mut items: Vec<ThreadItem> = Vec::with_capacity(page_size); let mut scanned_files = 0usize; let mut anchor_state = AnchorState::new(anchor); let mut more_matches_available = false; let mut candidates = collect_flat_files_by_updated_at(&root, &mut scanned_files).await?; candidates.sort_by_key(|candidate| { let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH); (Reverse(ts), Reverse(candidate.id)) }); for candidate in candidates.into_iter() { let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH); if anchor_state.should_skip(ts, candidate.id) { continue; } if items.len() == page_size { more_matches_available = true; break; } let updated_at_fallback = candidate.updated_at.and_then(format_rfc3339); if let Some(item) = build_thread_item( candidate.path, allowed_sources, provider_matcher, cwd_filters, updated_at_fallback, ) .await { items.push(item); } } let reached_scan_cap = scanned_files >= MAX_SCAN_FILES; if reached_scan_cap && !items.is_empty() { more_matches_available = true; } let next = if more_matches_available { build_next_cursor(&items, ThreadSortKey::UpdatedAt) } else { None }; Ok(ThreadsPage { items, next_cursor: next, num_scanned_files: scanned_files, reached_scan_cap, })}/// Pagination cursor token format: an RFC3339 timestamp.pub fn parse_cursor(token: &str) -> Option<Cursor> { if token.contains('|') { return None; } let ts = OffsetDateTime::parse(token, &Rfc3339).ok().or_else(|| { let format: &[FormatItem] = format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); PrimitiveDateTime::parse(token, format) .ok() .map(PrimitiveDateTime::assume_utc) })?; Some(Cursor::new(ts))}fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option<Cursor> { let last = items.last()?; let file_name = last.path.file_name()?.to_string_lossy(); let (created_ts, _id) = parse_timestamp_uuid_from_filename(&file_name)?; let ts = match sort_key { ThreadSortKey::CreatedAt => created_ts, ThreadSortKey::UpdatedAt => { let updated_at = last.updated_at.as_deref()?; OffsetDateTime::parse(updated_at, &Rfc3339).ok()? } }; Some(Cursor::new(ts))}async fn build_thread_item( path: PathBuf, allowed_sources: &[SessionSource], provider_matcher: Option<&ProviderMatcher<'_>>, cwd_filters: Option<&[PathBuf]>, updated_at: Option<String>,) -> Option<ThreadItem> { // Read head and detect preview-bearing events; goal previews can appear before // the first normal user message. let summary = read_head_summary(&path, HEAD_RECORD_LIMIT) .await .unwrap_or_default(); if !allowed_sources.is_empty() && !summary .source .as_ref() .is_some_and(|source| allowed_sources.contains(source)) { return None; } if let Some(matcher) = provider_matcher && !matcher.matches(summary.model_provider.as_deref()) { return None; } if let Some(cwd_filters) = cwd_filters && !summary.cwd.as_ref().is_some_and(|cwd| { cwd_filters .iter() .any(|filter| path_utils::paths_match_after_normalization(cwd, filter)) }) { return None; } // Apply filters: must have session meta and a discoverable preview. if summary.saw_session_meta && summary.preview.is_some() { let HeadTailSummary { thread_id, first_user_message, preview, cwd, git_branch, git_sha, git_origin_url, source, parent_thread_id, agent_nickname, agent_role, model_provider, cli_version, created_at, updated_at: mut summary_updated_at, .. } = summary; if summary_updated_at.is_none() { summary_updated_at = updated_at.or_else(|| created_at.clone()); } return Some(ThreadItem { path, thread_id, first_user_message, preview, cwd, git_branch, git_sha, git_origin_url, source, parent_thread_id, agent_nickname, agent_role, model_provider, cli_version, created_at, updated_at: summary_updated_at, }); } None}/// Read a single rollout file into the same summary item shape used by thread listing.////// This is for callers that already resolved a rollout path and need the same/// metadata/preview extraction as list operations without scanning the whole/// sessions tree.pub async fn read_thread_item_from_rollout(path: PathBuf) -> Option<ThreadItem> { build_thread_item( path, &[], /*provider_matcher*/ None, /*cwd_filters*/ None, /*updated_at*/ None, ) .await}/// Collects immediate subdirectories of `parent`, parses their (string) names with `parse`,/// and returns them sorted descending by the parsed key.async fn collect_dirs_desc<T, F>(parent: &Path, parse: F) -> io::Result<Vec<(T, PathBuf)>>where T: Ord + Copy, F: Fn(&str) -> Option<T>,{ let mut dir = tokio::fs::read_dir(parent).await?; let mut vec: Vec<(T, PathBuf)> = Vec::new(); while let Some(entry) = dir.next_entry().await? { if entry .file_type() .await .map(|ft| ft.is_dir()) .unwrap_or(false) && let Some(s) = entry.file_name().to_str() && let Some(v) = parse(s) { vec.push((v, entry.path())); } } vec.sort_by_key(|(v, _)| Reverse(*v)); Ok(vec)}/// Collects files in a directory and parses them with `parse`.async fn collect_files<T, F>(parent: &Path, parse: F) -> io::Result<Vec<T>>where F: Fn(&str, &Path) -> Option<T>,{ let mut dir = tokio::fs::read_dir(parent).await?; let mut collected: Vec<T> = Vec::new(); while let Some(entry) = dir.next_entry().await? { if entry .file_type() .await .map(|ft| ft.is_file()) .unwrap_or(false) && let Some(s) = entry.file_name().to_str() && let Some(v) = parse(s, &entry.path()) { collected.push(v); } } Ok(collected)}async fn collect_flat_rollout_files( root: &Path, scanned_files: &mut usize,) -> io::Result<Vec<(OffsetDateTime, Uuid, PathBuf)>> { let mut dir = tokio::fs::read_dir(root).await?; let mut collected = Vec::new(); while let Some(entry) = dir.next_entry().await? { if *scanned_files >= MAX_SCAN_FILES { break; } if !entry .file_type() .await .map(|ft| ft.is_file()) .unwrap_or(false) { continue; } let Some(rollout_file) = compression::RolloutFile::from_path(entry.path()) else { continue; }; let Some((ts, id)) = parse_timestamp_uuid_from_filename(rollout_file.plain_file_name()) else { continue; }; *scanned_files += 1; if *scanned_files > MAX_SCAN_FILES { break; } collected.push((ts, id, rollout_file.into_path())); } collected.sort_by_key(|(ts, sid, _path)| (Reverse(*ts), Reverse(*sid))); Ok(collected)}async fn collect_rollout_day_files( day_path: &Path,) -> io::Result<Vec<(OffsetDateTime, Uuid, PathBuf)>> { let mut day_files = collect_files(day_path, |_name_str, path| { let rollout_file = compression::RolloutFile::from_path(path.to_path_buf())?; parse_timestamp_uuid_from_filename(rollout_file.plain_file_name()) .map(|(ts, id)| (ts, id, rollout_file.into_path())) }) .await?; // Stable ordering within the same second: (timestamp desc, uuid desc) day_files.sort_by_key(|(ts, sid, _path)| (Reverse(*ts), Reverse(*sid))); Ok(day_files)}pub(crate) fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uuid)> { // Expected: rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl[.zst] let name = compression::parse_rollout_file_name(name)?; let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?; // Scan from the right for a '-' such that the suffix parses as a UUID. let (sep_idx, uuid) = core .match_indices('-') .rev() .find_map(|(i, _)| Uuid::parse_str(&core[i + 1..]).ok().map(|u| (i, u)))?; let ts_str = &core[..sep_idx]; let format: &[FormatItem] = format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); let ts = PrimitiveDateTime::parse(ts_str, format).ok()?.assume_utc(); Some((ts, uuid))}struct ThreadCandidate { path: PathBuf, id: Uuid, updated_at: Option<OffsetDateTime>,}async fn collect_files_by_updated_at( root: &Path, scanned_files: &mut usize,) -> io::Result<Vec<ThreadCandidate>> { let mut candidates = Vec::new(); let mut visitor = FilesByUpdatedAtVisitor { candidates: &mut candidates, }; walk_rollout_files(root, scanned_files, &mut visitor).await?; Ok(candidates)}async fn collect_flat_files_by_updated_at( root: &Path, scanned_files: &mut usize,) -> io::Result<Vec<ThreadCandidate>> { let mut candidates = Vec::new(); let mut dir = tokio::fs::read_dir(root).await?; while let Some(entry) = dir.next_entry().await? { if *scanned_files >= MAX_SCAN_FILES { break; } if !entry .file_type() .await .map(|ft| ft.is_file()) .unwrap_or(false) { continue; } let Some(rollout_file) = compression::RolloutFile::from_path(entry.path()) else { continue; }; let Some((_ts, id)) = parse_timestamp_uuid_from_filename(rollout_file.plain_file_name()) else { continue; }; *scanned_files += 1; if *scanned_files > MAX_SCAN_FILES { break; } let updated_at = file_modified_time(rollout_file.path()) .await .unwrap_or(None); candidates.push(ThreadCandidate { path: rollout_file.into_path(), id, updated_at, }); } Ok(candidates)}async fn walk_rollout_files( root: &Path, scanned_files: &mut usize, visitor: &mut impl RolloutFileVisitor,) -> io::Result<()> { let year_dirs = collect_dirs_desc(root, |s| s.parse::<u16>().ok()).await?; 'outer: for (_year, year_path) in year_dirs.iter() { if *scanned_files >= MAX_SCAN_FILES { break; } let month_dirs = collect_dirs_desc(year_path, |s| s.parse::<u8>().ok()).await?; for (_month, month_path) in month_dirs.iter() { if *scanned_files >= MAX_SCAN_FILES { break 'outer; } let day_dirs = collect_dirs_desc(month_path, |s| s.parse::<u8>().ok()).await?; for (_day, day_path) in day_dirs.iter() { if *scanned_files >= MAX_SCAN_FILES { break 'outer; } let day_files = collect_rollout_day_files(day_path).await?; for (ts, id, path) in day_files.into_iter() { *scanned_files += 1; if *scanned_files > MAX_SCAN_FILES { break 'outer; } if let ControlFlow::Break(()) = visitor.visit(ts, id, path, *scanned_files).await { break 'outer; } } } } } Ok(())}struct ProviderMatcher<'a> { filters: &'a [String], matches_default_provider: bool,}impl<'a> ProviderMatcher<'a> { fn new(filters: &'a [String], default_provider: &'a str) -> Option<Self> { if filters.is_empty() { return None; } let matches_default_provider = filters.iter().any(|provider| provider == default_provider); Some(Self { filters, matches_default_provider, }) } fn matches(&self, session_provider: Option<&str>) -> bool { match session_provider { Some(provider) => self.filters.iter().any(|candidate| candidate == provider), None => self.matches_default_provider, } }}async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTailSummary> { let mut lines = compression::open_rollout_line_reader(path).await?; let mut summary = HeadTailSummary::default(); let mut lines_scanned = 0usize; while lines_scanned < head_limit || (summary.saw_session_meta && (summary.preview.is_none() || summary.first_user_message.is_none()) && lines_scanned < head_limit + USER_EVENT_SCAN_LIMIT) { let line_opt = lines.next_line().await?; let Some(line) = line_opt else { break }; let trimmed = line.trim(); if trimmed.is_empty() { continue; } lines_scanned += 1; let parsed: Result<RolloutLine, _> = serde_json::from_str(trimmed); let Ok(rollout_line) = parsed else { continue }; match rollout_line.item { RolloutItem::SessionMeta(session_meta_line) => { if !summary.saw_session_meta { summary.source = Some(session_meta_line.meta.source.clone()); summary.parent_thread_id = session_meta_line.meta.parent_thread_id; summary.agent_nickname = session_meta_line.meta.agent_nickname.clone(); summary.agent_role = session_meta_line.meta.agent_role.clone(); summary.model_provider = session_meta_line.meta.model_provider.clone(); summary.thread_id = Some(session_meta_line.meta.id); summary.cwd = Some(session_meta_line.meta.cwd.clone()); summary.git_branch = session_meta_line .git .as_ref() .and_then(|git| git.branch.clone()); summary.git_sha = session_meta_line .git .as_ref() .and_then(|git| git.commit_hash.as_ref().map(|sha| sha.0.clone())); summary.git_origin_url = session_meta_line .git .as_ref() .and_then(|git| git.repository_url.clone()); summary.cli_version = Some(session_meta_line.meta.cli_version); summary.created_at = Some(session_meta_line.meta.timestamp.clone()); summary.saw_session_meta = true; } } RolloutItem::ResponseItem(_) | RolloutItem::InterAgentCommunication(_) => { summary .created_at .get_or_insert_with(|| rollout_line.timestamp.clone()); } RolloutItem::TurnContext(_) => { // Not included in `head`; skip. } RolloutItem::Compacted(_) => { // Not included in `head`; skip. } RolloutItem::EventMsg(ev) => { if let Some(preview) = event_msg_preview(&ev) { if summary.preview.is_none() { summary.preview = Some(preview.clone()); } if let EventMsg::UserMessage(_) = ev && summary.first_user_message.is_none() { summary.first_user_message = Some(preview); } } } } if summary.saw_session_meta && summary.preview.is_some() && summary.first_user_message.is_some() { break; } } Ok(summary)}/// Read up to `HEAD_RECORD_LIMIT` records from the start of the rollout file at `path`./// This should be enough to produce a summary including the session meta line.pub async fn read_head_for_summary(path: &Path) -> io::Result<Vec<serde_json::Value>> { let mut lines = compression::open_rollout_line_reader(path).await?; let mut head = Vec::new(); while head.len() < HEAD_RECORD_LIMIT { let Some(line) = lines.next_line().await? else { break; }; let trimmed = line.trim(); if trimmed.is_empty() { continue; } if let Ok(rollout_line) = serde_json::from_str::<RolloutLine>(trimmed) { match rollout_line.item { RolloutItem::SessionMeta(session_meta_line) => { if let Ok(value) = serde_json::to_value(session_meta_line) { head.push(value); } } RolloutItem::ResponseItem(item) => { if let Ok(value) = serde_json::to_value(item) { head.push(value); } } RolloutItem::InterAgentCommunication(communication) => { if let Ok(value) = serde_json::to_value(communication.to_model_input_item()) { head.push(value); } } RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) | RolloutItem::EventMsg(_) => {} } } } Ok(head)}fn strip_user_message_prefix(text: &str) -> &str { match text.find(USER_MESSAGE_BEGIN) { Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(), None => text.trim(), }}fn event_msg_preview(event: &EventMsg) -> Option<String> { match event { EventMsg::UserMessage(user) => { let message = strip_user_message_prefix(user.message.as_str()); if !message.is_empty() { return Some(message.to_string()); } if user .images .as_ref() .is_some_and(|images| !images.is_empty()) || !user.local_images.is_empty() { return Some("[Image]".to_string()); } None } EventMsg::ThreadGoalUpdated(event) => { let objective = event.goal.objective.trim(); (!objective.is_empty()).then(|| objective.to_string()) } _ => None, }}/// Read the SessionMetaLine from the head of a rollout file for reuse by/// callers that need the session metadata (e.g. to derive a cwd for config).pub async fn read_session_meta_line(path: &Path) -> io::Result<SessionMetaLine> { let head = read_head_for_summary(path).await?; let Some(first) = head.first() else { return Err(io::Error::other(format!( "rollout at {} is empty", path.display() ))); }; serde_json::from_value::<SessionMetaLine>(first.clone()).map_err(|_| { io::Error::other(format!( "rollout at {} does not start with session metadata", path.display() )) })}async fn file_modified_time(path: &Path) -> io::Result<Option<OffsetDateTime>> { Ok(compression::file_modified_time(path) .await? .and_then(truncate_to_millis))}fn format_rfc3339(dt: OffsetDateTime) -> Option<String> { dt.format(&Rfc3339).ok()}fn truncate_to_millis(dt: OffsetDateTime) -> Option<OffsetDateTime> { let millis_nanos = (dt.nanosecond() / 1_000_000) * 1_000_000; dt.replace_nanosecond(millis_nanos).ok()}async fn find_thread_path_by_id_str_in_subdir( codex_home: &Path, subdir: &str, id_str: &str, state_db_ctx: Option<&codex_state::StateRuntime>,) -> io::Result<Option<PathBuf>> { // Validate UUID format early. if Uuid::parse_str(id_str).is_err() { return Ok(None); } // Prefer DB lookup, then fall back to rollout file search. // TODO(jif): sqlite migration phase 1 let archived_only = match subdir { SESSIONS_SUBDIR => Some(false), ARCHIVED_SESSIONS_SUBDIR => Some(true), _ => None, }; let thread_id = ThreadId::from_string(id_str).ok(); let mut unverified_db_path = None; let mut fallback_reason = state_db_ctx.is_none().then_some("db_unavailable"); if let Some(state_db_ctx) = state_db_ctx && let Some(thread_id) = thread_id { match state_db_ctx .find_rollout_path_by_id(thread_id, archived_only) .await { Ok(Some(db_path)) => { if let Some(existing_db_path) = compression::existing_rollout_path(db_path.as_path()).await { match read_session_meta_line(&existing_db_path).await { Ok(meta_line) if meta_line.meta.id == thread_id => { return Ok(Some(existing_db_path)); } Ok(meta_line) => { tracing::error!( "state db returned rollout path for thread {id_str} but file belongs to thread {}: {}", meta_line.meta.id, existing_db_path.display() ); tracing::warn!( "state db discrepancy during find_thread_path_by_id_str_in_subdir: mismatched_db_path" ); codex_state::record_fallback( "find_thread_path", "mismatch", /*telemetry_override*/ None, ); } Err(err) => { tracing::debug!( "state db returned rollout path for thread {id_str} that could not be verified: {}: {err}", existing_db_path.display() ); unverified_db_path = Some(existing_db_path); } } } else { tracing::error!( "state db returned stale rollout path for thread {id_str}: {}", db_path.display() ); tracing::warn!( "state db discrepancy during find_thread_path_by_id_str_in_subdir: stale_db_path" ); codex_state::record_fallback( "find_thread_path", "stale_path", /*telemetry_override*/ None, ); } } Ok(None) => fallback_reason = Some("missing_row"), Err(err) => { tracing::warn!( "state db find_rollout_path_by_id failed during find_path_query: {err}" ); fallback_reason = Some("db_error"); } } } let mut root = codex_home.to_path_buf(); root.push(subdir); if !root.exists() { return Ok(unverified_db_path); } let (filename_match, filename_scan_error) = match find_rollout_path_by_id_from_filenames( root.as_path(), id_str, ) .await { Ok(path) => (path, None), Err(err) => { tracing::warn!( "rollout filename lookup failed during find_thread_path_by_id_str_in_subdir: {err}" ); (None, Some(err)) } }; let found = match filename_match { Some(path) => Some(path), None => { // This is safe because we know the values are valid. #[allow(clippy::unwrap_used)] let limit = NonZero::new(1).unwrap(); let options = file_search::FileSearchOptions { limit, compute_indices: false, respect_gitignore: false, ..Default::default() }; let results = file_search::run( id_str, vec![root.clone()], options, /*cancel_flag*/ None, ) .map_err(|e| io::Error::other(format!("file search failed: {e}")))?; let found = results .matches .into_iter() .map(|m| m.full_path()) .find_map(compression::RolloutFile::from_path) .map(compression::RolloutFile::into_path); if found.is_none() && let Some(err) = filename_scan_error { return Err(err); } found } }; if let Some(found_path) = found.as_ref() { tracing::debug!("state db missing rollout path for thread {id_str}"); tracing::warn!( "state db discrepancy during find_thread_path_by_id_str_in_subdir: falling_back" ); if let Some(reason) = fallback_reason { codex_state::record_fallback( "find_thread_path", reason, /*telemetry_override*/ None, ); } state_db::read_repair_rollout_path( state_db_ctx, thread_id, archived_only, found_path.as_path(), ) .await; } Ok(found.or(unverified_db_path))}async fn find_rollout_path_by_id_from_filenames( root: &Path, id_str: &str,) -> io::Result<Option<PathBuf>> { let Ok(target) = Uuid::parse_str(id_str) else { return Ok(None); }; let mut stack = vec![root.to_path_buf()]; while let Some(dir) = stack.pop() { let mut read_dir = match tokio::fs::read_dir(dir.as_path()).await { Ok(read_dir) => read_dir, Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => return Err(err), }; while let Some(entry) = read_dir.next_entry().await? { let path = entry.path(); let file_type = entry.file_type().await?; if file_type.is_dir() { stack.push(path); continue; } if !file_type.is_file() { continue; } let Some(rollout_file) = compression::RolloutFile::from_path(path) else { continue; }; let Some((_ts, id)) = parse_timestamp_uuid_from_filename(rollout_file.plain_file_name()) else { continue; }; if id == target { return Ok(Some(rollout_file.into_path())); } } } Ok(None)}/// Locate a recorded thread rollout file by its UUID string using the existing/// paginated listing implementation. Returns `Ok(Some(path))` if found, `Ok(None)` if not present/// or the id is invalid.pub async fn find_thread_path_by_id_str( codex_home: &Path, id_str: &str, state_db_ctx: Option<&codex_state::StateRuntime>,) -> io::Result<Option<PathBuf>> { find_thread_path_by_id_str_in_subdir(codex_home, SESSIONS_SUBDIR, id_str, state_db_ctx).await}/// Locate an archived thread rollout file by its UUID string.pub async fn find_archived_thread_path_by_id_str( codex_home: &Path, id_str: &str, state_db_ctx: Option<&codex_state::StateRuntime>,) -> io::Result<Option<PathBuf>> { find_thread_path_by_id_str_in_subdir(codex_home, ARCHIVED_SESSIONS_SUBDIR, id_str, state_db_ctx) .await}/// Extract the `YYYY/MM/DD` directory components from a rollout filename.pub fn rollout_date_parts(file_name: &OsStr) -> Option<(String, String, String)> { let name = file_name.to_string_lossy(); let date = name.strip_prefix("rollout-")?.get(..10)?; let year = date.get(..4)?.to_string(); let month = date.get(5..7)?.to_string(); let day = date.get(8..10)?.to_string(); Some((year, month, day))}