Files
codex/codex-rs/app-server/src/request_processors/thread_sections.rs
T
joeytrasatti-openai c42ea41ee0 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
2026-07-31 19:08:45 +00:00

196 lines
7.1 KiB
Rust

use super::thread_processor::THREAD_LIST_DEFAULT_LIMIT;
use super::thread_processor::THREAD_LIST_MAX_LIMIT;
use super::thread_processor::ThreadRequestProcessor;
use crate::error_code::internal_error;
use crate::error_code::invalid_params;
use crate::error_code::method_not_found;
use codex_app_server_protocol::ClientResponsePayload;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ThreadSection;
use codex_app_server_protocol::ThreadSectionCreateParams;
use codex_app_server_protocol::ThreadSectionCreateResponse;
use codex_app_server_protocol::ThreadSectionDeleteParams;
use codex_app_server_protocol::ThreadSectionDeleteResponse;
use codex_app_server_protocol::ThreadSectionListParams;
use codex_app_server_protocol::ThreadSectionListResponse;
use codex_app_server_protocol::ThreadSectionUpdateParams;
use codex_app_server_protocol::ThreadSectionUpdateResponse;
use codex_state::PINNED_THREAD_SECTION_ID;
use codex_thread_store::CreateThreadSectionParams as StoreCreateThreadSectionParams;
use codex_thread_store::DeleteThreadSectionParams as StoreDeleteThreadSectionParams;
use codex_thread_store::ListThreadSectionsParams as StoreListThreadSectionsParams;
use codex_thread_store::RenameThreadSectionParams as StoreRenameThreadSectionParams;
use codex_thread_store::ThreadStoreError;
impl ThreadRequestProcessor {
pub(crate) async fn thread_section_list(
&self,
params: ThreadSectionListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
const OPERATION: &str = "threadSection/list";
self.ensure_thread_sections_supported(OPERATION)?;
let limit = params
.limit
.map(|value| value as usize)
.unwrap_or(THREAD_LIST_DEFAULT_LIMIT)
.clamp(1, THREAD_LIST_MAX_LIMIT);
let page = self
.thread_store
.list_thread_sections(StoreListThreadSectionsParams {
cursor: params.cursor,
limit,
})
.await
.map_err(|err| thread_section_store_error(OPERATION, err))?;
Ok(Some(
ThreadSectionListResponse {
data: page
.sections
.into_iter()
.map(|section| ThreadSection {
id: section.id,
name: section.name,
})
.collect(),
next_cursor: page.next_cursor,
}
.into(),
))
}
pub(crate) async fn thread_section_create(
&self,
params: ThreadSectionCreateParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
const OPERATION: &str = "threadSection/create";
self.ensure_thread_sections_supported(OPERATION)?;
let name = params.name.trim();
if name.is_empty() {
return Err(invalid_params("section name must not be empty"));
}
let section = self
.thread_store
.create_thread_section(StoreCreateThreadSectionParams {
name: name.to_string(),
})
.await
.map_err(|err| thread_section_store_error(OPERATION, err))?;
Ok(Some(
ThreadSectionCreateResponse {
section: ThreadSection {
id: section.id,
name: section.name,
},
}
.into(),
))
}
pub(crate) async fn thread_section_update(
&self,
params: ThreadSectionUpdateParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
const OPERATION: &str = "threadSection/update";
self.ensure_thread_sections_supported(OPERATION)?;
let name = params.name.trim();
if name.is_empty() {
return Err(invalid_params("section name must not be empty"));
}
if params.section_id.trim().is_empty() {
return Err(invalid_params("sectionId must not be empty"));
}
if params.section_id == PINNED_THREAD_SECTION_ID {
return Err(invalid_params(
"the built-in pinned section cannot be renamed",
));
}
let section = self
.thread_store
.rename_thread_section(StoreRenameThreadSectionParams {
section_id: params.section_id.clone(),
name: name.to_string(),
})
.await
.map_err(|err| thread_section_store_error(OPERATION, err))?
.ok_or_else(|| {
invalid_params(format!("thread section not found: {}", params.section_id))
})?;
Ok(Some(
ThreadSectionUpdateResponse {
section: ThreadSection {
id: section.id,
name: section.name,
},
}
.into(),
))
}
pub(crate) async fn thread_section_delete(
&self,
params: ThreadSectionDeleteParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
const OPERATION: &str = "threadSection/delete";
self.ensure_thread_sections_supported(OPERATION)?;
if params.section_id.trim().is_empty() {
return Err(invalid_params("sectionId must not be empty"));
}
if params.section_id == PINNED_THREAD_SECTION_ID {
return Err(invalid_params(
"the built-in pinned section cannot be deleted",
));
}
let deleted = self
.thread_store
.delete_thread_section(StoreDeleteThreadSectionParams {
section_id: params.section_id.clone(),
})
.await
.map_err(|err| thread_section_store_error(OPERATION, err))?;
if !deleted {
return Err(invalid_params(format!(
"thread section not found: {}",
params.section_id
)));
}
Ok(Some(ThreadSectionDeleteResponse {}.into()))
}
fn ensure_thread_sections_supported(
&self,
operation: &'static str,
) -> Result<(), JSONRPCErrorError> {
if self.thread_store.supports_thread_sections() {
Ok(())
} else {
Err(unsupported_thread_section_operation(operation))
}
}
}
fn unsupported_thread_section_operation(operation: &'static str) -> JSONRPCErrorError {
method_not_found(format!("{operation} is unavailable without sqlite state"))
}
fn thread_section_store_error(
operation: &'static str,
error: ThreadStoreError,
) -> JSONRPCErrorError {
match error {
ThreadStoreError::Unsupported { .. } => unsupported_thread_section_operation(operation),
ThreadStoreError::InvalidRequest { message } => invalid_params(message),
error @ (ThreadStoreError::ThreadNotFound { .. }
| ThreadStoreError::Conflict { .. }
| ThreadStoreError::Internal { .. }) => {
let action = operation
.strip_prefix("threadSection/")
.unwrap_or(operation);
internal_error(format!("failed to {action} thread section: {error}"))
}
}
}