mirror of
https://github.com/openai/codex.git
synced 2026-11-11 21:10:39 +00:00
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:
committed by
copyberry
parent
d62353a312
commit
c42ea41ee0
@@ -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(§ion.id)
|
||||
.bind(§ion.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(§ion.id),
|
||||
runtime.move_thread_to_section(
|
||||
thread_id,
|
||||
Some(§ion.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(())
|
||||
}
|
||||
Reference in New Issue
Block a user