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#[derive(Debug, Default, PartialEq)]
36pub struct ThreadsPage {
37 pub items: Vec<ThreadItem>,
39 pub next_cursor: Option<Cursor>,
41 pub num_scanned_files: usize,
43 pub reached_scan_cap: bool,
45}
46
47#[derive(Debug, PartialEq, Default)]
49pub struct ThreadItem {
50 pub path: PathBuf,
52 pub thread_id: Option<ThreadId>,
54 pub first_user_message: Option<String>,
56 pub preview: Option<String>,
58 pub cwd: Option<PathBuf>,
60 pub git_branch: Option<String>,
62 pub git_sha: Option<String>,
64 pub git_origin_url: Option<String>,
66 pub source: Option<SessionSource>,
68 pub history_mode: ThreadHistoryMode,
70 pub parent_thread_id: Option<ThreadId>,
72 pub agent_nickname: Option<String>,
74 pub agent_role: Option<String>,
76 pub model_provider: Option<String>,
78 pub cli_version: Option<String>,
80 pub created_at: Option<String>,
83 pub updated_at: Option<String>,
85 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
117const 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#[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
174struct 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
210trait 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
225struct 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
275struct 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
336pub 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
418async 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
492async 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
539async 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
726pub 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 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 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
852pub async fn read_thread_item_from_rollout(path: PathBuf) -> Option<ThreadItem> {
858 build_thread_item(
859 path,
860 &[],
861 None,
862 None,
863 None,
864 )
865 .await
866}
867
868async 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
893async 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 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 let name = compression::parse_rollout_file_name(name)?;
967 let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
968
969 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 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 }
1180 RolloutItem::WorldState(_) => {
1181 }
1183 RolloutItem::Compacted(_) => {
1184 }
1186 RolloutItem::EventMsg(ev) => {
1187 if let Some(preview) = event_msg_preview(&ev) {
1188 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
1218pub 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
1278pub 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 if Uuid::parse_str(id_str).is_err() {
1337 return Ok(None);
1338 }
1339
1340 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 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 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 #[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 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 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
1533pub 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
1544pub 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
1554pub 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}