Add thread section management APIs (#36380)

## What changed

- Add `threadSection/create`, `threadSection/update`, and `threadSection/delete` app-server methods, with generated protocol schemas and TypeScript bindings.
- Persist custom sections in SQLite with stable UUIDv7 identities, trim and validate display names, and prevent renaming or deleting the built-in pinned section.
- Delete sections transactionally and return their active and archived threads to the unsectioned list.
- Serialize section mutations with section listing so concurrent requests observe ordered updates.

## Testing

- Cover section persistence across restarts, ordered renames, member cleanup on deletion, invalid requests, unsupported stores, and thread-store/state-runtime behavior.

GitOrigin-RevId: dc9a4be283c21dbc93ab1829eafc4825d8df6575
This commit is contained in:
joeytrasatti-openai
2026-07-31 19:08:45 +00:00
committed by copyberry
parent d62353a312
commit c42ea41ee0
38 changed files with 1862 additions and 62 deletions
+7
View File
@@ -10,6 +10,7 @@ mod live_thread;
mod local;
mod store;
mod thread_metadata_sync;
mod thread_sections;
mod types;
pub use error::ThreadStoreError;
@@ -22,6 +23,12 @@ pub use local::LocalThreadStore;
pub use local::LocalThreadStoreConfig;
pub use store::ThreadStore;
pub use store::ThreadStoreFuture;
pub use thread_sections::CreateThreadSectionParams;
pub use thread_sections::DeleteThreadSectionParams;
pub use thread_sections::ListThreadSectionsParams;
pub use thread_sections::RenameThreadSectionParams;
pub use thread_sections::StoredThreadSection;
pub use thread_sections::StoredThreadSectionsPage;
pub use types::AppendThreadItemsParams;
pub use types::ArchiveThreadParams;
pub use types::ArchiveThreadsParams;
+39
View File
@@ -14,6 +14,7 @@ mod rollout_lineage;
mod search_threads;
mod thread_history;
mod thread_history_materialization;
mod thread_sections;
mod unarchive_thread;
mod update_thread_metadata;
mod writer_lock;
@@ -41,10 +42,13 @@ use crate::AppendThreadItemsParams;
use crate::ArchiveThreadParams;
use crate::ArchiveThreadsParams;
use crate::CreateThreadParams;
use crate::CreateThreadSectionParams;
use crate::DeleteThreadParams;
use crate::DeleteThreadSectionParams;
use crate::DeleteThreadsParams;
use crate::ItemPage;
use crate::ListItemsParams;
use crate::ListThreadSectionsParams;
use crate::ListThreadsParams;
use crate::ListTurnsParams;
use crate::LoadThreadHistoryParams;
@@ -53,12 +57,15 @@ use crate::PrepareForkParams;
use crate::PreparedFork;
use crate::ReadThreadByRolloutPathParams;
use crate::ReadThreadParams;
use crate::RenameThreadSectionParams;
use crate::ResumeThreadParams;
use crate::SearchThreadOccurrencesParams;
use crate::SearchThreadsParams;
use crate::StoredModelContext;
use crate::StoredThread;
use crate::StoredThreadHistory;
use crate::StoredThreadSection;
use crate::StoredThreadSectionsPage;
use crate::ThreadOccurrenceSearchPage;
use crate::ThreadPage;
use crate::ThreadSearchPage;
@@ -458,6 +465,38 @@ impl ThreadStore for LocalThreadStore {
Box::pin(async move { list_threads::list_threads(self, params).await })
}
fn supports_thread_sections(&self) -> bool {
self.state_db.is_some()
}
fn list_thread_sections(
&self,
params: ListThreadSectionsParams,
) -> ThreadStoreFuture<'_, StoredThreadSectionsPage> {
Box::pin(async move { thread_sections::list_thread_sections(self, params).await })
}
fn create_thread_section(
&self,
params: CreateThreadSectionParams,
) -> ThreadStoreFuture<'_, StoredThreadSection> {
Box::pin(async move { thread_sections::create_thread_section(self, params).await })
}
fn rename_thread_section(
&self,
params: RenameThreadSectionParams,
) -> ThreadStoreFuture<'_, Option<StoredThreadSection>> {
Box::pin(async move { thread_sections::rename_thread_section(self, params).await })
}
fn delete_thread_section(
&self,
params: DeleteThreadSectionParams,
) -> ThreadStoreFuture<'_, bool> {
Box::pin(async move { thread_sections::delete_thread_section(self, params).await })
}
fn supports_paginated_history_lists(&self) -> bool {
self.state_db.is_some()
}
@@ -0,0 +1,96 @@
use super::LocalThreadStore;
use crate::CreateThreadSectionParams;
use crate::DeleteThreadSectionParams;
use crate::ListThreadSectionsParams;
use crate::RenameThreadSectionParams;
use crate::StoredThreadSection;
use crate::StoredThreadSectionsPage;
use crate::ThreadStoreError;
use crate::ThreadStoreResult;
use codex_rollout::StateDbHandle;
pub(super) async fn list_thread_sections(
store: &LocalThreadStore,
params: ListThreadSectionsParams,
) -> ThreadStoreResult<StoredThreadSectionsPage> {
let state_db = state_db(store, "threadSection/list")?;
let page = state_db
.list_thread_sections(params.cursor.as_deref(), params.limit)
.await
.map_err(|err| section_error("list", err))?;
Ok(StoredThreadSectionsPage {
sections: page
.sections
.into_iter()
.map(|section| StoredThreadSection {
id: section.id,
name: section.name,
})
.collect(),
next_cursor: page.next_cursor,
})
}
pub(super) async fn create_thread_section(
store: &LocalThreadStore,
params: CreateThreadSectionParams,
) -> ThreadStoreResult<StoredThreadSection> {
let state_db = state_db(store, "threadSection/create")?;
let section = state_db
.create_thread_section(&params.name)
.await
.map_err(|err| section_error("create", err))?;
Ok(StoredThreadSection {
id: section.id,
name: section.name,
})
}
pub(super) async fn rename_thread_section(
store: &LocalThreadStore,
params: RenameThreadSectionParams,
) -> ThreadStoreResult<Option<StoredThreadSection>> {
let state_db = state_db(store, "threadSection/update")?;
let section = state_db
.rename_thread_section(&params.section_id, &params.name)
.await
.map_err(|err| section_error("update", err))?;
Ok(section.map(|section| StoredThreadSection {
id: section.id,
name: section.name,
}))
}
pub(super) async fn delete_thread_section(
store: &LocalThreadStore,
params: DeleteThreadSectionParams,
) -> ThreadStoreResult<bool> {
let state_db = state_db(store, "threadSection/delete")?;
state_db
.delete_thread_section(&params.section_id)
.await
.map_err(|err| section_error("delete", err))
}
fn state_db<'store>(
store: &'store LocalThreadStore,
operation: &'static str,
) -> ThreadStoreResult<&'store StateDbHandle> {
store
.state_db
.as_ref()
.ok_or(ThreadStoreError::Unsupported { operation })
}
fn section_error(operation: &str, error: impl std::fmt::Display) -> ThreadStoreError {
ThreadStoreError::Internal {
message: format!("failed to {operation} thread section: {error}"),
}
}
#[cfg(test)]
#[path = "thread_sections_tests.rs"]
mod tests;
@@ -0,0 +1,76 @@
use super::LocalThreadStore;
use crate::CreateThreadSectionParams;
use crate::DeleteThreadSectionParams;
use crate::ListThreadSectionsParams;
use crate::RenameThreadSectionParams;
use crate::StoredThreadSection;
use crate::ThreadStore;
use crate::ThreadStoreError;
use crate::local::test_support::test_config;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn local_thread_sections_require_sqlite_and_preserve_section_identity() {
let home = tempfile::TempDir::new().expect("temp dir");
let config = test_config(home.path());
let unsupported_store = LocalThreadStore::new(config.clone(), /*state_db*/ None);
assert!(!unsupported_store.supports_thread_sections());
assert!(matches!(
unsupported_store
.create_thread_section(CreateThreadSectionParams {
name: "Research".to_string(),
})
.await,
Err(ThreadStoreError::Unsupported {
operation: "threadSection/create"
})
));
let runtime = codex_state::StateRuntime::init(
config.sqlite.clone(),
config.default_model_provider_id.clone(),
)
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config, Some(runtime));
assert!(store.supports_thread_sections());
let section = store
.create_thread_section(CreateThreadSectionParams {
name: "Research".to_string(),
})
.await
.expect("create section");
let sections = store
.list_thread_sections(ListThreadSectionsParams {
cursor: None,
limit: 10,
})
.await
.expect("list sections");
assert!(sections.sections.contains(&section));
let renamed = store
.rename_thread_section(RenameThreadSectionParams {
section_id: section.id.clone(),
name: "Planning".to_string(),
})
.await
.expect("rename section");
assert_eq!(
renamed,
Some(StoredThreadSection {
id: section.id.clone(),
name: "Planning".to_string(),
})
);
assert!(
store
.delete_thread_section(DeleteThreadSectionParams {
section_id: section.id,
})
.await
.expect("delete section")
);
}
+59
View File
@@ -8,10 +8,13 @@ use crate::AppendThreadItemsParams;
use crate::ArchiveThreadParams;
use crate::ArchiveThreadsParams;
use crate::CreateThreadParams;
use crate::CreateThreadSectionParams;
use crate::DeleteThreadParams;
use crate::DeleteThreadSectionParams;
use crate::DeleteThreadsParams;
use crate::ItemPage;
use crate::ListItemsParams;
use crate::ListThreadSectionsParams;
use crate::ListThreadsParams;
use crate::ListTurnsParams;
use crate::LoadThreadHistoryParams;
@@ -20,12 +23,15 @@ use crate::PrepareForkParams;
use crate::PreparedFork;
use crate::ReadThreadByRolloutPathParams;
use crate::ReadThreadParams;
use crate::RenameThreadSectionParams;
use crate::ResumeThreadParams;
use crate::SearchThreadOccurrencesParams;
use crate::SearchThreadsParams;
use crate::StoredModelContext;
use crate::StoredThread;
use crate::StoredThreadHistory;
use crate::StoredThreadSection;
use crate::StoredThreadSectionsPage;
use crate::ThreadOccurrenceSearchPage;
use crate::ThreadPage;
use crate::ThreadSearchPage;
@@ -123,6 +129,59 @@ pub trait ThreadStore: Any + Send + Sync {
/// Lists stored threads matching the supplied filters.
fn list_threads(&self, params: ListThreadsParams) -> ThreadStoreFuture<'_, ThreadPage>;
/// Whether this store can discover and manage independently persisted thread sections.
fn supports_thread_sections(&self) -> bool {
false
}
/// Lists independently persisted thread sections.
fn list_thread_sections(
&self,
_params: ListThreadSectionsParams,
) -> ThreadStoreFuture<'_, StoredThreadSectionsPage> {
Box::pin(async {
Err(ThreadStoreError::Unsupported {
operation: "threadSection/list",
})
})
}
/// Creates a custom thread section with a stable, server-assigned identity.
fn create_thread_section(
&self,
_params: CreateThreadSectionParams,
) -> ThreadStoreFuture<'_, StoredThreadSection> {
Box::pin(async {
Err(ThreadStoreError::Unsupported {
operation: "threadSection/create",
})
})
}
/// Renames a custom thread section, returning `None` when it does not exist.
fn rename_thread_section(
&self,
_params: RenameThreadSectionParams,
) -> ThreadStoreFuture<'_, Option<StoredThreadSection>> {
Box::pin(async {
Err(ThreadStoreError::Unsupported {
operation: "threadSection/update",
})
})
}
/// Deletes a custom thread section and reports whether it existed.
fn delete_thread_section(
&self,
_params: DeleteThreadSectionParams,
) -> ThreadStoreFuture<'_, bool> {
Box::pin(async {
Err(ThreadStoreError::Unsupported {
operation: "threadSection/delete",
})
})
}
/// Whether paginated threads can hydrate durable history through turn and item lists.
fn supports_paginated_history_lists(&self) -> bool {
false
@@ -0,0 +1,49 @@
/// Parameters for listing independently persisted thread sections.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListThreadSectionsParams {
/// Opaque cursor returned by a previous section listing.
pub cursor: Option<String>,
/// Maximum number of sections to return.
pub limit: usize,
}
/// Parameters for creating a thread section.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateThreadSectionParams {
/// User-facing section name.
pub name: String,
}
/// Parameters for renaming a thread section.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenameThreadSectionParams {
/// Stable identifier of the section to rename.
pub section_id: String,
/// Replacement user-facing section name.
pub name: String,
}
/// Parameters for deleting a thread section.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeleteThreadSectionParams {
/// Stable identifier of the section to delete.
pub section_id: String,
}
/// An independently persisted thread section and its user-facing name.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredThreadSection {
/// Stable, server-owned section identifier.
pub id: String,
/// User-facing section name.
pub name: String,
}
/// A cursor-paginated page of independently persisted thread sections.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredThreadSectionsPage {
/// Sections returned for this page.
pub sections: Vec<StoredThreadSection>,
/// Opaque cursor to continue listing sections.
pub next_cursor: Option<String>,
}