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
@@ -0,0 +1,72 @@
use super::StateRuntime;
use crate::PINNED_THREAD_SECTION_ID;
use crate::ThreadSection;
use uuid::Uuid;
impl StateRuntime {
/// Create a custom thread section with a stable, server-assigned UUIDv7.
pub async fn create_thread_section(&self, name: &str) -> anyhow::Result<ThreadSection> {
let section = ThreadSection {
id: Uuid::now_v7().to_string(),
name: name.to_string(),
};
sqlx::query("INSERT INTO thread_sections (id, name) VALUES (?, ?)")
.bind(&section.id)
.bind(&section.name)
.execute(self.pool.as_ref())
.await?;
Ok(section)
}
/// Rename a custom thread section without changing its stable identity.
pub async fn rename_thread_section(
&self,
id: &str,
name: &str,
) -> anyhow::Result<Option<ThreadSection>> {
if id == PINNED_THREAD_SECTION_ID {
anyhow::bail!("built-in pinned thread section cannot be renamed");
}
let section = sqlx::query_as::<_, (String, String)>(
"UPDATE thread_sections SET name = ? WHERE id = ? RETURNING id, name",
)
.bind(name)
.bind(id)
.fetch_optional(self.pool.as_ref())
.await?;
Ok(section.map(|(id, name)| ThreadSection { id, name }))
}
/// Delete a custom section and return its threads to the unsectioned list.
pub async fn delete_thread_section(&self, id: &str) -> anyhow::Result<bool> {
if id == PINNED_THREAD_SECTION_ID {
anyhow::bail!("built-in pinned thread section cannot be deleted");
}
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
sqlx::query(
"UPDATE threads SET section_position = NULL, section_entered_at_ms = NULL WHERE thread_section_id = ?",
)
.bind(id)
.execute(&mut *tx)
.await?;
let deleted = sqlx::query("DELETE FROM thread_sections WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await?
.rows_affected()
> 0;
tx.commit().await?;
Ok(deleted)
}
}
#[cfg(test)]
#[path = "thread_sections_tests.rs"]
mod tests;
@@ -0,0 +1,115 @@
use super::StateRuntime;
use crate::runtime::test_support::test_thread_metadata;
use crate::runtime::test_support::unique_temp_dir;
use anyhow::Result;
use codex_protocol::ThreadId;
use codex_utils_absolute_path::test_support::PathExt;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn deleting_custom_section_preserves_threads_and_clears_section_ordering() -> Result<()> {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
"test-provider".to_string(),
)
.await?;
let deleted_section = runtime.create_thread_section("Research").await?;
let retained_section = runtime.create_thread_section("Planning").await?;
let first = ThreadId::new();
let second = ThreadId::new();
let retained = ThreadId::new();
for (thread_id, section_id) in [
(first, &deleted_section.id),
(second, &deleted_section.id),
(retained, &retained_section.id),
] {
runtime
.upsert_thread(&test_thread_metadata(
&codex_home,
thread_id,
codex_home.clone(),
))
.await?;
runtime
.move_thread_to_section(thread_id, Some(section_id), /*before_thread_id*/ None)
.await?;
}
assert!(runtime.delete_thread_section(&deleted_section.id).await?);
assert_eq!(runtime.get_thread_section(&deleted_section.id).await?, None);
for thread_id in [first, second] {
let thread = runtime
.get_thread(thread_id)
.await?
.expect("section deletion must preserve its member threads");
assert_eq!(
(
thread.section,
thread.section_position,
thread.section_entered_at
),
(None, None, None)
);
}
let retained_thread = runtime
.get_thread(retained)
.await?
.expect("unrelated section member should remain");
assert_eq!(retained_thread.section, Some(retained_section));
assert!(retained_thread.section_position.is_some());
assert!(retained_thread.section_entered_at.is_some());
Ok(())
}
#[tokio::test]
async fn concurrent_section_deletion_and_membership_moves_preserve_thread_invariants() -> Result<()>
{
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(
crate::SqliteConfig::new_for_testing(codex_home.as_path().abs()),
"test-provider".to_string(),
)
.await?;
let section = runtime.create_thread_section("Research").await?;
let thread_id = ThreadId::new();
runtime
.upsert_thread(&test_thread_metadata(
&codex_home,
thread_id,
codex_home.clone(),
))
.await?;
let (deleted, moved) = tokio::join!(
runtime.delete_thread_section(&section.id),
runtime.move_thread_to_section(
thread_id,
Some(&section.id),
/*before_thread_id*/ None
),
);
assert!(deleted?);
if let Err(error) = moved {
assert!(error.to_string().contains("does not exist"));
}
let thread = runtime
.get_thread(thread_id)
.await?
.expect("concurrent section deletion must preserve the thread");
assert_eq!(
(
thread.section,
thread.section_position,
thread.section_entered_at
),
(None, None, None)
);
Ok(())
}