Skip to main content

codex_rollout/
list.rs

1#![allow(warnings, clippy::all)]
2
3use codex_utils_path as path_utils;
4use std::cmp::Reverse;
5use std::ffi::OsStr;
6use std::io;
7use std::num::NonZero;
8use std::ops::ControlFlow;
9use std::path::Path;
10use std::path::PathBuf;
11use time::OffsetDateTime;
12use time::PrimitiveDateTime;
13use time::format_description::FormatItem;
14use time::format_description::well_known::Rfc3339;
15use time::macros::format_description;
16use uuid::Uuid;
17
18use super::ARCHIVED_SESSIONS_SUBDIR;
19use super::SESSIONS_SUBDIR;
20use super::compression;
21use crate::protocol::EventMsg;
22use crate::state_db;
23use codex_file_search as file_search;
24use codex_protocol::ThreadId;
25use codex_protocol::items::TurnItem;
26use codex_protocol::protocol::RolloutItem;
27use codex_protocol::protocol::RolloutLine;
28use codex_protocol::protocol::SessionMetaLine;
29use codex_protocol::protocol::SessionSource;
30use codex_protocol::protocol::ThreadHistoryMode;
31use codex_protocol::protocol::user_message_preview;
32use serde_json::Value;
33
34/// Returned page of thread (thread) summaries.
35#[derive(Debug, Default, PartialEq)]
36pub struct ThreadsPage {
37    /// Thread summaries ordered newest first.
38    pub items: Vec<ThreadItem>,
39    /// Opaque pagination token to resume after the last item, or `None` if end.
40    pub next_cursor: Option<Cursor>,
41    /// Total number of files touched while scanning this request.
42    pub num_scanned_files: usize,
43    /// True if a hard scan cap was hit; consider resuming with `next_cursor`.
44    pub reached_scan_cap: bool,
45}
46
47/// Summary information for a thread rollout file.
48#[derive(Debug, PartialEq, Default)]
49pub struct ThreadItem {
50    /// Absolute path to the rollout file.
51    pub path: PathBuf,
52    /// Thread ID from session metadata.
53    pub thread_id: Option<ThreadId>,
54    /// First user message captured for this thread, if any.
55    pub first_user_message: Option<String>,
56    /// Best available user-facing preview for discovery and list display.
57    pub preview: Option<String>,
58    /// Working directory from session metadata.
59    pub cwd: Option<PathBuf>,
60    /// Git branch from session metadata.
61    pub git_branch: Option<String>,
62    /// Git commit SHA from session metadata.
63    pub git_sha: Option<String>,
64    /// Git origin URL from session metadata.
65    pub git_origin_url: Option<String>,
66    /// Session source from session metadata.
67    pub source: Option<SessionSource>,
68    /// Persisted thread history contract selected when this thread was created.
69    pub history_mode: ThreadHistoryMode,
70    /// Immediate control/spawn parent thread id from session metadata.
71    pub parent_thread_id: Option<ThreadId>,
72    /// Random unique nickname from session metadata for AgentControl-spawned sub-agents.
73    pub agent_nickname: Option<String>,
74    /// Role (agent_role) from session metadata for AgentControl-spawned sub-agents.
75    pub agent_role: Option<String>,
76    /// Model provider from session metadata.
77    pub model_provider: Option<String>,
78    /// CLI version from session metadata.
79    pub cli_version: Option<String>,
80    /// RFC3339 timestamp string for when the session was created, if available.
81    /// created_at comes from the filename timestamp with second precision.
82    pub created_at: Option<String>,
83    /// RFC3339 timestamp string for the most recent update (from file mtime).
84    pub updated_at: Option<String>,
85    /// RFC3339 timestamp string used for product recency ordering.
86    pub recency_at: Option<String>,
87}
88
89#[allow(dead_code)]
90#[deprecated(note = "use ThreadItem")]
91pub type ConversationItem = ThreadItem;
92#[allow(dead_code)]
93#[deprecated(note = "use ThreadsPage")]
94pub type ConversationsPage = ThreadsPage;
95
96#[derive(Default)]
97struct HeadTailSummary {
98    saw_session_meta: bool,
99    thread_id: Option<ThreadId>,
100    first_user_message: Option<String>,
101    preview: Option<String>,
102    cwd: Option<PathBuf>,
103    git_branch: Option<String>,
104    git_sha: Option<String>,
105    git_origin_url: Option<String>,
106    source: Option<SessionSource>,
107    history_mode: ThreadHistoryMode,
108    parent_thread_id: Option<ThreadId>,
109    agent_nickname: Option<String>,
110    agent_role: Option<String>,
111    model_provider: Option<String>,
112    cli_version: Option<String>,
113    created_at: Option<String>,
114    updated_at: Option<String>,
115}
116
117/// Hard cap to bound worst‑case work per request.
118const MAX_SCAN_FILES: usize = 10000;
119const HEAD_RECORD_LIMIT: usize = 10;
120const USER_EVENT_SCAN_LIMIT: usize = 200;
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum ThreadSortKey {
124    CreatedAt,
125    UpdatedAt,
126    RecencyAt,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum SortDirection {
131    Asc,
132    Desc,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum ThreadListLayout {
137    NestedByDate,
138    Flat,
139}
140
141pub struct ThreadListConfig<'a> {
142    pub allowed_sources: &'a [SessionSource],
143    pub model_providers: Option<&'a [String]>,
144    pub cwd_filters: Option<&'a [PathBuf]>,
145    pub default_provider: &'a str,
146    pub layout: ThreadListLayout,
147}
148
149/// Pagination cursor identifying the last item in a page.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct Cursor {
152    ts: OffsetDateTime,
153    id: Option<ThreadId>,
154}
155
156impl Cursor {
157    pub(crate) fn new(ts: OffsetDateTime) -> Self {
158        Self { ts, id: None }
159    }
160
161    pub(crate) fn with_thread_id(ts: OffsetDateTime, id: ThreadId) -> Self {
162        Self { ts, id: Some(id) }
163    }
164
165    pub(crate) fn timestamp(&self) -> OffsetDateTime {
166        self.ts
167    }
168
169    pub(crate) fn thread_id(&self) -> Option<ThreadId> {
170        self.id
171    }
172}
173
174/// Keeps track of where a paginated listing left off. As the file scan goes newest -> oldest,
175/// it ignores everything until it passes the last seen timestamp from the previous page, then
176/// starts returning results after that. This makes paging stable even if new files show up during
177/// pagination.
178struct AnchorState {
179    ts: OffsetDateTime,
180    passed: bool,
181}
182
183impl AnchorState {
184    fn new(anchor: Option<Cursor>) -> Self {
185        match anchor {
186            Some(cursor) => Self {
187                ts: cursor.ts,
188                passed: false,
189            },
190            None => Self {
191                ts: OffsetDateTime::UNIX_EPOCH,
192                passed: true,
193            },
194        }
195    }
196
197    fn should_skip(&mut self, ts: OffsetDateTime, _id: Uuid) -> bool {
198        if self.passed {
199            return false;
200        }
201        if ts < self.ts {
202            self.passed = true;
203            false
204        } else {
205            true
206        }
207    }
208}
209
210/// Visitor interface to customize behavior when visiting each rollout file
211/// in `walk_rollout_files`.
212///
213/// We need to apply different logic if we're ultimately going to be returning
214/// threads ordered by created_at or updated_at.
215trait RolloutFileVisitor {
216    fn visit(
217        &mut self,
218        ts: OffsetDateTime,
219        id: Uuid,
220        path: PathBuf,
221        scanned: usize,
222    ) -> impl std::future::Future<Output = ControlFlow<()>> + Send;
223}
224
225/// Collects thread items during directory traversal in created_at order,
226/// applying pagination and filters inline.
227struct FilesByCreatedAtVisitor<'a> {
228    items: &'a mut Vec<ThreadItem>,
229    page_size: usize,
230    anchor_state: AnchorState,
231    more_matches_available: bool,
232    allowed_sources: &'a [SessionSource],
233    provider_matcher: Option<&'a ProviderMatcher<'a>>,
234    cwd_filters: Option<&'a [PathBuf]>,
235}
236
237impl<'a> RolloutFileVisitor for FilesByCreatedAtVisitor<'a> {
238    async fn visit(
239        &mut self,
240        ts: OffsetDateTime,
241        id: Uuid,
242        path: PathBuf,
243        scanned: usize,
244    ) -> ControlFlow<()> {
245        if scanned >= MAX_SCAN_FILES && self.items.len() >= self.page_size {
246            self.more_matches_available = true;
247            return ControlFlow::Break(());
248        }
249        if self.anchor_state.should_skip(ts, id) {
250            return ControlFlow::Continue(());
251        }
252        if self.items.len() == self.page_size {
253            self.more_matches_available = true;
254            return ControlFlow::Break(());
255        }
256        let updated_at = file_modified_time(&path)
257            .await
258            .unwrap_or(None)
259            .and_then(format_rfc3339);
260        if let Some(item) = build_thread_item(
261            path,
262            self.allowed_sources,
263            self.provider_matcher,
264            self.cwd_filters,
265            updated_at,
266        )
267        .await
268        {
269            self.items.push(item);
270        }
271        ControlFlow::Continue(())
272    }
273}
274
275/// Collects lightweight file candidates (path + id + mtime).
276/// Sorting after mtime happens after all files are collected.
277struct FilesByUpdatedAtVisitor<'a> {
278    candidates: &'a mut Vec<ThreadCandidate>,
279}
280
281impl<'a> RolloutFileVisitor for FilesByUpdatedAtVisitor<'a> {
282    async fn visit(
283        &mut self,
284        _ts: OffsetDateTime,
285        id: Uuid,
286        path: PathBuf,
287        _scanned: usize,
288    ) -> ControlFlow<()> {
289        let updated_at = file_modified_time(&path).await.unwrap_or(None);
290        self.candidates.push(ThreadCandidate {
291            path,
292            id,
293            updated_at,
294        });
295        ControlFlow::Continue(())
296    }
297}
298
299impl serde::Serialize for Cursor {
300    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
301    where
302        S: serde::Serializer,
303    {
304        let ts_str = self
305            .ts
306            .format(&Rfc3339)
307            .map_err(|e| serde::ser::Error::custom(format!("format error: {e}")))?;
308        match self.id {
309            Some(id) => serializer.serialize_str(&format!("{ts_str}|{id}")),
310            None => serializer.serialize_str(&ts_str),
311        }
312    }
313}
314
315impl<'de> serde::Deserialize<'de> for Cursor {
316    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
317    where
318        D: serde::Deserializer<'de>,
319    {
320        let s = String::deserialize(deserializer)?;
321        parse_cursor(&s).ok_or_else(|| serde::de::Error::custom("invalid cursor"))
322    }
323}
324
325impl From<codex_state::Anchor> for Cursor {
326    fn from(anchor: codex_state::Anchor) -> Self {
327        let ts = anchor
328            .ts
329            .timestamp_nanos_opt()
330            .and_then(|nanos| OffsetDateTime::from_unix_timestamp_nanos(nanos as i128).ok())
331            .unwrap_or(OffsetDateTime::UNIX_EPOCH);
332        Self { ts, id: anchor.id }
333    }
334}
335
336/// Retrieve recorded thread file paths with token pagination. The returned `next_cursor`
337/// can be supplied on the next call to resume after the last returned item, resilient to
338/// concurrent new sessions being appended. Ordering is stable by the requested sort key
339/// (timestamp desc).
340pub async fn get_threads(
341    codex_home: &Path,
342    page_size: usize,
343    cursor: Option<&Cursor>,
344    sort_key: ThreadSortKey,
345    allowed_sources: &[SessionSource],
346    model_providers: Option<&[String]>,
347    cwd_filters: Option<&[PathBuf]>,
348    default_provider: &str,
349) -> io::Result<ThreadsPage> {
350    let root = codex_home.join(SESSIONS_SUBDIR);
351    get_threads_in_root(
352        root,
353        page_size,
354        cursor,
355        sort_key,
356        ThreadListConfig {
357            allowed_sources,
358            model_providers,
359            cwd_filters,
360            default_provider,
361            layout: ThreadListLayout::NestedByDate,
362        },
363    )
364    .await
365}
366
367pub async fn get_threads_in_root(
368    root: PathBuf,
369    page_size: usize,
370    cursor: Option<&Cursor>,
371    sort_key: ThreadSortKey,
372    config: ThreadListConfig<'_>,
373) -> io::Result<ThreadsPage> {
374    if !root.exists() {
375        return Ok(ThreadsPage {
376            items: Vec::new(),
377            next_cursor: None,
378            num_scanned_files: 0,
379            reached_scan_cap: false,
380        });
381    }
382
383    let anchor = cursor.cloned();
384
385    let provider_matcher = config
386        .model_providers
387        .and_then(|filters| ProviderMatcher::new(filters, config.default_provider));
388
389    let result = match config.layout {
390        ThreadListLayout::NestedByDate => {
391            traverse_directories_for_paths(
392                root.clone(),
393                page_size,
394                anchor,
395                sort_key,
396                config.allowed_sources,
397                provider_matcher.as_ref(),
398                config.cwd_filters,
399            )
400            .await?
401        }
402        ThreadListLayout::Flat => {
403            traverse_flat_paths(
404                root.clone(),
405                page_size,
406                anchor,
407                sort_key,
408                config.allowed_sources,
409                provider_matcher.as_ref(),
410                config.cwd_filters,
411            )
412            .await?
413        }
414    };
415    Ok(result)
416}
417
418/// Load thread file paths from disk using directory traversal.
419///
420/// Directory layout: `~/.codex/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl`
421/// Returned newest (based on sort key) first.
422async fn traverse_directories_for_paths(
423    root: PathBuf,
424    page_size: usize,
425    anchor: Option<Cursor>,
426    sort_key: ThreadSortKey,
427    allowed_sources: &[SessionSource],
428    provider_matcher: Option<&ProviderMatcher<'_>>,
429    cwd_filters: Option<&[PathBuf]>,
430) -> io::Result<ThreadsPage> {
431    match sort_key {
432        ThreadSortKey::CreatedAt => {
433            traverse_directories_for_paths_created(
434                root,
435                page_size,
436                anchor,
437                allowed_sources,
438                provider_matcher,
439                cwd_filters,
440            )
441            .await
442        }
443        ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => {
444            traverse_directories_for_paths_updated(
445                root,
446                page_size,
447                anchor,
448                allowed_sources,
449                provider_matcher,
450                cwd_filters,
451            )
452            .await
453        }
454    }
455}
456
457async fn traverse_flat_paths(
458    root: PathBuf,
459    page_size: usize,
460    anchor: Option<Cursor>,
461    sort_key: ThreadSortKey,
462    allowed_sources: &[SessionSource],
463    provider_matcher: Option<&ProviderMatcher<'_>>,
464    cwd_filters: Option<&[PathBuf]>,
465) -> io::Result<ThreadsPage> {
466    match sort_key {
467        ThreadSortKey::CreatedAt => {
468            traverse_flat_paths_created(
469                root,
470                page_size,
471                anchor,
472                allowed_sources,
473                provider_matcher,
474                cwd_filters,
475            )
476            .await
477        }
478        ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => {
479            traverse_flat_paths_updated(
480                root,
481                page_size,
482                anchor,
483                allowed_sources,
484                provider_matcher,
485                cwd_filters,
486            )
487            .await
488        }
489    }
490}
491
492/// Walk the rollout directory tree in reverse chronological order and
493/// collect items until the page fills or the scan cap is hit.
494///
495/// Ordering comes from directory/filename sorting, so created_at is derived
496/// from the filename timestamp. Pagination is handled by the anchor cursor
497/// so we resume strictly after the last returned `(ts, id)` pair.
498async fn traverse_directories_for_paths_created(
499    root: PathBuf,
500    page_size: usize,
501    anchor: Option<Cursor>,
502    allowed_sources: &[SessionSource],
503    provider_matcher: Option<&ProviderMatcher<'_>>,
504    cwd_filters: Option<&[PathBuf]>,
505) -> io::Result<ThreadsPage> {
506    let mut items: Vec<ThreadItem> = Vec::with_capacity(page_size);
507    let mut scanned_files = 0usize;
508    let mut more_matches_available = false;
509    let mut visitor = FilesByCreatedAtVisitor {
510        items: &mut items,
511        page_size,
512        anchor_state: AnchorState::new(anchor),
513        more_matches_available,
514        allowed_sources,
515        provider_matcher,
516        cwd_filters,
517    };
518    walk_rollout_files(&root, &mut scanned_files, &mut visitor).await?;
519    more_matches_available = visitor.more_matches_available;
520
521    let reached_scan_cap = scanned_files >= MAX_SCAN_FILES;
522    if reached_scan_cap && !items.is_empty() {
523        more_matches_available = true;
524    }
525
526    let next = if more_matches_available {
527        build_next_cursor(&items, ThreadSortKey::CreatedAt)
528    } else {
529        None
530    };
531    Ok(ThreadsPage {
532        items,
533        next_cursor: next,
534        num_scanned_files: scanned_files,
535        reached_scan_cap,
536    })
537}
538
539/// Walk the rollout directory tree to collect files by updated_at, then sort by
540/// file mtime (updated_at) and apply pagination/filtering in that order.
541///
542/// Because updated_at is not encoded in filenames, this path must scan all
543/// files up to the scan cap, then sort and filter by the anchor cursor.
544///
545/// NOTE: This can be optimized in the future if we store additional state on disk
546/// to cache updated_at timestamps.
547async fn traverse_directories_for_paths_updated(
548    root: PathBuf,
549    page_size: usize,
550    anchor: Option<Cursor>,
551    allowed_sources: &[SessionSource],
552    provider_matcher: Option<&ProviderMatcher<'_>>,
553    cwd_filters: Option<&[PathBuf]>,
554) -> io::Result<ThreadsPage> {
555    let mut items: Vec<ThreadItem> = Vec::with_capacity(page_size);
556    let mut scanned_files = 0usize;
557    let mut anchor_state = AnchorState::new(anchor);
558    let mut more_matches_available = false;
559
560    let mut candidates = collect_files_by_updated_at(&root, &mut scanned_files).await?;
561    candidates.sort_by_key(|candidate| {
562        let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH);
563        (Reverse(ts), Reverse(candidate.id))
564    });
565
566    for candidate in candidates.into_iter() {
567        let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH);
568        if anchor_state.should_skip(ts, candidate.id) {
569            continue;
570        }
571        if items.len() == page_size {
572            more_matches_available = true;
573            break;
574        }
575
576        let updated_at_fallback = candidate.updated_at.and_then(format_rfc3339);
577        if let Some(item) = build_thread_item(
578            candidate.path,
579            allowed_sources,
580            provider_matcher,
581            cwd_filters,
582            updated_at_fallback,
583        )
584        .await
585        {
586            items.push(item);
587        }
588    }
589
590    let reached_scan_cap = scanned_files >= MAX_SCAN_FILES;
591    if reached_scan_cap && !items.is_empty() {
592        more_matches_available = true;
593    }
594
595    let next = if more_matches_available {
596        build_next_cursor(&items, ThreadSortKey::UpdatedAt)
597    } else {
598        None
599    };
600    Ok(ThreadsPage {
601        items,
602        next_cursor: next,
603        num_scanned_files: scanned_files,
604        reached_scan_cap,
605    })
606}
607
608async fn traverse_flat_paths_created(
609    root: PathBuf,
610    page_size: usize,
611    anchor: Option<Cursor>,
612    allowed_sources: &[SessionSource],
613    provider_matcher: Option<&ProviderMatcher<'_>>,
614    cwd_filters: Option<&[PathBuf]>,
615) -> io::Result<ThreadsPage> {
616    let mut items: Vec<ThreadItem> = Vec::with_capacity(page_size);
617    let mut scanned_files = 0usize;
618    let mut anchor_state = AnchorState::new(anchor);
619    let mut more_matches_available = false;
620
621    let files = collect_flat_rollout_files(&root, &mut scanned_files).await?;
622    for (ts, id, path) in files.into_iter() {
623        if anchor_state.should_skip(ts, id) {
624            continue;
625        }
626        if items.len() == page_size {
627            more_matches_available = true;
628            break;
629        }
630        let updated_at = file_modified_time(&path)
631            .await
632            .unwrap_or(None)
633            .and_then(format_rfc3339);
634        if let Some(item) = build_thread_item(
635            path,
636            allowed_sources,
637            provider_matcher,
638            cwd_filters,
639            updated_at,
640        )
641        .await
642        {
643            items.push(item);
644        }
645    }
646
647    let reached_scan_cap = scanned_files >= MAX_SCAN_FILES;
648    if reached_scan_cap && !items.is_empty() {
649        more_matches_available = true;
650    }
651
652    let next = if more_matches_available {
653        build_next_cursor(&items, ThreadSortKey::CreatedAt)
654    } else {
655        None
656    };
657    Ok(ThreadsPage {
658        items,
659        next_cursor: next,
660        num_scanned_files: scanned_files,
661        reached_scan_cap,
662    })
663}
664
665async fn traverse_flat_paths_updated(
666    root: PathBuf,
667    page_size: usize,
668    anchor: Option<Cursor>,
669    allowed_sources: &[SessionSource],
670    provider_matcher: Option<&ProviderMatcher<'_>>,
671    cwd_filters: Option<&[PathBuf]>,
672) -> io::Result<ThreadsPage> {
673    let mut items: Vec<ThreadItem> = Vec::with_capacity(page_size);
674    let mut scanned_files = 0usize;
675    let mut anchor_state = AnchorState::new(anchor);
676    let mut more_matches_available = false;
677
678    let mut candidates = collect_flat_files_by_updated_at(&root, &mut scanned_files).await?;
679    candidates.sort_by_key(|candidate| {
680        let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH);
681        (Reverse(ts), Reverse(candidate.id))
682    });
683
684    for candidate in candidates.into_iter() {
685        let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH);
686        if anchor_state.should_skip(ts, candidate.id) {
687            continue;
688        }
689        if items.len() == page_size {
690            more_matches_available = true;
691            break;
692        }
693
694        let updated_at_fallback = candidate.updated_at.and_then(format_rfc3339);
695        if let Some(item) = build_thread_item(
696            candidate.path,
697            allowed_sources,
698            provider_matcher,
699            cwd_filters,
700            updated_at_fallback,
701        )
702        .await
703        {
704            items.push(item);
705        }
706    }
707
708    let reached_scan_cap = scanned_files >= MAX_SCAN_FILES;
709    if reached_scan_cap && !items.is_empty() {
710        more_matches_available = true;
711    }
712
713    let next = if more_matches_available {
714        build_next_cursor(&items, ThreadSortKey::UpdatedAt)
715    } else {
716        None
717    };
718    Ok(ThreadsPage {
719        items,
720        next_cursor: next,
721        num_scanned_files: scanned_files,
722        reached_scan_cap,
723    })
724}
725
726/// Pagination cursor token format: an RFC3339 timestamp with an optional thread ID tie-breaker.
727pub fn parse_cursor(token: &str) -> Option<Cursor> {
728    let (timestamp, id) = match token.rsplit_once('|') {
729        Some((timestamp, id)) => (timestamp, Some(ThreadId::from_string(id).ok()?)),
730        None => (token, None),
731    };
732
733    let ts = OffsetDateTime::parse(timestamp, &Rfc3339)
734        .ok()
735        .or_else(|| {
736            let format: &[FormatItem] =
737                format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
738            PrimitiveDateTime::parse(timestamp, format)
739                .ok()
740                .map(PrimitiveDateTime::assume_utc)
741        })?;
742
743    Some(Cursor { ts, id })
744}
745
746fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option<Cursor> {
747    let last = items.last()?;
748    let file_name = last.path.file_name()?.to_string_lossy();
749    let (created_ts, id) = parse_timestamp_uuid_from_filename(&file_name)?;
750    let ts = match sort_key {
751        ThreadSortKey::CreatedAt => created_ts,
752        ThreadSortKey::UpdatedAt => {
753            let updated_at = last.updated_at.as_deref()?;
754            OffsetDateTime::parse(updated_at, &Rfc3339).ok()?
755        }
756        ThreadSortKey::RecencyAt => {
757            let recency_at = last.recency_at.as_deref().or(last.updated_at.as_deref())?;
758            OffsetDateTime::parse(recency_at, &Rfc3339).ok()?
759        }
760    };
761    match sort_key {
762        ThreadSortKey::RecencyAt => Some(Cursor::with_thread_id(
763            ts,
764            ThreadId::from_string(&id.to_string()).ok()?,
765        )),
766        ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => Some(Cursor::new(ts)),
767    }
768}
769
770async fn build_thread_item(
771    path: PathBuf,
772    allowed_sources: &[SessionSource],
773    provider_matcher: Option<&ProviderMatcher<'_>>,
774    cwd_filters: Option<&[PathBuf]>,
775    updated_at: Option<String>,
776) -> Option<ThreadItem> {
777    // Read head and detect preview-bearing events; goal previews can appear before
778    // the first normal user message.
779    let summary = read_head_summary(&path, HEAD_RECORD_LIMIT)
780        .await
781        .unwrap_or_default();
782    if !allowed_sources.is_empty()
783        && !summary
784            .source
785            .as_ref()
786            .is_some_and(|source| allowed_sources.contains(source))
787    {
788        return None;
789    }
790    if let Some(matcher) = provider_matcher
791        && !matcher.matches(summary.model_provider.as_deref())
792    {
793        return None;
794    }
795    if let Some(cwd_filters) = cwd_filters
796        && !summary.cwd.as_ref().is_some_and(|cwd| {
797            cwd_filters
798                .iter()
799                .any(|filter| path_utils::paths_match_after_normalization(cwd, filter))
800        })
801    {
802        return None;
803    }
804    // Apply filters: must have session meta and a discoverable preview.
805    if summary.saw_session_meta && summary.preview.is_some() {
806        let HeadTailSummary {
807            thread_id,
808            first_user_message,
809            preview,
810            cwd,
811            git_branch,
812            git_sha,
813            git_origin_url,
814            source,
815            history_mode,
816            parent_thread_id,
817            agent_nickname,
818            agent_role,
819            model_provider,
820            cli_version,
821            created_at,
822            updated_at: mut summary_updated_at,
823            ..
824        } = summary;
825        if summary_updated_at.is_none() {
826            summary_updated_at = updated_at.or_else(|| created_at.clone());
827        }
828        return Some(ThreadItem {
829            path,
830            thread_id,
831            first_user_message,
832            preview,
833            cwd,
834            git_branch,
835            git_sha,
836            git_origin_url,
837            source,
838            history_mode,
839            parent_thread_id,
840            agent_nickname,
841            agent_role,
842            model_provider,
843            cli_version,
844            created_at,
845            recency_at: summary_updated_at.clone(),
846            updated_at: summary_updated_at,
847        });
848    }
849    None
850}
851
852/// Read a single rollout file into the same summary item shape used by thread listing.
853///
854/// This is for callers that already resolved a rollout path and need the same
855/// metadata/preview extraction as list operations without scanning the whole
856/// sessions tree.
857pub async fn read_thread_item_from_rollout(path: PathBuf) -> Option<ThreadItem> {
858    build_thread_item(
859        path,
860        &[],
861        /*provider_matcher*/ None,
862        /*cwd_filters*/ None,
863        /*updated_at*/ None,
864    )
865    .await
866}
867
868/// Collects immediate subdirectories of `parent`, parses their (string) names with `parse`,
869/// and returns them sorted descending by the parsed key.
870async fn collect_dirs_desc<T, F>(parent: &Path, parse: F) -> io::Result<Vec<(T, PathBuf)>>
871where
872    T: Ord + Copy,
873    F: Fn(&str) -> Option<T>,
874{
875    let mut dir = tokio::fs::read_dir(parent).await?;
876    let mut vec: Vec<(T, PathBuf)> = Vec::new();
877    while let Some(entry) = dir.next_entry().await? {
878        if entry
879            .file_type()
880            .await
881            .map(|ft| ft.is_dir())
882            .unwrap_or(false)
883            && let Some(s) = entry.file_name().to_str()
884            && let Some(v) = parse(s)
885        {
886            vec.push((v, entry.path()));
887        }
888    }
889    vec.sort_by_key(|(v, _)| Reverse(*v));
890    Ok(vec)
891}
892
893/// Collects files in a directory and parses them with `parse`.
894async fn collect_files<T, F>(parent: &Path, parse: F) -> io::Result<Vec<T>>
895where
896    F: Fn(&str, &Path) -> Option<T>,
897{
898    let mut dir = tokio::fs::read_dir(parent).await?;
899    let mut collected: Vec<T> = Vec::new();
900    while let Some(entry) = dir.next_entry().await? {
901        if entry
902            .file_type()
903            .await
904            .map(|ft| ft.is_file())
905            .unwrap_or(false)
906            && let Some(s) = entry.file_name().to_str()
907            && let Some(v) = parse(s, &entry.path())
908        {
909            collected.push(v);
910        }
911    }
912    Ok(collected)
913}
914
915async fn collect_flat_rollout_files(
916    root: &Path,
917    scanned_files: &mut usize,
918) -> io::Result<Vec<(OffsetDateTime, Uuid, PathBuf)>> {
919    let mut dir = tokio::fs::read_dir(root).await?;
920    let mut collected = Vec::new();
921    while let Some(entry) = dir.next_entry().await? {
922        if *scanned_files >= MAX_SCAN_FILES {
923            break;
924        }
925        if !entry
926            .file_type()
927            .await
928            .map(|ft| ft.is_file())
929            .unwrap_or(false)
930        {
931            continue;
932        }
933        let Some(rollout_file) = compression::RolloutFile::from_path(entry.path()) else {
934            continue;
935        };
936        let Some((ts, id)) = parse_timestamp_uuid_from_filename(rollout_file.plain_file_name())
937        else {
938            continue;
939        };
940        *scanned_files += 1;
941        if *scanned_files > MAX_SCAN_FILES {
942            break;
943        }
944        collected.push((ts, id, rollout_file.into_path()));
945    }
946    collected.sort_by_key(|(ts, sid, _path)| (Reverse(*ts), Reverse(*sid)));
947    Ok(collected)
948}
949
950async fn collect_rollout_day_files(
951    day_path: &Path,
952) -> io::Result<Vec<(OffsetDateTime, Uuid, PathBuf)>> {
953    let mut day_files = collect_files(day_path, |_name_str, path| {
954        let rollout_file = compression::RolloutFile::from_path(path.to_path_buf())?;
955        parse_timestamp_uuid_from_filename(rollout_file.plain_file_name())
956            .map(|(ts, id)| (ts, id, rollout_file.into_path()))
957    })
958    .await?;
959    // Stable ordering within the same second: (timestamp desc, uuid desc)
960    day_files.sort_by_key(|(ts, sid, _path)| (Reverse(*ts), Reverse(*sid)));
961    Ok(day_files)
962}
963
964pub(crate) fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uuid)> {
965    // Expected: rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl[.zst]
966    let name = compression::parse_rollout_file_name(name)?;
967    let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
968
969    // Scan from the right for a '-' such that the suffix parses as a UUID.
970    let (sep_idx, uuid) = core
971        .match_indices('-')
972        .rev()
973        .find_map(|(i, _)| Uuid::parse_str(&core[i + 1..]).ok().map(|u| (i, u)))?;
974
975    let ts_str = &core[..sep_idx];
976    let format: &[FormatItem] =
977        format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
978    let ts = PrimitiveDateTime::parse(ts_str, format).ok()?.assume_utc();
979    Some((ts, uuid))
980}
981
982struct ThreadCandidate {
983    path: PathBuf,
984    id: Uuid,
985    updated_at: Option<OffsetDateTime>,
986}
987
988async fn collect_files_by_updated_at(
989    root: &Path,
990    scanned_files: &mut usize,
991) -> io::Result<Vec<ThreadCandidate>> {
992    let mut candidates = Vec::new();
993    let mut visitor = FilesByUpdatedAtVisitor {
994        candidates: &mut candidates,
995    };
996    walk_rollout_files(root, scanned_files, &mut visitor).await?;
997
998    Ok(candidates)
999}
1000
1001async fn collect_flat_files_by_updated_at(
1002    root: &Path,
1003    scanned_files: &mut usize,
1004) -> io::Result<Vec<ThreadCandidate>> {
1005    let mut candidates = Vec::new();
1006    let mut dir = tokio::fs::read_dir(root).await?;
1007    while let Some(entry) = dir.next_entry().await? {
1008        if *scanned_files >= MAX_SCAN_FILES {
1009            break;
1010        }
1011        if !entry
1012            .file_type()
1013            .await
1014            .map(|ft| ft.is_file())
1015            .unwrap_or(false)
1016        {
1017            continue;
1018        }
1019        let Some(rollout_file) = compression::RolloutFile::from_path(entry.path()) else {
1020            continue;
1021        };
1022        let Some((_ts, id)) = parse_timestamp_uuid_from_filename(rollout_file.plain_file_name())
1023        else {
1024            continue;
1025        };
1026        *scanned_files += 1;
1027        if *scanned_files > MAX_SCAN_FILES {
1028            break;
1029        }
1030        let updated_at = file_modified_time(rollout_file.path())
1031            .await
1032            .unwrap_or(None);
1033        candidates.push(ThreadCandidate {
1034            path: rollout_file.into_path(),
1035            id,
1036            updated_at,
1037        });
1038    }
1039
1040    Ok(candidates)
1041}
1042
1043async fn walk_rollout_files(
1044    root: &Path,
1045    scanned_files: &mut usize,
1046    visitor: &mut impl RolloutFileVisitor,
1047) -> io::Result<()> {
1048    let year_dirs = collect_dirs_desc(root, |s| s.parse::<u16>().ok()).await?;
1049
1050    'outer: for (_year, year_path) in year_dirs.iter() {
1051        if *scanned_files >= MAX_SCAN_FILES {
1052            break;
1053        }
1054        let month_dirs = collect_dirs_desc(year_path, |s| s.parse::<u8>().ok()).await?;
1055        for (_month, month_path) in month_dirs.iter() {
1056            if *scanned_files >= MAX_SCAN_FILES {
1057                break 'outer;
1058            }
1059            let day_dirs = collect_dirs_desc(month_path, |s| s.parse::<u8>().ok()).await?;
1060            for (_day, day_path) in day_dirs.iter() {
1061                if *scanned_files >= MAX_SCAN_FILES {
1062                    break 'outer;
1063                }
1064                let day_files = collect_rollout_day_files(day_path).await?;
1065                for (ts, id, path) in day_files.into_iter() {
1066                    *scanned_files += 1;
1067                    if *scanned_files > MAX_SCAN_FILES {
1068                        break 'outer;
1069                    }
1070                    if let ControlFlow::Break(()) =
1071                        visitor.visit(ts, id, path, *scanned_files).await
1072                    {
1073                        break 'outer;
1074                    }
1075                }
1076            }
1077        }
1078    }
1079
1080    Ok(())
1081}
1082
1083struct ProviderMatcher<'a> {
1084    filters: &'a [String],
1085    matches_default_provider: bool,
1086}
1087
1088impl<'a> ProviderMatcher<'a> {
1089    fn new(filters: &'a [String], default_provider: &'a str) -> Option<Self> {
1090        if filters.is_empty() {
1091            return None;
1092        }
1093
1094        let matches_default_provider = filters.iter().any(|provider| provider == default_provider);
1095        Some(Self {
1096            filters,
1097            matches_default_provider,
1098        })
1099    }
1100
1101    fn matches(&self, session_provider: Option<&str>) -> bool {
1102        match session_provider {
1103            Some(provider) => self.filters.iter().any(|candidate| candidate == provider),
1104            None => self.matches_default_provider,
1105        }
1106    }
1107}
1108
1109async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTailSummary> {
1110    let mut lines = compression::open_rollout_line_reader(path).await?;
1111    let mut summary = HeadTailSummary::default();
1112    let mut lines_scanned = 0usize;
1113
1114    while lines_scanned < head_limit
1115        || (summary.saw_session_meta
1116            && (summary.preview.is_none() || summary.first_user_message.is_none())
1117            && lines_scanned < head_limit + USER_EVENT_SCAN_LIMIT)
1118    {
1119        let line_opt = lines.next_line().await?;
1120        let Some(line) = line_opt else { break };
1121        let trimmed = line.trim();
1122        if trimmed.is_empty() {
1123            continue;
1124        }
1125        lines_scanned += 1;
1126
1127        let parsed: Result<RolloutLine, _> = serde_json::from_str(trimmed);
1128        let rollout_line = match parsed {
1129            Ok(rollout_line) => rollout_line,
1130            Err(_) => {
1131                if !summary.saw_session_meta
1132                    && let Ok(value) = serde_json::from_str::<Value>(trimmed)
1133                {
1134                    // The first SessionMeta belongs to this rollout. Later SessionMeta lines can
1135                    // be copied from fork history, so only an unknown mode before the first parsed
1136                    // SessionMeta should make this thread unreadable.
1137                    crate::recorder::reject_unknown_thread_history_mode(&value)?;
1138                }
1139                continue;
1140            }
1141        };
1142
1143        match rollout_line.item {
1144            RolloutItem::SessionMeta(session_meta_line) => {
1145                if !summary.saw_session_meta {
1146                    summary.source = Some(session_meta_line.meta.source.clone());
1147                    summary.history_mode = session_meta_line.meta.history_mode;
1148                    summary.parent_thread_id = session_meta_line.meta.parent_thread_id;
1149                    summary.agent_nickname = session_meta_line.meta.agent_nickname.clone();
1150                    summary.agent_role = session_meta_line.meta.agent_role.clone();
1151                    summary.model_provider = session_meta_line.meta.model_provider.clone();
1152                    summary.thread_id = Some(session_meta_line.meta.id);
1153                    summary.cwd = Some(session_meta_line.meta.cwd.clone());
1154                    summary.git_branch = session_meta_line
1155                        .git
1156                        .as_ref()
1157                        .and_then(|git| git.branch.clone());
1158                    summary.git_sha = session_meta_line
1159                        .git
1160                        .as_ref()
1161                        .and_then(|git| git.commit_hash.as_ref().map(|sha| sha.0.clone()));
1162                    summary.git_origin_url = session_meta_line
1163                        .git
1164                        .as_ref()
1165                        .and_then(|git| git.repository_url.clone());
1166                    summary.cli_version = Some(session_meta_line.meta.cli_version);
1167                    summary.created_at = Some(session_meta_line.meta.timestamp.clone());
1168                    summary.saw_session_meta = true;
1169                }
1170            }
1171            RolloutItem::ResponseItem(_) | RolloutItem::InterAgentCommunication(_) => {
1172                summary
1173                    .created_at
1174                    .get_or_insert_with(|| rollout_line.timestamp.clone());
1175            }
1176            RolloutItem::InterAgentCommunicationMetadata { .. } => {}
1177            RolloutItem::TurnContext(_) => {
1178                // Not included in `head`; skip.
1179            }
1180            RolloutItem::WorldState(_) => {
1181                // Not included in `head`; skip.
1182            }
1183            RolloutItem::Compacted(_) => {
1184                // Not included in `head`; skip.
1185            }
1186            RolloutItem::EventMsg(ev) => {
1187                if let Some(preview) = event_msg_preview(&ev) {
1188                    // Legacy rollouts persist UserMessage while paginated rollouts persist
1189                    // ItemCompleted(UserMessage), so summaries must recognize both formats.
1190                    let is_user_message = match &ev {
1191                        EventMsg::UserMessage(_) => true,
1192                        EventMsg::ItemCompleted(event) => {
1193                            matches!(event.item, TurnItem::UserMessage(_))
1194                        }
1195                        _ => false,
1196                    };
1197                    if summary.preview.is_none() {
1198                        summary.preview = Some(preview.clone());
1199                    }
1200                    if is_user_message && summary.first_user_message.is_none() {
1201                        summary.first_user_message = Some(preview);
1202                    }
1203                }
1204            }
1205        }
1206
1207        if summary.saw_session_meta
1208            && summary.preview.is_some()
1209            && summary.first_user_message.is_some()
1210        {
1211            break;
1212        }
1213    }
1214
1215    Ok(summary)
1216}
1217
1218/// Read up to `HEAD_RECORD_LIMIT` records from the start of the rollout file at `path`.
1219/// This should be enough to produce a summary including the session meta line.
1220pub async fn read_head_for_summary(path: &Path) -> io::Result<Vec<serde_json::Value>> {
1221    let mut lines = compression::open_rollout_line_reader(path).await?;
1222    let mut head = Vec::new();
1223
1224    while head.len() < HEAD_RECORD_LIMIT {
1225        let Some(line) = lines.next_line().await? else {
1226            break;
1227        };
1228        let trimmed = line.trim();
1229        if trimmed.is_empty() {
1230            continue;
1231        }
1232        if let Ok(rollout_line) = serde_json::from_str::<RolloutLine>(trimmed) {
1233            match rollout_line.item {
1234                RolloutItem::SessionMeta(session_meta_line) => {
1235                    if let Ok(value) = serde_json::to_value(session_meta_line) {
1236                        head.push(value);
1237                    }
1238                }
1239                RolloutItem::ResponseItem(item) => {
1240                    if let Ok(value) = serde_json::to_value(item) {
1241                        head.push(value);
1242                    }
1243                }
1244                RolloutItem::InterAgentCommunication(communication) => {
1245                    if let Ok(value) = serde_json::to_value(communication.to_model_input_item()) {
1246                        head.push(value);
1247                    }
1248                }
1249                RolloutItem::InterAgentCommunicationMetadata { .. }
1250                | RolloutItem::Compacted(_)
1251                | RolloutItem::TurnContext(_)
1252                | RolloutItem::WorldState(_)
1253                | RolloutItem::EventMsg(_) => {}
1254            }
1255        }
1256    }
1257
1258    Ok(head)
1259}
1260
1261fn event_msg_preview(event: &EventMsg) -> Option<String> {
1262    match event {
1263        EventMsg::UserMessage(user) => user_message_preview(user),
1264        EventMsg::ItemCompleted(event) => match &event.item {
1265            TurnItem::UserMessage(user) => {
1266                user_message_preview(&user.as_legacy_user_message_event())
1267            }
1268            _ => None,
1269        },
1270        EventMsg::ThreadGoalUpdated(event) => {
1271            let objective = event.goal.objective.trim();
1272            (!objective.is_empty()).then(|| objective.to_string())
1273        }
1274        _ => None,
1275    }
1276}
1277
1278/// Read the SessionMetaLine from the head of a rollout file for reuse by
1279/// callers that need the session metadata (e.g. to derive a cwd for config).
1280pub async fn read_session_meta_line(path: &Path) -> io::Result<SessionMetaLine> {
1281    let mut lines = compression::open_rollout_line_reader(path).await?;
1282    while let Some(line) = lines.next_line().await? {
1283        let trimmed = line.trim();
1284        if trimmed.is_empty() {
1285            continue;
1286        }
1287        let Ok(rollout_line) = serde_json::from_str::<RolloutLine>(trimmed) else {
1288            if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
1289                crate::recorder::reject_unknown_thread_history_mode(&value)?;
1290            }
1291            continue;
1292        };
1293        match rollout_line.item {
1294            RolloutItem::SessionMeta(session_meta_line) => return Ok(session_meta_line),
1295            RolloutItem::ResponseItem(_) | RolloutItem::InterAgentCommunication(_) => {
1296                return Err(io::Error::other(format!(
1297                    "rollout at {} does not start with session metadata",
1298                    path.display()
1299                )));
1300            }
1301            RolloutItem::InterAgentCommunicationMetadata { .. }
1302            | RolloutItem::Compacted(_)
1303            | RolloutItem::TurnContext(_)
1304            | RolloutItem::WorldState(_)
1305            | RolloutItem::EventMsg(_) => {}
1306        }
1307    }
1308    Err(io::Error::other(format!(
1309        "rollout at {} is empty",
1310        path.display()
1311    )))
1312}
1313
1314async fn file_modified_time(path: &Path) -> io::Result<Option<OffsetDateTime>> {
1315    Ok(compression::file_modified_time(path)
1316        .await?
1317        .and_then(truncate_to_millis))
1318}
1319
1320fn format_rfc3339(dt: OffsetDateTime) -> Option<String> {
1321    dt.format(&Rfc3339).ok()
1322}
1323
1324fn truncate_to_millis(dt: OffsetDateTime) -> Option<OffsetDateTime> {
1325    let millis_nanos = (dt.nanosecond() / 1_000_000) * 1_000_000;
1326    dt.replace_nanosecond(millis_nanos).ok()
1327}
1328
1329async fn find_thread_path_by_id_str_in_subdir(
1330    codex_home: &Path,
1331    subdir: &str,
1332    id_str: &str,
1333    state_db_ctx: Option<&codex_state::StateRuntime>,
1334) -> io::Result<Option<PathBuf>> {
1335    // Validate UUID format early.
1336    if Uuid::parse_str(id_str).is_err() {
1337        return Ok(None);
1338    }
1339
1340    // Prefer DB lookup, then fall back to rollout file search.
1341    // TODO(jif): sqlite migration phase 1
1342    let archived_only = match subdir {
1343        SESSIONS_SUBDIR => Some(false),
1344        ARCHIVED_SESSIONS_SUBDIR => Some(true),
1345        _ => None,
1346    };
1347    let thread_id = ThreadId::from_string(id_str).ok();
1348    let mut unverified_db_path = None;
1349    let mut fallback_reason = state_db_ctx.is_none().then_some("db_unavailable");
1350    if let Some(state_db_ctx) = state_db_ctx
1351        && let Some(thread_id) = thread_id
1352    {
1353        match state_db_ctx
1354            .find_rollout_path_by_id(thread_id, archived_only)
1355            .await
1356        {
1357            Ok(Some(db_path)) => {
1358                if let Some(existing_db_path) =
1359                    compression::existing_rollout_path(db_path.as_path()).await
1360                {
1361                    match read_session_meta_line(&existing_db_path).await {
1362                        Ok(meta_line) if meta_line.meta.id == thread_id => {
1363                            return Ok(Some(existing_db_path));
1364                        }
1365                        Ok(meta_line) => {
1366                            tracing::error!(
1367                                "state db returned rollout path for thread {id_str} but file belongs to thread {}: {}",
1368                                meta_line.meta.id,
1369                                existing_db_path.display()
1370                            );
1371                            tracing::warn!(
1372                                "state db discrepancy during find_thread_path_by_id_str_in_subdir: mismatched_db_path"
1373                            );
1374                            codex_state::record_fallback(
1375                                "find_thread_path",
1376                                "mismatch",
1377                                /*telemetry_override*/ None,
1378                            );
1379                        }
1380                        Err(err) => {
1381                            tracing::debug!(
1382                                "state db returned rollout path for thread {id_str} that could not be verified: {}: {err}",
1383                                existing_db_path.display()
1384                            );
1385                            unverified_db_path = Some(existing_db_path);
1386                        }
1387                    }
1388                } else {
1389                    tracing::error!(
1390                        "state db returned stale rollout path for thread {id_str}: {}",
1391                        db_path.display()
1392                    );
1393                    tracing::warn!(
1394                        "state db discrepancy during find_thread_path_by_id_str_in_subdir: stale_db_path"
1395                    );
1396                    codex_state::record_fallback(
1397                        "find_thread_path",
1398                        "stale_path",
1399                        /*telemetry_override*/ None,
1400                    );
1401                }
1402            }
1403            Ok(None) => fallback_reason = Some("missing_row"),
1404            Err(err) => {
1405                tracing::warn!(
1406                    "state db find_rollout_path_by_id failed during find_path_query: {err}"
1407                );
1408                fallback_reason = Some("db_error");
1409            }
1410        }
1411    }
1412
1413    let mut root = codex_home.to_path_buf();
1414    root.push(subdir);
1415    if !root.exists() {
1416        return Ok(unverified_db_path);
1417    }
1418    let (filename_match, filename_scan_error) = match find_rollout_path_by_id_from_filenames(
1419        root.as_path(),
1420        id_str,
1421    )
1422    .await
1423    {
1424        Ok(path) => (path, None),
1425        Err(err) => {
1426            tracing::warn!(
1427                "rollout filename lookup failed during find_thread_path_by_id_str_in_subdir: {err}"
1428            );
1429            (None, Some(err))
1430        }
1431    };
1432
1433    let found = match filename_match {
1434        Some(path) => Some(path),
1435        None => {
1436            // This is safe because we know the values are valid.
1437            #[allow(clippy::unwrap_used)]
1438            let limit = NonZero::new(1).unwrap();
1439            let options = file_search::FileSearchOptions {
1440                limit,
1441                compute_indices: false,
1442                respect_gitignore: false,
1443                ..Default::default()
1444            };
1445
1446            let results = file_search::run(
1447                id_str,
1448                vec![root.clone()],
1449                options,
1450                /*cancel_flag*/ None,
1451            )
1452            .map_err(|e| io::Error::other(format!("file search failed: {e}")))?;
1453
1454            let found = results
1455                .matches
1456                .into_iter()
1457                .map(|m| m.full_path())
1458                .find_map(compression::RolloutFile::from_path)
1459                .map(compression::RolloutFile::into_path);
1460
1461            if found.is_none()
1462                && let Some(err) = filename_scan_error
1463            {
1464                return Err(err);
1465            }
1466            found
1467        }
1468    };
1469    if let Some(found_path) = found.as_ref() {
1470        tracing::debug!("state db missing rollout path for thread {id_str}");
1471        tracing::warn!(
1472            "state db discrepancy during find_thread_path_by_id_str_in_subdir: falling_back"
1473        );
1474        if let Some(reason) = fallback_reason {
1475            codex_state::record_fallback(
1476                "find_thread_path",
1477                reason,
1478                /*telemetry_override*/ None,
1479            );
1480        }
1481        state_db::read_repair_rollout_path(
1482            state_db_ctx,
1483            thread_id,
1484            archived_only,
1485            found_path.as_path(),
1486        )
1487        .await;
1488    }
1489
1490    Ok(found.or(unverified_db_path))
1491}
1492
1493async fn find_rollout_path_by_id_from_filenames(
1494    root: &Path,
1495    id_str: &str,
1496) -> io::Result<Option<PathBuf>> {
1497    let Ok(target) = Uuid::parse_str(id_str) else {
1498        return Ok(None);
1499    };
1500    let mut stack = vec![root.to_path_buf()];
1501    while let Some(dir) = stack.pop() {
1502        let mut read_dir = match tokio::fs::read_dir(dir.as_path()).await {
1503            Ok(read_dir) => read_dir,
1504            Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
1505            Err(err) => return Err(err),
1506        };
1507        while let Some(entry) = read_dir.next_entry().await? {
1508            let path = entry.path();
1509            let file_type = entry.file_type().await?;
1510            if file_type.is_dir() {
1511                stack.push(path);
1512                continue;
1513            }
1514            if !file_type.is_file() {
1515                continue;
1516            }
1517            let Some(rollout_file) = compression::RolloutFile::from_path(path) else {
1518                continue;
1519            };
1520            let Some((_ts, id)) =
1521                parse_timestamp_uuid_from_filename(rollout_file.plain_file_name())
1522            else {
1523                continue;
1524            };
1525            if id == target {
1526                return Ok(Some(rollout_file.into_path()));
1527            }
1528        }
1529    }
1530    Ok(None)
1531}
1532
1533/// Locate a recorded thread rollout file by its UUID string using the existing
1534/// paginated listing implementation. Returns `Ok(Some(path))` if found, `Ok(None)` if not present
1535/// or the id is invalid.
1536pub async fn find_thread_path_by_id_str(
1537    codex_home: &Path,
1538    id_str: &str,
1539    state_db_ctx: Option<&codex_state::StateRuntime>,
1540) -> io::Result<Option<PathBuf>> {
1541    find_thread_path_by_id_str_in_subdir(codex_home, SESSIONS_SUBDIR, id_str, state_db_ctx).await
1542}
1543
1544/// Locate an archived thread rollout file by its UUID string.
1545pub async fn find_archived_thread_path_by_id_str(
1546    codex_home: &Path,
1547    id_str: &str,
1548    state_db_ctx: Option<&codex_state::StateRuntime>,
1549) -> io::Result<Option<PathBuf>> {
1550    find_thread_path_by_id_str_in_subdir(codex_home, ARCHIVED_SESSIONS_SUBDIR, id_str, state_db_ctx)
1551        .await
1552}
1553
1554/// Extract the `YYYY/MM/DD` directory components from a rollout filename.
1555pub fn rollout_date_parts(file_name: &OsStr) -> Option<(String, String, String)> {
1556    let name = file_name.to_string_lossy();
1557    let date = name.strip_prefix("rollout-")?.get(..10)?;
1558    let year = date.get(..4)?.to_string();
1559    let month = date.get(5..7)?.to_string();
1560    let day = date.get(8..10)?.to_string();
1561    Some((year, month, day))
1562}