Support multiple cwd filters for thread list (#18502)

## Summary

- Teach app-server `thread/list` to accept either a single `cwd` or an
array of cwd filters, returning threads whose recorded session cwd
matches any requested path
- Add `useStateDbOnly` as an explicit opt-in fast path for callers that
want to answer `thread/list` from SQLite without scanning JSONL rollout
files
- Preserve backwards compatibility: by default, `thread/list` still
scans JSONL rollouts and repairs SQLite state
- Wire the new cwd array and SQLite-only options through app-server,
local/remote thread-store, rollout listing, generated TypeScript/schema
fixtures, proto output, and docs

## Test Plan

- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-rollout`
- `cargo test -p codex-thread-store`
- `cargo test -p codex-app-server thread_list`
- `just fmt`
- `just fix -p codex-app-server-protocol -p codex-rollout -p
codex-thread-store -p codex-app-server`
- `cargo build -p codex-cli --bin codex`
This commit is contained in:
acrognale-oai authored and GitHub committed 2026-04-22 06:10:09 -04:00
1 parent b04ffeee4c
commit 4f8c58f737
32 files changed
+1183 -133

No files matched your search

+58 -10
View File
@@ -1,6 +1,7 @@
#![allow(warnings, clippy::all)]
use async_trait::async_trait;
use codex_utils_path as path_utils;
use std::cmp::Reverse;
use std::ffi::OsStr;
use std::io;
@@ -126,6 +127,7 @@ pub enum ThreadListLayout {
pub struct ThreadListConfig<'a> {
pub allowed_sources: &'a [SessionSource],
pub model_providers: Option<&'a [String]>,
pub cwd_filters: Option<&'a [PathBuf]>,
pub default_provider: &'a str,
pub layout: ThreadListLayout,
}
@@ -207,6 +209,7 @@ struct FilesByCreatedAtVisitor<'a> {
more_matches_available: bool,
allowed_sources: &'a [SessionSource],
provider_matcher: Option<&'a ProviderMatcher<'a>>,
cwd_filters: Option<&'a [PathBuf]>,
}
#[async_trait]
@@ -237,6 +240,7 @@ impl<'a> RolloutFileVisitor for FilesByCreatedAtVisitor<'a> {
path,
self.allowed_sources,
self.provider_matcher,
self.cwd_filters,
updated_at,
)
.await
@@ -317,6 +321,7 @@ pub async fn get_threads(
sort_key: ThreadSortKey,
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
cwd_filters: Option<&[PathBuf]>,
default_provider: &str,
) -> io::Result<ThreadsPage> {
let root = codex_home.join(SESSIONS_SUBDIR);
@@ -328,6 +333,7 @@ pub async fn get_threads(
ThreadListConfig {
allowed_sources,
model_providers,
cwd_filters,
default_provider,
layout: ThreadListLayout::NestedByDate,
},
@@ -366,6 +372,7 @@ pub async fn get_threads_in_root(
sort_key,
config.allowed_sources,
provider_matcher.as_ref(),
config.cwd_filters,
)
.await?
}
@@ -377,6 +384,7 @@ pub async fn get_threads_in_root(
sort_key,
config.allowed_sources,
provider_matcher.as_ref(),
config.cwd_filters,
)
.await?
}
@@ -395,6 +403,7 @@ async fn traverse_directories_for_paths(
sort_key: ThreadSortKey,
allowed_sources: &[SessionSource],
provider_matcher: Option<&ProviderMatcher<'_>>,
cwd_filters: Option<&[PathBuf]>,
) -> io::Result<ThreadsPage> {
match sort_key {
ThreadSortKey::CreatedAt => {
@@ -404,6 +413,7 @@ async fn traverse_directories_for_paths(
anchor,
allowed_sources,
provider_matcher,
cwd_filters,
)
.await
}
@@ -414,6 +424,7 @@ async fn traverse_directories_for_paths(
anchor,
allowed_sources,
provider_matcher,
cwd_filters,
)
.await
}
@@ -427,15 +438,30 @@ async fn traverse_flat_paths(
sort_key: ThreadSortKey,
allowed_sources: &[SessionSource],
provider_matcher: Option<&ProviderMatcher<'_>>,
cwd_filters: Option<&[PathBuf]>,
) -> io::Result<ThreadsPage> {
match sort_key {
ThreadSortKey::CreatedAt => {
traverse_flat_paths_created(root, page_size, anchor, allowed_sources, provider_matcher)
.await
traverse_flat_paths_created(
root,
page_size,
anchor,
allowed_sources,
provider_matcher,
cwd_filters,
)
.await
}
ThreadSortKey::UpdatedAt => {
traverse_flat_paths_updated(root, page_size, anchor, allowed_sources, provider_matcher)
.await
traverse_flat_paths_updated(
root,
page_size,
anchor,
allowed_sources,
provider_matcher,
cwd_filters,
)
.await
}
}
}
@@ -452,6 +478,7 @@ async fn traverse_directories_for_paths_created(
anchor: Option<Cursor>,
allowed_sources: &[SessionSource],
provider_matcher: Option<&ProviderMatcher<'_>>,
cwd_filters: Option<&[PathBuf]>,
) -> io::Result<ThreadsPage> {
let mut items: Vec<ThreadItem> = Vec::with_capacity(page_size);
let mut scanned_files = 0usize;
@@ -463,6 +490,7 @@ async fn traverse_directories_for_paths_created(
more_matches_available,
allowed_sources,
provider_matcher,
cwd_filters,
};
walk_rollout_files(&root, &mut scanned_files, &mut visitor).await?;
more_matches_available = visitor.more_matches_available;
@@ -499,14 +527,14 @@ async fn traverse_directories_for_paths_updated(
anchor: Option<Cursor>,
allowed_sources: &[SessionSource],
provider_matcher: Option<&ProviderMatcher<'_>>,
cwd_filters: Option<&[PathBuf]>,
) -> io::Result<ThreadsPage> {
let mut items: Vec<ThreadItem> = Vec::with_capacity(page_size);
let mut scanned_files = 0usize;
let mut anchor_state = AnchorState::new(anchor);
let mut more_matches_available = false;
let candidates = collect_files_by_updated_at(&root, &mut scanned_files).await?;
let mut candidates = candidates;
let mut candidates = collect_files_by_updated_at(&root, &mut scanned_files).await?;
candidates.sort_by_key(|candidate| {
let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH);
(Reverse(ts), Reverse(candidate.id))
@@ -527,6 +555,7 @@ async fn traverse_directories_for_paths_updated(
candidate.path,
allowed_sources,
provider_matcher,
cwd_filters,
updated_at_fallback,
)
.await
@@ -559,6 +588,7 @@ async fn traverse_flat_paths_created(
anchor: Option<Cursor>,
allowed_sources: &[SessionSource],
provider_matcher: Option<&ProviderMatcher<'_>>,
cwd_filters: Option<&[PathBuf]>,
) -> io::Result<ThreadsPage> {
let mut items: Vec<ThreadItem> = Vec::with_capacity(page_size);
let mut scanned_files = 0usize;
@@ -578,8 +608,14 @@ async fn traverse_flat_paths_created(
.await
.unwrap_or(None)
.and_then(format_rfc3339);
if let Some(item) =
build_thread_item(path, allowed_sources, provider_matcher, updated_at).await
if let Some(item) = build_thread_item(
path,
allowed_sources,
provider_matcher,
cwd_filters,
updated_at,
)
.await
{
items.push(item);
}
@@ -609,14 +645,14 @@ async fn traverse_flat_paths_updated(
anchor: Option<Cursor>,
allowed_sources: &[SessionSource],
provider_matcher: Option<&ProviderMatcher<'_>>,
cwd_filters: Option<&[PathBuf]>,
) -> io::Result<ThreadsPage> {
let mut items: Vec<ThreadItem> = Vec::with_capacity(page_size);
let mut scanned_files = 0usize;
let mut anchor_state = AnchorState::new(anchor);
let mut more_matches_available = false;
let candidates = collect_flat_files_by_updated_at(&root, &mut scanned_files).await?;
let mut candidates = candidates;
let mut candidates = collect_flat_files_by_updated_at(&root, &mut scanned_files).await?;
candidates.sort_by_key(|candidate| {
let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH);
(Reverse(ts), Reverse(candidate.id))
@@ -637,6 +673,7 @@ async fn traverse_flat_paths_updated(
candidate.path,
allowed_sources,
provider_matcher,
cwd_filters,
updated_at_fallback,
)
.await
@@ -698,6 +735,7 @@ async fn build_thread_item(
path: PathBuf,
allowed_sources: &[SessionSource],
provider_matcher: Option<&ProviderMatcher<'_>>,
cwd_filters: Option<&[PathBuf]>,
updated_at: Option<String>,
) -> Option<ThreadItem> {
// Read head and detect message events; stop once meta + user are found.
@@ -717,6 +755,15 @@ async fn build_thread_item(
{
return None;
}
if let Some(cwd_filters) = cwd_filters
&& !summary.cwd.as_ref().is_some_and(|cwd| {
cwd_filters
.iter()
.any(|filter| path_utils::paths_match_after_normalization(cwd, filter))
})
{
return None;
}
// Apply filters: must have session meta and at least one user message event
if summary.saw_session_meta && summary.saw_user_event {
let HeadTailSummary {
@@ -768,6 +815,7 @@ pub async fn read_thread_item_from_rollout(path: PathBuf) -> Option<ThreadItem>
path,
&[],
/*provider_matcher*/ None,
/*cwd_filters*/ None,
/*updated_at*/ None,
)
.await