mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Add rollout-backed thread content search (#23519)
## Summary - add experimental `thread/search` for local rollout-backed thread search using `rg` over JSONL rollouts - return search-specific result rows with optional previews instead of storing preview data on `StoredThread` or ordinary `Thread` responses - keep `thread/list` separate from full-content search and document the new app-server surface ## Testing - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-app-server thread_search_returns_content_and_title_matches -- --nocapture`
This commit is contained in:
committed by
GitHub
Unverified
parent
4acb456bfe
commit
ac0bff27e7
@@ -34,9 +34,11 @@ pub use types::LoadThreadHistoryParams;
|
||||
pub use types::ReadThreadByRolloutPathParams;
|
||||
pub use types::ReadThreadParams;
|
||||
pub use types::ResumeThreadParams;
|
||||
pub use types::SearchThreadsParams;
|
||||
pub use types::SortDirection;
|
||||
pub use types::StoredThread;
|
||||
pub use types::StoredThreadHistory;
|
||||
pub use types::StoredThreadSearchResult;
|
||||
pub use types::StoredTurn;
|
||||
pub use types::StoredTurnError;
|
||||
pub use types::StoredTurnItemsView;
|
||||
@@ -45,6 +47,7 @@ pub use types::ThreadEventPersistenceMode;
|
||||
pub use types::ThreadMetadataPatch;
|
||||
pub use types::ThreadPage;
|
||||
pub use types::ThreadPersistenceMetadata;
|
||||
pub use types::ThreadSearchPage;
|
||||
pub use types::ThreadSortKey;
|
||||
pub use types::TurnPage;
|
||||
pub use types::UpdateThreadMetadataParams;
|
||||
|
||||
@@ -107,7 +107,7 @@ pub(super) async fn list_threads(
|
||||
Ok(ThreadPage { items, next_cursor })
|
||||
}
|
||||
|
||||
async fn list_rollout_threads(
|
||||
pub(super) async fn list_rollout_threads(
|
||||
state_db: Option<codex_rollout::StateDbHandle>,
|
||||
config: &RolloutConfig,
|
||||
default_model_provider_id: &str,
|
||||
|
||||
@@ -4,6 +4,7 @@ mod helpers;
|
||||
mod list_threads;
|
||||
mod live_writer;
|
||||
mod read_thread;
|
||||
mod search_threads;
|
||||
mod unarchive_thread;
|
||||
mod update_thread_metadata;
|
||||
|
||||
@@ -28,9 +29,11 @@ use crate::LoadThreadHistoryParams;
|
||||
use crate::ReadThreadByRolloutPathParams;
|
||||
use crate::ReadThreadParams;
|
||||
use crate::ResumeThreadParams;
|
||||
use crate::SearchThreadsParams;
|
||||
use crate::StoredThread;
|
||||
use crate::StoredThreadHistory;
|
||||
use crate::ThreadPage;
|
||||
use crate::ThreadSearchPage;
|
||||
use crate::ThreadStore;
|
||||
use crate::ThreadStoreError;
|
||||
use crate::ThreadStoreResult;
|
||||
@@ -261,6 +264,13 @@ impl ThreadStore for LocalThreadStore {
|
||||
list_threads::list_threads(self, params).await
|
||||
}
|
||||
|
||||
async fn search_threads(
|
||||
&self,
|
||||
params: SearchThreadsParams,
|
||||
) -> ThreadStoreResult<ThreadSearchPage> {
|
||||
search_threads::search_threads(self, params).await
|
||||
}
|
||||
|
||||
async fn update_thread_metadata(
|
||||
&self,
|
||||
params: UpdateThreadMetadataParams,
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use codex_install_context::InstallContext;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_rollout::RolloutConfig;
|
||||
use codex_rollout::find_thread_names_by_ids;
|
||||
use codex_rollout::first_rollout_content_match_snippet;
|
||||
use codex_rollout::parse_cursor;
|
||||
use codex_rollout::search_rollout_paths;
|
||||
|
||||
use super::LocalThreadStore;
|
||||
use super::helpers::distinct_thread_metadata_title;
|
||||
use super::helpers::set_thread_name_from_title;
|
||||
use super::helpers::stored_thread_from_rollout_item;
|
||||
use super::list_threads::list_rollout_threads;
|
||||
use crate::ListThreadsParams;
|
||||
use crate::SearchThreadsParams;
|
||||
use crate::SortDirection;
|
||||
use crate::StoredThreadSearchResult;
|
||||
use crate::ThreadSearchPage;
|
||||
use crate::ThreadSortKey;
|
||||
use crate::ThreadStoreError;
|
||||
use crate::ThreadStoreResult;
|
||||
|
||||
struct ThreadSearchItem {
|
||||
item: codex_rollout::ThreadItem,
|
||||
snippet: String,
|
||||
}
|
||||
|
||||
pub(super) async fn search_threads(
|
||||
store: &LocalThreadStore,
|
||||
params: SearchThreadsParams,
|
||||
) -> ThreadStoreResult<ThreadSearchPage> {
|
||||
let search_term = params.search_term.as_str();
|
||||
if search_term.is_empty() {
|
||||
return Err(ThreadStoreError::InvalidRequest {
|
||||
message: "thread/search requires search_term".to_string(),
|
||||
});
|
||||
}
|
||||
let cursor = params
|
||||
.cursor
|
||||
.as_deref()
|
||||
.map(|cursor| {
|
||||
parse_cursor(cursor).ok_or_else(|| ThreadStoreError::InvalidRequest {
|
||||
message: format!("invalid cursor: {cursor}"),
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let sort_key = match params.sort_key {
|
||||
ThreadSortKey::CreatedAt => codex_rollout::ThreadSortKey::CreatedAt,
|
||||
ThreadSortKey::UpdatedAt => codex_rollout::ThreadSortKey::UpdatedAt,
|
||||
};
|
||||
let sort_direction = match params.sort_direction {
|
||||
SortDirection::Asc => codex_rollout::SortDirection::Asc,
|
||||
SortDirection::Desc => codex_rollout::SortDirection::Desc,
|
||||
};
|
||||
let state_db = store.state_db().await;
|
||||
let rollout_config = RolloutConfig {
|
||||
codex_home: store.config.codex_home.clone(),
|
||||
sqlite_home: store.config.sqlite_home.clone(),
|
||||
cwd: store.config.codex_home.clone(),
|
||||
model_provider_id: store.config.default_model_provider_id.clone(),
|
||||
generate_memories: false,
|
||||
};
|
||||
let rg_command = InstallContext::current().rg_command();
|
||||
let matching_paths = search_rollout_paths(
|
||||
rg_command.as_path(),
|
||||
store.config.codex_home.as_path(),
|
||||
params.archived,
|
||||
search_term,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
message: format!("failed to search rollout contents: {err}"),
|
||||
})?;
|
||||
if matching_paths.is_empty() {
|
||||
return Ok(ThreadSearchPage {
|
||||
items: Vec::new(),
|
||||
next_cursor: None,
|
||||
});
|
||||
}
|
||||
let mut matching_items = Vec::new();
|
||||
let mut page_cursor = cursor;
|
||||
let scan_page_size = params.page_size.saturating_mul(8).clamp(256, 2048);
|
||||
let scan_params = ListThreadsParams {
|
||||
page_size: scan_page_size,
|
||||
cursor: None,
|
||||
sort_key: params.sort_key,
|
||||
sort_direction: params.sort_direction,
|
||||
allowed_sources: params.allowed_sources.clone(),
|
||||
model_providers: None,
|
||||
cwd_filters: None,
|
||||
archived: params.archived,
|
||||
search_term: None,
|
||||
use_state_db_only: state_db.is_some(),
|
||||
};
|
||||
let mut remaining_paths = matching_paths;
|
||||
|
||||
loop {
|
||||
let page = list_rollout_threads(
|
||||
state_db.clone(),
|
||||
&rollout_config,
|
||||
store.config.default_model_provider_id.as_str(),
|
||||
&scan_params,
|
||||
page_cursor.as_ref(),
|
||||
sort_key,
|
||||
sort_direction,
|
||||
)
|
||||
.await?;
|
||||
for item in page.items {
|
||||
if !remaining_paths.remove(item.path.as_path()) {
|
||||
continue;
|
||||
}
|
||||
let Some(snippet) =
|
||||
first_rollout_content_match_snippet(item.path.as_path(), search_term)
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
message: format!("failed to read rollout search match: {err}"),
|
||||
})?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
matching_items.push(ThreadSearchItem { item, snippet });
|
||||
if matching_items.len() > params.page_size {
|
||||
break;
|
||||
}
|
||||
}
|
||||
page_cursor = page.next_cursor;
|
||||
if matching_items.len() > params.page_size
|
||||
|| remaining_paths.is_empty()
|
||||
|| page_cursor.is_none()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let more_matches_available = matching_items.len() > params.page_size;
|
||||
matching_items.truncate(params.page_size);
|
||||
let next_cursor = if more_matches_available {
|
||||
matching_items
|
||||
.last()
|
||||
.and_then(|item| cursor_from_thread_search_item(item, params.sort_key))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.as_ref()
|
||||
.and_then(|cursor| serde_json::to_value(cursor).ok())
|
||||
.and_then(|value| value.as_str().map(str::to_owned));
|
||||
|
||||
let mut items = matching_items
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
stored_thread_from_rollout_item(
|
||||
item.item,
|
||||
params.archived,
|
||||
store.config.default_model_provider_id.as_str(),
|
||||
)
|
||||
.map(|thread| StoredThreadSearchResult {
|
||||
thread,
|
||||
snippet: item.snippet,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
set_thread_search_result_names(store, &mut items).await;
|
||||
|
||||
Ok(ThreadSearchPage { items, next_cursor })
|
||||
}
|
||||
|
||||
fn cursor_from_thread_search_item(
|
||||
item: &ThreadSearchItem,
|
||||
sort_key: ThreadSortKey,
|
||||
) -> Option<codex_rollout::Cursor> {
|
||||
let timestamp = match sort_key {
|
||||
ThreadSortKey::CreatedAt => item.item.created_at.as_deref()?,
|
||||
ThreadSortKey::UpdatedAt => item
|
||||
.item
|
||||
.updated_at
|
||||
.as_deref()
|
||||
.or(item.item.created_at.as_deref())?,
|
||||
};
|
||||
parse_cursor(timestamp)
|
||||
}
|
||||
|
||||
async fn set_thread_search_result_names(
|
||||
store: &LocalThreadStore,
|
||||
items: &mut [StoredThreadSearchResult],
|
||||
) {
|
||||
let thread_ids = items
|
||||
.iter()
|
||||
.map(|item| item.thread.thread_id)
|
||||
.collect::<HashSet<_>>();
|
||||
let mut names = HashMap::<ThreadId, String>::with_capacity(thread_ids.len());
|
||||
if let Some(state_db_ctx) = store.state_db().await {
|
||||
for &thread_id in &thread_ids {
|
||||
let Ok(Some(metadata)) = state_db_ctx.get_thread(thread_id).await else {
|
||||
continue;
|
||||
};
|
||||
if let Some(title) = distinct_thread_metadata_title(&metadata) {
|
||||
names.insert(thread_id, title);
|
||||
}
|
||||
}
|
||||
}
|
||||
if names.len() < thread_ids.len()
|
||||
&& let Ok(legacy_names) =
|
||||
find_thread_names_by_ids(store.config.codex_home.as_path(), &thread_ids).await
|
||||
{
|
||||
for (thread_id, title) in legacy_names {
|
||||
names.entry(thread_id).or_insert(title);
|
||||
}
|
||||
}
|
||||
for item in items {
|
||||
if let Some(title) = names.get(&item.thread.thread_id).cloned() {
|
||||
set_thread_name_from_title(&mut item.thread, title);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,11 @@ use crate::LoadThreadHistoryParams;
|
||||
use crate::ReadThreadByRolloutPathParams;
|
||||
use crate::ReadThreadParams;
|
||||
use crate::ResumeThreadParams;
|
||||
use crate::SearchThreadsParams;
|
||||
use crate::StoredThread;
|
||||
use crate::StoredThreadHistory;
|
||||
use crate::ThreadPage;
|
||||
use crate::ThreadSearchPage;
|
||||
use crate::ThreadStoreError;
|
||||
use crate::ThreadStoreResult;
|
||||
use crate::TurnPage;
|
||||
@@ -76,6 +78,16 @@ pub trait ThreadStore: Any + Send + Sync {
|
||||
/// Lists stored threads matching the supplied filters.
|
||||
async fn list_threads(&self, params: ListThreadsParams) -> ThreadStoreResult<ThreadPage>;
|
||||
|
||||
/// Searches stored threads and returns search-only preview metadata.
|
||||
async fn search_threads(
|
||||
&self,
|
||||
_params: SearchThreadsParams,
|
||||
) -> ThreadStoreResult<ThreadSearchPage> {
|
||||
Err(ThreadStoreError::Unsupported {
|
||||
operation: "thread/search",
|
||||
})
|
||||
}
|
||||
|
||||
/// Lists turns within a stored thread.
|
||||
async fn list_turns(&self, _params: ListTurnsParams) -> ThreadStoreResult<TurnPage> {
|
||||
Err(ThreadStoreError::Unsupported {
|
||||
|
||||
@@ -199,6 +199,25 @@ pub struct ListThreadsParams {
|
||||
pub use_state_db_only: bool,
|
||||
}
|
||||
|
||||
/// Parameters for searching thread content.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SearchThreadsParams {
|
||||
/// Maximum number of threads to return.
|
||||
pub page_size: usize,
|
||||
/// Opaque cursor returned by a previous search call.
|
||||
pub cursor: Option<String>,
|
||||
/// Sort order requested by the caller.
|
||||
pub sort_key: ThreadSortKey,
|
||||
/// Sort direction requested by the caller.
|
||||
pub sort_direction: SortDirection,
|
||||
/// Allowed session sources. Empty means implementation default.
|
||||
pub allowed_sources: Vec<SessionSource>,
|
||||
/// Whether archived threads should be searched instead of active threads.
|
||||
pub archived: bool,
|
||||
/// Visible thread content to search for.
|
||||
pub search_term: String,
|
||||
}
|
||||
|
||||
/// A page of stored thread records.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ThreadPage {
|
||||
@@ -208,6 +227,21 @@ pub struct ThreadPage {
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct StoredThreadSearchResult {
|
||||
pub thread: StoredThread,
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
/// A page of thread-search results.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ThreadSearchPage {
|
||||
/// Search results returned for this page.
|
||||
pub items: Vec<StoredThreadSearchResult>,
|
||||
/// Opaque cursor to continue searching.
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// Requested amount of item detail for stored turns.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum StoredTurnItemsView {
|
||||
|
||||
Reference in New Issue
Block a user