use super::*;
use crate::SortDirection;
use codex_protocol::protocol::SessionSource;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
impl StateRuntime {
pub async fn get_thread(&self, id: ThreadId) -> anyhow::Result<Option<crate::ThreadMetadata>> {
let row = sqlx::query(
r#"
SELECT
threads.id,
threads.rollout_path,
threads.created_at_ms AS created_at,
threads.updated_at_ms AS updated_at,
threads.recency_at_ms AS recency_at,
threads.source,
threads.history_mode,
threads.thread_source,
threads.agent_nickname,
threads.agent_role,
threads.agent_path,
threads.model_provider,
threads.model,
threads.reasoning_effort,
threads.cwd,
threads.cli_version,
threads.title,
threads.name,
threads.preview,
threads.sandbox_policy,
threads.approval_mode,
threads.tokens_used,
threads.first_user_message,
threads.archived_at,
threads.git_sha,
threads.git_branch,
threads.git_origin_url
FROM threads
WHERE threads.id = ?
"#,
)
.bind(id.to_string())
.fetch_optional(self.pool.as_ref())
.await?;
row.map(|row| ThreadRow::try_from_row(&row).and_then(ThreadMetadata::try_from))
.transpose()
}
pub async fn get_thread_memory_mode(&self, id: ThreadId) -> anyhow::Result<Option<String>> {
let row = sqlx::query("SELECT memory_mode FROM threads WHERE id = ?")
.bind(id.to_string())
.fetch_optional(self.pool.as_ref())
.await?;
Ok(row.and_then(|row| row.try_get("memory_mode").ok()))
}
pub async fn set_thread_preview_if_empty(
&self,
thread_id: ThreadId,
preview: &str,
) -> anyhow::Result<bool> {
let preview = preview.trim();
if preview.is_empty() {
return Ok(false);
}
let result = sqlx::query(
r#"
UPDATE threads
SET preview = ?
WHERE id = ? AND preview = ''
"#,
)
.bind(preview)
.bind(thread_id.to_string())
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn upsert_thread_spawn_edge(
&self,
parent_thread_id: ThreadId,
child_thread_id: ThreadId,
status: crate::DirectionalThreadSpawnEdgeStatus,
) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO thread_spawn_edges (
parent_thread_id,
child_thread_id,
status
) VALUES (?, ?, ?)
ON CONFLICT(child_thread_id) DO UPDATE SET
parent_thread_id = excluded.parent_thread_id,
status = excluded.status
"#,
)
.bind(parent_thread_id.to_string())
.bind(child_thread_id.to_string())
.bind(status.as_ref())
.execute(self.pool.as_ref())
.await?;
Ok(())
}
pub async fn set_thread_spawn_edge_status(
&self,
child_thread_id: ThreadId,
status: crate::DirectionalThreadSpawnEdgeStatus,
) -> anyhow::Result<()> {
sqlx::query("UPDATE thread_spawn_edges SET status = ? WHERE child_thread_id = ?")
.bind(status.as_ref())
.bind(child_thread_id.to_string())
.execute(self.pool.as_ref())
.await?;
Ok(())
}
pub async fn list_thread_spawn_children_with_status(
&self,
parent_thread_id: ThreadId,
status: crate::DirectionalThreadSpawnEdgeStatus,
) -> anyhow::Result<Vec<ThreadId>> {
self.list_thread_spawn_children_matching(parent_thread_id, Some(status))
.await
}
pub async fn list_thread_spawn_children(
&self,
parent_thread_id: ThreadId,
) -> anyhow::Result<Vec<ThreadId>> {
self.list_thread_spawn_children_matching(parent_thread_id, None)
.await
}
pub async fn list_thread_spawn_descendants_with_status(
&self,
root_thread_id: ThreadId,
status: crate::DirectionalThreadSpawnEdgeStatus,
) -> anyhow::Result<Vec<ThreadId>> {
self.list_thread_spawn_descendants_matching(root_thread_id, Some(status))
.await
}
pub async fn list_thread_spawn_descendants(
&self,
root_thread_id: ThreadId,
) -> anyhow::Result<Vec<ThreadId>> {
self.list_thread_spawn_descendants_matching(root_thread_id, None)
.await
}
pub async fn find_thread_spawn_child_by_path(
&self,
parent_thread_id: ThreadId,
agent_path: &str,
) -> anyhow::Result<Option<ThreadId>> {
let rows = sqlx::query(
r#"
SELECT threads.id
FROM thread_spawn_edges
JOIN threads ON threads.id = thread_spawn_edges.child_thread_id
WHERE thread_spawn_edges.parent_thread_id = ?
AND threads.agent_path = ?
ORDER BY threads.id
LIMIT 2
"#,
)
.bind(parent_thread_id.to_string())
.bind(agent_path)
.fetch_all(self.pool.as_ref())
.await?;
one_thread_id_from_rows(rows, agent_path)
}
pub async fn find_thread_spawn_descendant_by_path(
&self,
root_thread_id: ThreadId,
agent_path: &str,
) -> anyhow::Result<Option<ThreadId>> {
let rows = sqlx::query(
r#"
WITH RECURSIVE subtree(child_thread_id) AS (
SELECT child_thread_id
FROM thread_spawn_edges
WHERE parent_thread_id = ?
UNION ALL
SELECT edge.child_thread_id
FROM thread_spawn_edges AS edge
JOIN subtree ON edge.parent_thread_id = subtree.child_thread_id
)
SELECT threads.id
FROM subtree
JOIN threads ON threads.id = subtree.child_thread_id
WHERE threads.agent_path = ?
ORDER BY threads.id
LIMIT 2
"#,
)
.bind(root_thread_id.to_string())
.bind(agent_path)
.fetch_all(self.pool.as_ref())
.await?;
one_thread_id_from_rows(rows, agent_path)
}
async fn list_thread_spawn_children_matching(
&self,
parent_thread_id: ThreadId,
status: Option<crate::DirectionalThreadSpawnEdgeStatus>,
) -> anyhow::Result<Vec<ThreadId>> {
let mut builder = QueryBuilder::<Sqlite>::new(
"SELECT child_thread_id FROM thread_spawn_edges WHERE parent_thread_id = ",
);
builder.push_bind(parent_thread_id.to_string());
if let Some(status) = status {
builder.push(" AND status = ").push_bind(status.to_string());
}
builder.push(" ORDER BY child_thread_id");
let rows = builder.build().fetch_all(self.pool.as_ref()).await?;
rows.into_iter()
.map(|row| {
ThreadId::try_from(row.try_get::<String, _>("child_thread_id")?).map_err(Into::into)
})
.collect()
}
async fn list_thread_spawn_descendants_matching(
&self,
root_thread_id: ThreadId,
status: Option<crate::DirectionalThreadSpawnEdgeStatus>,
) -> anyhow::Result<Vec<ThreadId>> {
let mut builder = QueryBuilder::<Sqlite>::new(
r#"
WITH RECURSIVE subtree(child_thread_id, depth) AS (
SELECT child_thread_id, 1
FROM thread_spawn_edges
WHERE parent_thread_id =
"#,
);
builder.push_bind(root_thread_id.to_string());
if let Some(status) = status {
let status = status.to_string();
builder.push(" AND status = ").push_bind(status.clone());
builder.push(
r#"
UNION ALL
SELECT edge.child_thread_id, subtree.depth + 1
FROM thread_spawn_edges AS edge
JOIN subtree ON edge.parent_thread_id = subtree.child_thread_id
WHERE status =
"#,
);
builder.push_bind(status);
} else {
builder.push(
r#"
UNION ALL
SELECT edge.child_thread_id, subtree.depth + 1
FROM thread_spawn_edges AS edge
JOIN subtree ON edge.parent_thread_id = subtree.child_thread_id
"#,
);
}
builder.push(
r#"
)
SELECT child_thread_id
FROM subtree
ORDER BY depth ASC, child_thread_id ASC
"#,
);
let rows = builder.build().fetch_all(self.pool.as_ref()).await?;
rows.into_iter()
.map(|row| {
ThreadId::try_from(row.try_get::<String, _>("child_thread_id")?).map_err(Into::into)
})
.collect()
}
async fn insert_thread_spawn_edge_if_absent(
&self,
parent_thread_id: ThreadId,
child_thread_id: ThreadId,
) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO thread_spawn_edges (
parent_thread_id,
child_thread_id,
status
) VALUES (?, ?, ?)
ON CONFLICT(child_thread_id) DO NOTHING
"#,
)
.bind(parent_thread_id.to_string())
.bind(child_thread_id.to_string())
.bind(crate::DirectionalThreadSpawnEdgeStatus::Open.as_ref())
.execute(self.pool.as_ref())
.await?;
Ok(())
}
async fn insert_thread_spawn_edge_from_source_if_absent(
&self,
child_thread_id: ThreadId,
source: &str,
) -> anyhow::Result<()> {
let Some(parent_thread_id) = thread_spawn_parent_thread_id_from_source_str(source) else {
return Ok(());
};
self.insert_thread_spawn_edge_if_absent(parent_thread_id, child_thread_id)
.await
}
pub async fn find_rollout_path_by_id(
&self,
id: ThreadId,
archived_only: Option<bool>,
) -> anyhow::Result<Option<PathBuf>> {
let mut builder =
QueryBuilder::<Sqlite>::new("SELECT rollout_path FROM threads WHERE id = ");
builder.push_bind(id.to_string());
match archived_only {
Some(true) => {
builder.push(" AND archived = 1");
}
Some(false) => {
builder.push(" AND archived = 0");
}
None => {}
}
let row = builder.build().fetch_optional(self.pool.as_ref()).await?;
Ok(row
.and_then(|r| r.try_get::<String, _>("rollout_path").ok())
.map(PathBuf::from))
}
#[allow(clippy::too_many_arguments)]
pub async fn find_thread_by_exact_title(
&self,
title: &str,
allowed_sources: &[String],
model_providers: Option<&[String]>,
archived_only: bool,
cwd: Option<&Path>,
) -> anyhow::Result<Option<crate::ThreadMetadata>> {
let mut builder = QueryBuilder::<Sqlite>::new("");
push_thread_select_columns(&mut builder);
builder.push(" FROM threads");
push_thread_filters(
&mut builder,
ThreadFilterOptions {
archived_only,
allowed_sources,
model_providers,
cwd_filters: None,
anchor: None,
sort_key: crate::SortKey::UpdatedAt,
sort_direction: SortDirection::Desc,
search_term: None,
},
false,
);
builder.push(" AND threads.title = ");
builder.push_bind(title);
if let Some(cwd) = cwd {
builder.push(" AND threads.cwd = ");
builder.push_bind(cwd.display().to_string());
}
push_thread_order_and_limit(
&mut builder,
crate::SortKey::UpdatedAt,
SortDirection::Desc,
OrderByIndex::Enabled,
false,
1,
);
let row = builder.build().fetch_optional(self.pool.as_ref()).await?;
row.map(|row| ThreadRow::try_from_row(&row).and_then(crate::ThreadMetadata::try_from))
.transpose()
}
pub async fn list_threads(
&self,
page_size: usize,
filters: ThreadFilterOptions<'_>,
) -> anyhow::Result<crate::ThreadsPage> {
self.list_threads_matching(page_size, filters, None)
.await
}
pub async fn list_threads_by_parent(
&self,
page_size: usize,
parent_thread_id: ThreadId,
filters: ThreadFilterOptions<'_>,
) -> anyhow::Result<crate::ThreadsPage> {
self.list_threads_by_relation(
page_size,
crate::ThreadRelationFilter::DirectChildrenOf(parent_thread_id),
filters,
)
.await
}
pub async fn list_threads_by_relation(
&self,
page_size: usize,
relation_filter: crate::ThreadRelationFilter,
filters: ThreadFilterOptions<'_>,
) -> anyhow::Result<crate::ThreadsPage> {
self.list_threads_matching(page_size, filters, Some(relation_filter))
.await
}
async fn list_threads_matching(
&self,
page_size: usize,
filters: ThreadFilterOptions<'_>,
relation_filter: Option<crate::ThreadRelationFilter>,
) -> anyhow::Result<crate::ThreadsPage> {
let limit = page_size.saturating_add(1);
let mut builder = QueryBuilder::<Sqlite>::new("");
push_list_threads_query(&mut builder, filters, relation_filter, limit);
let rows = builder.build().fetch_all(self.pool.as_ref()).await?;
let mut items = Vec::with_capacity(rows.len());
let mut parent_thread_ids = std::collections::HashMap::new();
for row in rows {
let item = ThreadRow::try_from_row(&row).and_then(ThreadMetadata::try_from)?;
if relation_filter.is_some()
&& let Some(parent_thread_id) =
row.try_get::<Option<String>, _>("parent_thread_id")?
{
parent_thread_ids.insert(item.id, ThreadId::try_from(parent_thread_id)?);
}
items.push(item);
}
let num_scanned_rows = items.len();
let next_anchor = if items.len() > page_size {
if let Some(overflow_item) = items.pop() {
parent_thread_ids.remove(&overflow_item.id);
}
items.last().and_then(|item| {
anchor_from_item(item, filters.sort_key, relation_filter.is_some())
})
} else {
None
};
Ok(ThreadsPage {
items,
parent_thread_ids,
next_anchor,
num_scanned_rows,
})
}
pub async fn list_thread_ids(
&self,
limit: usize,
anchor: Option<&crate::Anchor>,
sort_key: crate::SortKey,
allowed_sources: &[String],
model_providers: Option<&[String]>,
archived_only: bool,
) -> anyhow::Result<Vec<ThreadId>> {
let mut builder = QueryBuilder::<Sqlite>::new("SELECT threads.id FROM threads");
push_thread_filters(
&mut builder,
ThreadFilterOptions {
archived_only,
allowed_sources,
model_providers,
cwd_filters: None,
anchor,
sort_key,
sort_direction: SortDirection::Desc,
search_term: None,
},
sort_key == crate::SortKey::RecencyAt,
);
push_thread_order_and_limit(
&mut builder,
sort_key,
SortDirection::Desc,
OrderByIndex::Enabled,
sort_key == crate::SortKey::RecencyAt,
limit,
);
let rows = builder.build().fetch_all(self.pool.as_ref()).await?;
rows.into_iter()
.map(|row| {
let id: String = row.try_get("id")?;
Ok(ThreadId::try_from(id)?)
})
.collect()
}
pub async fn upsert_thread(&self, metadata: &crate::ThreadMetadata) -> anyhow::Result<()> {
self.upsert_thread_with_creation_memory_mode(metadata, None)
.await
}
pub async fn insert_thread_if_absent(
&self,
metadata: &crate::ThreadMetadata,
) -> anyhow::Result<bool> {
let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?;
let recency_at = self.allocate_thread_recency_at(metadata.recency_at)?;
let preview = metadata_preview(metadata);
let result = sqlx::query(
r#"
INSERT INTO threads (
id,
rollout_path,
created_at,
updated_at,
recency_at,
created_at_ms,
updated_at_ms,
recency_at_ms,
source,
history_mode,
thread_source,
agent_nickname,
agent_role,
agent_path,
model_provider,
model,
reasoning_effort,
cwd,
cli_version,
title,
name,
preview,
sandbox_policy,
approval_mode,
tokens_used,
first_user_message,
archived,
archived_at,
git_sha,
git_branch,
git_origin_url,
memory_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
"#,
)
.bind(metadata.id.to_string())
.bind(metadata.rollout_path.display().to_string())
.bind(datetime_to_epoch_seconds(metadata.created_at))
.bind(datetime_to_epoch_seconds(updated_at))
.bind(datetime_to_epoch_seconds(recency_at))
.bind(datetime_to_epoch_millis(metadata.created_at))
.bind(datetime_to_epoch_millis(updated_at))
.bind(datetime_to_epoch_millis(recency_at))
.bind(metadata.source.as_str())
.bind(metadata.history_mode.as_str())
.bind(
metadata
.thread_source
.as_ref()
.map(codex_protocol::protocol::ThreadSource::as_str),
)
.bind(metadata.agent_nickname.as_deref())
.bind(metadata.agent_role.as_deref())
.bind(metadata.agent_path.as_deref())
.bind(metadata.model_provider.as_str())
.bind(metadata.model.as_deref())
.bind(
metadata
.reasoning_effort
.as_ref()
.map(crate::extract::enum_to_string),
)
.bind(metadata.cwd.display().to_string())
.bind(metadata.cli_version.as_str())
.bind(metadata.title.as_str())
.bind(metadata.name.as_deref())
.bind(preview)
.bind(metadata.sandbox_policy.as_str())
.bind(metadata.approval_mode.as_str())
.bind(metadata.tokens_used)
.bind(metadata.first_user_message.as_deref().unwrap_or_default())
.bind(metadata.archived_at.is_some())
.bind(metadata.archived_at.map(datetime_to_epoch_seconds))
.bind(metadata.git_sha.as_deref())
.bind(metadata.git_branch.as_deref())
.bind(metadata.git_origin_url.as_deref())
.bind("enabled")
.execute(self.pool.as_ref())
.await?;
self.insert_thread_spawn_edge_from_source_if_absent(metadata.id, metadata.source.as_str())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn set_thread_memory_mode(
&self,
thread_id: ThreadId,
memory_mode: &str,
) -> anyhow::Result<bool> {
let result = sqlx::query("UPDATE threads SET memory_mode = ? WHERE id = ?")
.bind(memory_mode)
.bind(thread_id.to_string())
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn update_thread_title(
&self,
thread_id: ThreadId,
title: &str,
) -> anyhow::Result<bool> {
let result = sqlx::query("UPDATE threads SET title = ? WHERE id = ?")
.bind(title)
.bind(thread_id.to_string())
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn update_thread_name(
&self,
thread_id: ThreadId,
name: Option<&str>,
) -> anyhow::Result<bool> {
let result = sqlx::query("UPDATE threads SET name = ? WHERE id = ?")
.bind(name)
.bind(thread_id.to_string())
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn touch_thread_updated_at(
&self,
thread_id: ThreadId,
updated_at: DateTime<Utc>,
) -> anyhow::Result<bool> {
let updated_at = self.allocate_thread_updated_at(updated_at)?;
let result =
sqlx::query("UPDATE threads SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
.bind(datetime_to_epoch_seconds(updated_at))
.bind(datetime_to_epoch_millis(updated_at))
.bind(thread_id.to_string())
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn touch_thread_recency_at(
&self,
thread_id: ThreadId,
recency_at: DateTime<Utc>,
) -> anyhow::Result<bool> {
let recency_at = self.allocate_thread_recency_at(recency_at)?;
let recency_at_seconds = datetime_to_epoch_seconds(recency_at);
let recency_at_millis = datetime_to_epoch_millis(recency_at);
let result = sqlx::query(
r#"
UPDATE threads
SET
recency_at = MAX(?, MAX(?, recency_at_ms + 1) / 1000),
recency_at_ms = MAX(?, recency_at_ms + 1)
WHERE id = ?
"#,
)
.bind(recency_at_seconds)
.bind(recency_at_millis)
.bind(recency_at_millis)
.bind(thread_id.to_string())
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
fn allocate_thread_updated_at(
&self,
updated_at: DateTime<Utc>,
) -> anyhow::Result<DateTime<Utc>> {
allocate_thread_timestamp(self.thread_updated_at_millis.as_ref(), updated_at)
}
fn allocate_thread_recency_at(
&self,
recency_at: DateTime<Utc>,
) -> anyhow::Result<DateTime<Utc>> {
allocate_thread_timestamp(self.thread_recency_at_millis.as_ref(), recency_at)
}
}
fn allocate_thread_timestamp(
high_water_mark: &AtomicI64,
timestamp: DateTime<Utc>,
) -> anyhow::Result<DateTime<Utc>> {
let candidate = datetime_to_epoch_millis(timestamp);
let allocated = loop {
let current = high_water_mark.load(Ordering::Relaxed);
if candidate > current {
if high_water_mark
.compare_exchange(current, candidate, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
break candidate;
}
continue;
}
if candidate.saturating_add(1000) <= current {
break candidate;
}
let bumped = current.saturating_add(1);
if high_water_mark
.compare_exchange(current, bumped, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
break bumped;
}
};
epoch_millis_to_datetime(allocated)
}
impl StateRuntime {
pub async fn update_thread_git_info(
&self,
thread_id: ThreadId,
git_sha: Option<Option<&str>>,
git_branch: Option<Option<&str>>,
git_origin_url: Option<Option<&str>>,
) -> anyhow::Result<bool> {
let result = sqlx::query(
r#"
UPDATE threads
SET
git_sha = CASE WHEN ? THEN ? ELSE git_sha END,
git_branch = CASE WHEN ? THEN ? ELSE git_branch END,
git_origin_url = CASE WHEN ? THEN ? ELSE git_origin_url END
WHERE id = ?
"#,
)
.bind(git_sha.is_some())
.bind(git_sha.flatten())
.bind(git_branch.is_some())
.bind(git_branch.flatten())
.bind(git_origin_url.is_some())
.bind(git_origin_url.flatten())
.bind(thread_id.to_string())
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
async fn upsert_thread_with_creation_memory_mode(
&self,
metadata: &crate::ThreadMetadata,
creation_memory_mode: Option<&str>,
) -> anyhow::Result<()> {
let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?;
let insert_recency_at = self.allocate_thread_recency_at(metadata.recency_at)?;
let preview = metadata_preview(metadata);
sqlx::query(
r#"
INSERT INTO threads (
id,
rollout_path,
created_at,
updated_at,
recency_at,
created_at_ms,
updated_at_ms,
recency_at_ms,
source,
history_mode,
thread_source,
agent_nickname,
agent_role,
agent_path,
model_provider,
model,
reasoning_effort,
cwd,
cli_version,
title,
name,
preview,
sandbox_policy,
approval_mode,
tokens_used,
first_user_message,
archived,
archived_at,
git_sha,
git_branch,
git_origin_url,
memory_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
rollout_path = excluded.rollout_path,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
recency_at = threads.recency_at,
created_at_ms = excluded.created_at_ms,
updated_at_ms = excluded.updated_at_ms,
recency_at_ms = threads.recency_at_ms,
source = excluded.source,
history_mode = excluded.history_mode,
thread_source = excluded.thread_source,
agent_nickname = excluded.agent_nickname,
agent_role = excluded.agent_role,
agent_path = excluded.agent_path,
model_provider = excluded.model_provider,
model = excluded.model,
reasoning_effort = excluded.reasoning_effort,
cwd = excluded.cwd,
cli_version = excluded.cli_version,
title = excluded.title,
preview = COALESCE(NULLIF(excluded.preview, ''), threads.preview),
sandbox_policy = excluded.sandbox_policy,
approval_mode = excluded.approval_mode,
tokens_used = excluded.tokens_used,
first_user_message = excluded.first_user_message,
archived = excluded.archived,
archived_at = excluded.archived_at,
git_sha = COALESCE(threads.git_sha, excluded.git_sha),
git_branch = COALESCE(threads.git_branch, excluded.git_branch),
git_origin_url = COALESCE(threads.git_origin_url, excluded.git_origin_url)
"#,
)
.bind(metadata.id.to_string())
.bind(metadata.rollout_path.display().to_string())
.bind(datetime_to_epoch_seconds(metadata.created_at))
.bind(datetime_to_epoch_seconds(updated_at))
.bind(datetime_to_epoch_seconds(insert_recency_at))
.bind(datetime_to_epoch_millis(metadata.created_at))
.bind(datetime_to_epoch_millis(updated_at))
.bind(datetime_to_epoch_millis(insert_recency_at))
.bind(metadata.source.as_str())
.bind(metadata.history_mode.as_str())
.bind(
metadata
.thread_source
.as_ref()
.map(codex_protocol::protocol::ThreadSource::as_str),
)
.bind(metadata.agent_nickname.as_deref())
.bind(metadata.agent_role.as_deref())
.bind(metadata.agent_path.as_deref())
.bind(metadata.model_provider.as_str())
.bind(metadata.model.as_deref())
.bind(
metadata
.reasoning_effort
.as_ref()
.map(crate::extract::enum_to_string),
)
.bind(metadata.cwd.display().to_string())
.bind(metadata.cli_version.as_str())
.bind(metadata.title.as_str())
.bind(metadata.name.as_deref())
.bind(preview)
.bind(metadata.sandbox_policy.as_str())
.bind(metadata.approval_mode.as_str())
.bind(metadata.tokens_used)
.bind(metadata.first_user_message.as_deref().unwrap_or_default())
.bind(metadata.archived_at.is_some())
.bind(metadata.archived_at.map(datetime_to_epoch_seconds))
.bind(metadata.git_sha.as_deref())
.bind(metadata.git_branch.as_deref())
.bind(metadata.git_origin_url.as_deref())
.bind(creation_memory_mode.unwrap_or("enabled"))
.execute(self.pool.as_ref())
.await?;
self.insert_thread_spawn_edge_from_source_if_absent(metadata.id, metadata.source.as_str())
.await?;
Ok(())
}
pub async fn apply_rollout_items(
&self,
builder: &ThreadMetadataBuilder,
items: &[RolloutItem],
new_thread_memory_mode: Option<&str>,
updated_at_override: Option<DateTime<Utc>>,
) -> anyhow::Result<()> {
if items.is_empty() {
return Ok(());
}
let existing_metadata = self.get_thread(builder.id).await?;
let mut metadata = existing_metadata
.clone()
.unwrap_or_else(|| builder.build(&self.default_provider));
metadata.rollout_path = builder.rollout_path.clone();
for item in items {
apply_rollout_item(&mut metadata, item, &self.default_provider);
}
if let Some(existing_metadata) = existing_metadata.as_ref() {
metadata.prefer_existing_git_info(existing_metadata);
}
let updated_at = match updated_at_override {
Some(updated_at) => Some(updated_at),
None => file_modified_time_utc(builder.rollout_path.as_path()).await,
};
if let Some(updated_at) = updated_at {
metadata.updated_at = updated_at;
}
let upsert_result = if existing_metadata.is_none() {
self.upsert_thread_with_creation_memory_mode(&metadata, new_thread_memory_mode)
.await
} else {
self.upsert_thread(&metadata).await
};
upsert_result?;
if let Some(memory_mode) = extract_memory_mode(items)
&& let Err(err) = self
.set_thread_memory_mode(builder.id, memory_mode.as_str())
.await
{
return Err(err);
}
Ok(())
}
pub async fn mark_archived(
&self,
thread_id: ThreadId,
rollout_path: &Path,
archived_at: DateTime<Utc>,
) -> anyhow::Result<()> {
let Some(mut metadata) = self.get_thread(thread_id).await? else {
return Ok(());
};
metadata.archived_at = Some(archived_at);
metadata.rollout_path = rollout_path.to_path_buf();
if let Some(updated_at) = file_modified_time_utc(rollout_path).await {
metadata.updated_at = updated_at;
}
if metadata.id != thread_id {
warn!(
"thread id mismatch during archive: expected {thread_id}, got {}",
metadata.id
);
}
self.upsert_thread(&metadata).await
}
pub async fn mark_unarchived(
&self,
thread_id: ThreadId,
rollout_path: &Path,
) -> anyhow::Result<()> {
let Some(mut metadata) = self.get_thread(thread_id).await? else {
return Ok(());
};
metadata.archived_at = None;
metadata.rollout_path = rollout_path.to_path_buf();
if let Some(updated_at) = file_modified_time_utc(rollout_path).await {
metadata.updated_at = updated_at;
}
if metadata.id != thread_id {
warn!(
"thread id mismatch during unarchive: expected {thread_id}, got {}",
metadata.id
);
}
self.upsert_thread(&metadata).await
}
pub async fn delete_thread(&self, thread_id: ThreadId) -> anyhow::Result<u64> {
self.delete_threads_strict(&[thread_id]).await
}
pub async fn delete_threads_strict(&self, thread_ids: &[ThreadId]) -> anyhow::Result<u64> {
if thread_ids.is_empty() {
return Ok(0);
}
let thread_id_strings = thread_ids
.iter()
.map(ThreadId::to_string)
.collect::<Vec<_>>();
for (thread_id, thread_id_string) in thread_ids.iter().zip(&thread_id_strings) {
sqlx::query("DELETE FROM logs WHERE thread_id = ?")
.bind(thread_id_string)
.execute(self.logs_pool.as_ref())
.await?;
self.memories.delete_thread_memory(*thread_id).await?;
self.thread_goals.delete_thread_goal(*thread_id).await?;
}
let mut tx = self.pool.begin().await?;
for thread_id_string in &thread_id_strings {
sqlx::query("DELETE FROM thread_dynamic_tools WHERE thread_id = ?")
.bind(thread_id_string)
.execute(&mut *tx)
.await?;
}
for thread_id_string in &thread_id_strings {
sqlx::query(
"DELETE FROM thread_spawn_edges WHERE parent_thread_id = ? OR child_thread_id = ?",
)
.bind(thread_id_string)
.bind(thread_id_string)
.execute(&mut *tx)
.await?;
}
let mut rows_affected = 0;
for thread_id_string in &thread_id_strings {
rows_affected += sqlx::query("DELETE FROM threads WHERE id = ?")
.bind(thread_id_string)
.execute(&mut *tx)
.await?
.rows_affected();
}
tx.commit().await?;
Ok(rows_affected)
}
}
fn one_thread_id_from_rows(
rows: Vec<sqlx::sqlite::SqliteRow>,
agent_path: &str,
) -> anyhow::Result<Option<ThreadId>> {
let mut ids = rows
.into_iter()
.map(|row| {
let id: String = row.try_get("id")?;
ThreadId::try_from(id).map_err(anyhow::Error::from)
})
.collect::<Result<Vec<_>, _>>()?;
match ids.len() {
0 => Ok(None),
1 => Ok(ids.pop()),
_ => Err(anyhow::anyhow!(
"multiple agents found for canonical path `{agent_path}`"
)),
}
}
fn push_list_threads_query(
builder: &mut QueryBuilder<Sqlite>,
filters: ThreadFilterOptions<'_>,
relation_filter: Option<crate::ThreadRelationFilter>,
limit: usize,
) {
if let Some(crate::ThreadRelationFilter::DescendantsOf(ancestor_thread_id)) = relation_filter {
builder.push(
r#"
WITH RECURSIVE subtree(child_thread_id, parent_thread_id) AS (
SELECT child_thread_id, parent_thread_id
FROM thread_spawn_edges
WHERE parent_thread_id =
"#,
);
builder.push_bind(ancestor_thread_id.to_string());
builder.push(
r#"
UNION
SELECT edge.child_thread_id, edge.parent_thread_id
FROM thread_spawn_edges AS edge
JOIN subtree ON edge.parent_thread_id = subtree.child_thread_id
)
"#,
);
}
push_thread_select_columns(builder);
match relation_filter {
Some(crate::ThreadRelationFilter::DirectChildrenOf(_)) => builder.push(
", listed_edge.parent_thread_id AS parent_thread_id\nFROM thread_spawn_edges AS listed_edge\nCROSS JOIN threads ON threads.id = listed_edge.child_thread_id",
),
Some(crate::ThreadRelationFilter::DescendantsOf(_)) => builder.push(
", subtree.parent_thread_id AS parent_thread_id\nFROM subtree\nCROSS JOIN threads ON threads.id = subtree.child_thread_id",
),
None => builder.push(" FROM threads"),
};
let include_thread_id_tiebreaker =
relation_filter.is_some() || filters.sort_key == SortKey::RecencyAt;
push_thread_filters(builder, filters, include_thread_id_tiebreaker);
match relation_filter {
Some(crate::ThreadRelationFilter::DirectChildrenOf(parent_thread_id)) => {
builder.push(" AND listed_edge.parent_thread_id = ");
builder.push_bind(parent_thread_id.to_string());
}
Some(crate::ThreadRelationFilter::DescendantsOf(ancestor_thread_id)) => {
builder.push(" AND subtree.child_thread_id != ");
builder.push_bind(ancestor_thread_id.to_string());
}
None => {}
}
let order_by_index = match (relation_filter, filters.cwd_filters) {
(Some(_), _) => OrderByIndex::Disabled,
(None, Some(cwd_filters)) if cwd_filters.len() > 1 => OrderByIndex::Disabled,
(None, Some(_) | None) => OrderByIndex::Enabled,
};
push_thread_order_and_limit(
builder,
filters.sort_key,
filters.sort_direction,
order_by_index,
include_thread_id_tiebreaker,
limit,
);
}
pub(super) fn push_thread_select_columns(builder: &mut QueryBuilder<Sqlite>) {
builder.push(
r#"
SELECT
threads.id,
threads.rollout_path,
threads.created_at_ms AS created_at,
threads.updated_at_ms AS updated_at,
threads.recency_at_ms AS recency_at,
threads.source,
threads.history_mode,
threads.thread_source,
threads.agent_nickname,
threads.agent_role,
threads.agent_path,
threads.model_provider,
threads.model,
threads.reasoning_effort,
threads.cwd,
threads.cli_version,
threads.title,
threads.name,
threads.preview,
threads.sandbox_policy,
threads.approval_mode,
threads.tokens_used,
threads.first_user_message,
threads.archived_at,
threads.git_sha,
threads.git_branch,
threads.git_origin_url
"#,
);
}
pub(super) fn extract_memory_mode(items: &[RolloutItem]) -> Option<String> {
items.iter().rev().find_map(|item| match item {
RolloutItem::SessionMeta(meta_line) => meta_line.meta.memory_mode.clone(),
RolloutItem::ResponseItem(_)
| RolloutItem::InterAgentCommunication(_)
| RolloutItem::InterAgentCommunicationMetadata { .. }
| RolloutItem::Compacted(_)
| RolloutItem::TurnContext(_)
| RolloutItem::WorldState(_)
| RolloutItem::EventMsg(_) => None,
})
}
fn thread_spawn_parent_thread_id_from_source_str(source: &str) -> Option<ThreadId> {
let parsed_source = serde_json::from_str(source)
.or_else(|_| serde_json::from_value::<SessionSource>(Value::String(source.to_string())));
parsed_source.ok()?.parent_thread_id()
}
#[derive(Clone, Copy)]
pub struct ThreadFilterOptions<'a> {
pub archived_only: bool,
pub allowed_sources: &'a [String],
pub model_providers: Option<&'a [String]>,
pub cwd_filters: Option<&'a [PathBuf]>,
pub anchor: Option<&'a crate::Anchor>,
pub sort_key: SortKey,
pub sort_direction: SortDirection,
pub search_term: Option<&'a str>,
}
pub(super) fn push_thread_filters<'a>(
builder: &mut QueryBuilder<Sqlite>,
options: ThreadFilterOptions<'a>,
include_thread_id_tiebreaker: bool,
) {
let ThreadFilterOptions {
archived_only,
allowed_sources,
model_providers,
cwd_filters,
anchor,
sort_key,
sort_direction,
search_term,
} = options;
builder.push(" WHERE 1 = 1");
if archived_only {
builder.push(" AND threads.archived = 1");
} else {
builder.push(" AND threads.archived = 0");
}
builder.push(" AND threads.preview <> ''");
if !allowed_sources.is_empty() {
builder.push(" AND threads.source IN (");
let mut separated = builder.separated(", ");
for source in allowed_sources {
separated.push_bind(source);
}
separated.push_unseparated(")");
}
if let Some(model_providers) = model_providers
&& !model_providers.is_empty()
{
builder.push(" AND threads.model_provider IN (");
let mut separated = builder.separated(", ");
for provider in model_providers {
separated.push_bind(provider);
}
separated.push_unseparated(")");
}
match cwd_filters {
Some([]) => {
builder.push(" AND 1 = 0");
}
Some(cwd_filters) => {
builder.push(" AND threads.cwd IN (");
let mut separated = builder.separated(", ");
for cwd in cwd_filters {
separated.push_bind(cwd.display().to_string());
}
separated.push_unseparated(")");
}
None => {}
}
if let Some(search_term) = search_term {
builder.push(" AND (instr(COALESCE(threads.name, ''), ");
builder.push_bind(search_term);
builder.push(") > 0 OR instr(threads.title, ");
builder.push_bind(search_term);
builder.push(") > 0 OR instr(threads.preview, ");
builder.push_bind(search_term);
builder.push(") > 0)");
}
if let Some(anchor) = anchor {
let anchor_ts = datetime_to_epoch_millis(anchor.ts);
let column = match sort_key {
SortKey::CreatedAt => "threads.created_at_ms",
SortKey::UpdatedAt => "threads.updated_at_ms",
SortKey::RecencyAt => "threads.recency_at_ms",
};
let operator = match sort_direction {
SortDirection::Asc => ">",
SortDirection::Desc => "<",
};
builder.push(" AND (");
builder.push(column);
builder.push(" ");
builder.push(operator);
builder.push(" ");
builder.push_bind(anchor_ts);
if include_thread_id_tiebreaker && let Some(anchor_id) = anchor.id {
builder.push(" OR (");
builder.push(column);
builder.push(" = ");
builder.push_bind(anchor_ts);
builder.push(" AND threads.id ");
builder.push(operator);
builder.push(" ");
builder.push_bind(anchor_id.to_string());
builder.push(")");
}
builder.push(")");
}
}
#[derive(Clone, Copy)]
pub(super) enum OrderByIndex {
Enabled,
Disabled,
}
pub(super) fn push_thread_order_and_limit(
builder: &mut QueryBuilder<Sqlite>,
sort_key: SortKey,
sort_direction: SortDirection,
order_by_index: OrderByIndex,
include_thread_id_tiebreaker: bool,
limit: usize,
) {
let order_column = match sort_key {
SortKey::CreatedAt => "threads.created_at_ms",
SortKey::UpdatedAt => "threads.updated_at_ms",
SortKey::RecencyAt => "threads.recency_at_ms",
};
let order_direction = match sort_direction {
SortDirection::Asc => "ASC",
SortDirection::Desc => "DESC",
};
builder.push(" ORDER BY ");
match order_by_index {
OrderByIndex::Enabled => {}
OrderByIndex::Disabled => {
builder.push("+");
}
}
builder.push(order_column);
builder.push(" ");
builder.push(order_direction);
if include_thread_id_tiebreaker {
builder.push(", threads.id ");
builder.push(order_direction);
}
builder.push(" LIMIT ");
builder.push_bind(limit as i64);
}
fn metadata_preview(metadata: &crate::ThreadMetadata) -> &str {
metadata
.preview
.as_deref()
.or(metadata.first_user_message.as_deref())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Anchor;
use crate::DirectionalThreadSpawnEdgeStatus;
use crate::runtime::test_support::test_thread_metadata;
use crate::runtime::test_support::unique_temp_dir;
use anyhow::Result;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadHistoryMode;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[tokio::test]
async fn upsert_thread_keeps_creation_memory_mode_for_existing_rows() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
runtime
.upsert_thread_with_creation_memory_mode(&metadata, Some("disabled"))
.await
.expect("initial insert should succeed");
let memory_mode: String =
sqlx::query_scalar("SELECT memory_mode FROM threads WHERE id = ?")
.bind(thread_id.to_string())
.fetch_one(runtime.pool.as_ref())
.await
.expect("memory mode should be readable");
assert_eq!(memory_mode, "disabled");
metadata.title = "updated title".to_string();
runtime
.upsert_thread(&metadata)
.await
.expect("upsert should succeed");
let memory_mode: String =
sqlx::query_scalar("SELECT memory_mode FROM threads WHERE id = ?")
.bind(thread_id.to_string())
.fetch_one(runtime.pool.as_ref())
.await
.expect("memory mode should remain readable");
assert_eq!(memory_mode, "disabled");
}
#[tokio::test]
async fn thread_metadata_round_trips_history_mode() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000124").expect("valid thread id");
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
metadata.history_mode = ThreadHistoryMode::Paginated;
runtime
.upsert_thread(&metadata)
.await
.expect("upsert should succeed");
let metadata = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(metadata.history_mode, ThreadHistoryMode::Paginated);
}
#[tokio::test]
async fn delete_thread_cleans_associated_state() -> Result<()> {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()).await?;
let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000401")?;
let child_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000402")?;
runtime
.upsert_thread(&test_thread_metadata(
&codex_home,
thread_id,
codex_home.clone(),
))
.await?;
seed_thread_cleanup_state(&runtime, thread_id, child_thread_id).await?;
sqlx::query("INSERT INTO thread_dynamic_tools (thread_id, position, name, description, input_schema) VALUES (?, ?, ?, ?, ?)")
.bind(thread_id.to_string())
.bind(0_i64)
.bind("test_tool")
.bind("test dynamic tool")
.bind("{}")
.execute(runtime.pool.as_ref())
.await?;
let rows = runtime
.delete_threads_strict(&[thread_id, child_thread_id])
.await?;
assert_eq!(rows, 1);
assert!(runtime.get_thread(thread_id).await?.is_none());
let dynamic_tool_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM thread_dynamic_tools WHERE thread_id = ?")
.bind(thread_id.to_string())
.fetch_one(runtime.pool.as_ref())
.await?;
assert_eq!(dynamic_tool_count, 0);
assert_thread_cleanup_state(&runtime, thread_id).await?;
let missing_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000403")?;
let missing_child_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000404")?;
seed_thread_cleanup_state(&runtime, missing_thread_id, missing_child_thread_id).await?;
assert_eq!(runtime.delete_thread(missing_thread_id).await?, 0);
assert_thread_cleanup_state(&runtime, missing_thread_id).await?;
Ok(())
}
#[tokio::test]
async fn delete_thread_keeps_retry_graph_on_cleanup_failure() -> Result<()> {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()).await?;
let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000405")?;
let child_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000406")?;
runtime
.upsert_thread(&test_thread_metadata(
&codex_home,
thread_id,
codex_home.clone(),
))
.await?;
seed_thread_cleanup_state(&runtime, thread_id, child_thread_id).await?;
runtime.logs_pool.close().await;
runtime
.delete_thread(thread_id)
.await
.expect_err("closed log db should fail deletion");
assert!(runtime.get_thread(thread_id).await?.is_some());
assert_eq!(
runtime.list_thread_spawn_descendants(thread_id).await?,
vec![child_thread_id]
);
Ok(())
}
async fn seed_thread_cleanup_state(
runtime: &StateRuntime,
thread_id: ThreadId,
child_thread_id: ThreadId,
) -> Result<()> {
runtime
.upsert_thread_spawn_edge(
thread_id,
child_thread_id,
DirectionalThreadSpawnEdgeStatus::Closed,
)
.await?;
runtime
.thread_goals()
.replace_thread_goal(
thread_id,
"test goal",
crate::ThreadGoalStatus::Active,
None,
)
.await?;
sqlx::query("INSERT INTO logs (ts, ts_nanos, level, target, feedback_log_body, thread_id) VALUES (1, 0, 'INFO', 'test', 'feedback log', ?)")
.bind(thread_id.to_string())
.execute(runtime.logs_pool.as_ref())
.await?;
Ok(())
}
async fn assert_thread_cleanup_state(
runtime: &StateRuntime,
thread_id: ThreadId,
) -> Result<()> {
let spawn_edge_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM thread_spawn_edges WHERE parent_thread_id = ? OR child_thread_id = ?",
)
.bind(thread_id.to_string())
.bind(thread_id.to_string())
.fetch_one(runtime.pool.as_ref())
.await?;
assert_eq!(spawn_edge_count, 0);
assert_eq!(
runtime.thread_goals().get_thread_goal(thread_id).await?,
None
);
let logs = runtime
.query_logs(&LogQuery {
thread_ids: vec![thread_id.to_string()],
..Default::default()
})
.await?;
assert!(logs.is_empty());
Ok(())
}
#[tokio::test]
async fn list_threads_updated_after_returns_oldest_changes_first() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let older_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread id");
let middle_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread id");
let newer_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000003").expect("valid thread id");
let older_updated_at =
DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("valid older timestamp");
let newer_updated_at =
DateTime::<Utc>::from_timestamp(1_700_000_200, 0).expect("valid newer timestamp");
for (thread_id, updated_at) in [
(older_id, older_updated_at),
(newer_id, newer_updated_at),
(middle_id, newer_updated_at),
] {
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
metadata.updated_at = updated_at;
metadata.first_user_message = Some("hello".to_string());
runtime
.upsert_thread(&metadata)
.await
.expect("thread insert should succeed");
}
let anchor = Anchor {
ts: older_updated_at,
id: None,
};
let model_providers = ["test-provider".to_string()];
let page = runtime
.list_threads(
1,
ThreadFilterOptions {
archived_only: false,
allowed_sources: &[],
model_providers: Some(&model_providers),
cwd_filters: None,
anchor: Some(&anchor),
sort_key: SortKey::UpdatedAt,
sort_direction: SortDirection::Asc,
search_term: None,
},
)
.await
.expect("list should succeed");
let ids = page.items.iter().map(|item| item.id).collect::<Vec<_>>();
assert_eq!(ids, vec![newer_id]);
assert_eq!(
page.next_anchor,
Some(Anchor {
ts: DateTime::<Utc>::from_timestamp_millis(1_700_000_200_000)
.expect("valid timestamp"),
id: None,
})
);
let page = runtime
.list_threads(
1,
ThreadFilterOptions {
archived_only: false,
allowed_sources: &[],
model_providers: Some(&model_providers),
cwd_filters: None,
anchor: page.next_anchor.as_ref(),
sort_key: SortKey::UpdatedAt,
sort_direction: SortDirection::Asc,
search_term: None,
},
)
.await
.expect("second page should succeed");
let ids = page.items.iter().map(|item| item.id).collect::<Vec<_>>();
assert_eq!(ids, vec![middle_id]);
assert_eq!(page.next_anchor, None);
}
#[tokio::test]
async fn list_threads_filters_by_cwd() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let first_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread id");
let second_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000102").expect("valid thread id");
let other_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000103").expect("valid thread id");
let first_cwd = codex_home.join("first");
let second_cwd = codex_home.join("second");
let other_cwd = codex_home.join("other");
for (thread_id, cwd, updated_at) in [
(first_id, first_cwd.clone(), 1_700_000_100),
(second_id, second_cwd.clone(), 1_700_000_300),
(other_id, other_cwd, 1_700_000_500),
] {
let mut metadata = test_thread_metadata(&codex_home, thread_id, cwd);
metadata.updated_at =
DateTime::<Utc>::from_timestamp(updated_at, 0).expect("valid timestamp");
runtime
.upsert_thread(&metadata)
.await
.expect("thread insert should succeed");
}
let cwd_filters = vec![first_cwd, second_cwd];
let first_page = runtime
.list_threads(
1,
ThreadFilterOptions {
archived_only: false,
allowed_sources: &[],
model_providers: None,
cwd_filters: Some(cwd_filters.as_slice()),
anchor: None,
sort_key: SortKey::UpdatedAt,
sort_direction: SortDirection::Desc,
search_term: None,
},
)
.await
.expect("list should succeed");
let ids = first_page
.items
.iter()
.map(|item| item.id)
.collect::<Vec<_>>();
assert_eq!(ids, vec![second_id]);
assert_eq!(
first_page.next_anchor,
Some(Anchor {
ts: DateTime::<Utc>::from_timestamp_millis(1_700_000_300_000)
.expect("valid timestamp"),
id: None,
})
);
let second_page = runtime
.list_threads(
1,
ThreadFilterOptions {
archived_only: false,
allowed_sources: &[],
model_providers: None,
cwd_filters: Some(cwd_filters.as_slice()),
anchor: first_page.next_anchor.as_ref(),
sort_key: SortKey::UpdatedAt,
sort_direction: SortDirection::Desc,
search_term: None,
},
)
.await
.expect("second page should succeed");
let ids = second_page
.items
.iter()
.map(|item| item.id)
.collect::<Vec<_>>();
assert_eq!(ids, vec![first_id]);
assert_eq!(second_page.next_anchor, None);
let page = runtime
.list_threads(
10,
ThreadFilterOptions {
archived_only: false,
allowed_sources: &[],
model_providers: None,
cwd_filters: Some(&[]),
anchor: None,
sort_key: SortKey::UpdatedAt,
sort_direction: SortDirection::Desc,
search_term: None,
},
)
.await
.expect("list with empty cwd filters should succeed");
assert_eq!(page.items, Vec::new());
}
#[tokio::test]
async fn list_threads_uses_indexes_matching_cwd_filters() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home, "test-provider".to_string())
.await
.expect("state db should initialize");
let model_providers = ["test-provider".to_string()];
let cwd_filters = [
PathBuf::from("/workspace/one"),
PathBuf::from("/workspace/two"),
];
let anchor = Anchor {
ts: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid timestamp"),
id: None,
};
for (sort_key, visible_index, cwd_index) in [
(
SortKey::CreatedAt,
"idx_threads_visible_created_at_ms",
"idx_threads_archived_cwd_created_at_ms",
),
(
SortKey::UpdatedAt,
"idx_threads_visible_updated_at_ms",
"idx_threads_archived_cwd_updated_at_ms",
),
(
SortKey::RecencyAt,
"idx_threads_visible_recency_at_ms",
"idx_threads_archived_cwd_recency_at_ms",
),
] {
for (cwd_filters, anchor, expected_index, expect_temp_sort) in [
(None, None, visible_index, false),
(Some(&cwd_filters[..1]), None, cwd_index, false),
(
Some(&cwd_filters[..]),
None,
"idx_threads_archived_cwd_",
true,
),
(Some(&cwd_filters[..]), Some(&anchor), cwd_index, true),
] {
let mut builder = QueryBuilder::<Sqlite>::new("EXPLAIN QUERY PLAN ");
push_list_threads_query(
&mut builder,
ThreadFilterOptions {
archived_only: false,
allowed_sources: &[],
model_providers: Some(&model_providers),
cwd_filters,
anchor,
sort_key,
sort_direction: SortDirection::Desc,
search_term: None,
},
None,
201,
);
let plan_details = builder
.build()
.fetch_all(runtime.pool.as_ref())
.await
.expect("query plan should load")
.into_iter()
.map(|row| row.get::<String, _>("detail"))
.collect::<Vec<_>>();
assert!(
plan_details
.iter()
.any(|detail| detail.contains(expected_index)),
"query plan did not use {expected_index}: {plan_details:?}"
);
assert_eq!(
plan_details
.iter()
.any(|detail| detail.contains("TEMP B-TREE")),
expect_temp_sort,
"unexpected sorting plan: {plan_details:?}"
);
}
}
}
#[tokio::test]
async fn list_threads_by_relation_filters_spawn_graph_with_keyset_pagination() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let parent_id = ThreadId::new();
let first_child_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread id");
let second_child_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread id");
let grandchild_id = ThreadId::new();
for (thread_id, created_at) in [
(parent_id, 1_700_000_000),
(first_child_id, 1_700_000_200),
(second_child_id, 1_700_000_200),
(grandchild_id, 1_700_000_300),
] {
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
metadata.created_at =
DateTime::<Utc>::from_timestamp(created_at, 0).expect("valid timestamp");
metadata.updated_at = metadata.created_at;
runtime
.upsert_thread(&metadata)
.await
.expect("thread insert should succeed");
}
for (parent_thread_id, child_thread_id, status) in [
(
parent_id,
first_child_id,
DirectionalThreadSpawnEdgeStatus::Open,
),
(
parent_id,
second_child_id,
DirectionalThreadSpawnEdgeStatus::Closed,
),
(
first_child_id,
grandchild_id,
DirectionalThreadSpawnEdgeStatus::Open,
),
] {
runtime
.upsert_thread_spawn_edge(parent_thread_id, child_thread_id, status)
.await
.expect("spawn edge insert should succeed");
}
let mut builder = QueryBuilder::<Sqlite>::new("EXPLAIN QUERY PLAN ");
push_list_threads_query(
&mut builder,
ThreadFilterOptions {
archived_only: false,
allowed_sources: &[],
model_providers: None,
cwd_filters: None,
anchor: None,
sort_key: SortKey::CreatedAt,
sort_direction: SortDirection::Desc,
search_term: None,
},
Some(crate::ThreadRelationFilter::DescendantsOf(parent_id)),
10,
);
let plan_details = builder
.build()
.fetch_all(runtime.pool.as_ref())
.await
.expect("relationship query plan should load")
.into_iter()
.map(|row| row.get::<String, _>("detail"))
.collect::<Vec<_>>();
assert!(
plan_details
.iter()
.any(|detail| detail.contains("idx_thread_spawn_edges_parent_status")),
"spawn relationship query did not use the parent index: {plan_details:?}"
);
let filters = |anchor| ThreadFilterOptions {
archived_only: false,
allowed_sources: &[],
model_providers: None,
cwd_filters: None,
anchor,
sort_key: SortKey::CreatedAt,
sort_direction: SortDirection::Desc,
search_term: None,
};
let first_page = runtime
.list_threads_by_parent( 1, parent_id, filters(None))
.await
.expect("first page should succeed");
let second_page = runtime
.list_threads_by_parent(
1,
parent_id,
filters(first_page.next_anchor.as_ref()),
)
.await
.expect("second page should succeed");
assert_eq!(
first_page
.items
.iter()
.map(|item| item.id)
.collect::<Vec<_>>(),
vec![second_child_id]
);
assert_eq!(
second_page
.items
.iter()
.map(|item| item.id)
.collect::<Vec<_>>(),
vec![first_child_id]
);
assert_eq!(second_page.next_anchor, None);
let first_descendant_page = runtime
.list_threads_by_relation(
2,
crate::ThreadRelationFilter::DescendantsOf(parent_id),
filters(None),
)
.await
.expect("first descendant page should succeed");
let second_descendant_page = runtime
.list_threads_by_relation(
2,
crate::ThreadRelationFilter::DescendantsOf(parent_id),
filters(first_descendant_page.next_anchor.as_ref()),
)
.await
.expect("second descendant page should succeed");
assert_eq!(
(
first_descendant_page
.items
.iter()
.map(|item| item.id)
.collect::<Vec<_>>(),
second_descendant_page
.items
.iter()
.map(|item| item.id)
.collect::<Vec<_>>(),
first_descendant_page.parent_thread_ids,
second_descendant_page.parent_thread_ids,
second_descendant_page.next_anchor,
),
(
vec![grandchild_id, second_child_id],
vec![first_child_id],
[
(grandchild_id, first_child_id),
(second_child_id, parent_id)
]
.into(),
[(first_child_id, parent_id)].into(),
None,
)
);
runtime
.upsert_thread_spawn_edge(
grandchild_id,
parent_id,
DirectionalThreadSpawnEdgeStatus::Open,
)
.await
.expect("cycle-closing spawn edge insert should succeed");
let cyclic_descendants = runtime
.list_threads_by_relation(
10,
crate::ThreadRelationFilter::DescendantsOf(parent_id),
filters(None),
)
.await
.expect("cyclic descendant graph should terminate");
assert_eq!(
cyclic_descendants
.items
.iter()
.map(|item| item.id)
.collect::<Vec<_>>(),
vec![grandchild_id, second_child_id, first_child_id]
);
}
#[tokio::test]
async fn apply_rollout_items_restores_memory_mode_from_session_meta() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000456").expect("valid thread id");
let metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
runtime
.upsert_thread(&metadata)
.await
.expect("initial upsert should succeed");
let builder = ThreadMetadataBuilder::new(
thread_id,
metadata.rollout_path.clone(),
metadata.created_at,
SessionSource::Cli,
);
let items = vec![RolloutItem::SessionMeta(SessionMetaLine {
meta: SessionMeta {
session_id: thread_id.into(),
id: thread_id,
forked_from_id: None,
parent_thread_id: None,
timestamp: metadata.created_at.to_rfc3339(),
cwd: PathBuf::new(),
originator: String::new(),
cli_version: String::new(),
source: SessionSource::Cli,
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
model_provider: None,
base_instructions: None,
dynamic_tools: None,
selected_capability_roots: Vec::new(),
memory_mode: Some("polluted".to_string()),
history_mode: Default::default(),
history_base: None,
subagent_history_start_ordinal: None,
multi_agent_version: None,
context_window: None,
},
git: None,
})];
runtime
.apply_rollout_items(
&builder, &items, None,
None,
)
.await
.expect("apply_rollout_items should succeed");
let memory_mode = runtime
.get_thread_memory_mode(thread_id)
.await
.expect("memory mode should load");
assert_eq!(memory_mode.as_deref(), Some("polluted"));
}
#[tokio::test]
async fn apply_rollout_items_preserves_existing_git_branch_and_fills_missing_git_fields() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000457").expect("valid thread id");
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
metadata.git_branch = Some("sqlite-branch".to_string());
runtime
.upsert_thread(&metadata)
.await
.expect("initial upsert should succeed");
let created_at = metadata.created_at.to_rfc3339();
let builder = ThreadMetadataBuilder::new(
thread_id,
metadata.rollout_path.clone(),
metadata.created_at,
SessionSource::Cli,
);
let items = vec![RolloutItem::SessionMeta(SessionMetaLine {
meta: SessionMeta {
session_id: thread_id.into(),
id: thread_id,
forked_from_id: None,
parent_thread_id: None,
timestamp: created_at,
cwd: PathBuf::new(),
originator: String::new(),
cli_version: String::new(),
source: SessionSource::Cli,
thread_source: None,
agent_path: None,
agent_nickname: None,
agent_role: None,
model_provider: None,
base_instructions: None,
dynamic_tools: None,
selected_capability_roots: Vec::new(),
memory_mode: None,
history_mode: Default::default(),
history_base: None,
subagent_history_start_ordinal: None,
multi_agent_version: None,
context_window: None,
},
git: Some(GitInfo {
commit_hash: Some(codex_git_utils::GitSha::new("rollout-sha")),
branch: Some("rollout-branch".to_string()),
repository_url: Some("git@example.com:openai/codex.git".to_string()),
}),
})];
runtime
.apply_rollout_items(
&builder, &items, None,
None,
)
.await
.expect("apply_rollout_items should succeed");
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.git_sha.as_deref(), Some("rollout-sha"));
assert_eq!(persisted.git_branch.as_deref(), Some("sqlite-branch"));
assert_eq!(
persisted.git_origin_url.as_deref(),
Some("git@example.com:openai/codex.git")
);
}
#[tokio::test]
async fn upsert_thread_preserves_existing_git_fields_atomically() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000458").expect("valid thread id");
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
metadata.git_sha = Some("sqlite-sha".to_string());
metadata.git_branch = Some("sqlite-branch".to_string());
metadata.git_origin_url = Some("git@example.com:openai/codex.git".to_string());
runtime
.upsert_thread(&metadata)
.await
.expect("initial upsert should succeed");
let mut rollout_metadata = metadata.clone();
rollout_metadata.git_sha = Some("rollout-sha".to_string());
rollout_metadata.git_branch = Some("rollout-branch".to_string());
rollout_metadata.git_origin_url = Some("https://example.com/repo.git".to_string());
runtime
.upsert_thread(&rollout_metadata)
.await
.expect("rollout upsert should succeed");
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.git_sha.as_deref(), Some("sqlite-sha"));
assert_eq!(persisted.git_branch.as_deref(), Some("sqlite-branch"));
assert_eq!(
persisted.git_origin_url.as_deref(),
Some("git@example.com:openai/codex.git")
);
}
#[tokio::test]
async fn upsert_thread_preserves_existing_preview_when_incoming_preview_is_empty() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000459").expect("valid thread id");
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
metadata.first_user_message = None;
metadata.preview = Some("migrated goal preview".to_string());
runtime
.upsert_thread(&metadata)
.await
.expect("initial upsert should succeed");
let mut rollout_metadata = metadata.clone();
rollout_metadata.preview = None;
runtime
.upsert_thread(&rollout_metadata)
.await
.expect("rollout upsert should succeed");
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.preview.as_deref(), Some("migrated goal preview"));
}
#[tokio::test]
async fn set_thread_preview_if_empty_only_fills_blank_preview() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000460").expect("valid thread id");
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
metadata.first_user_message = None;
metadata.preview = None;
runtime
.upsert_thread(&metadata)
.await
.expect("initial upsert should succeed");
let empty_updated = runtime
.set_thread_preview_if_empty(thread_id, " ")
.await
.expect("empty preview update should succeed");
assert!(!empty_updated);
let goal_updated = runtime
.set_thread_preview_if_empty(thread_id, " goal preview ")
.await
.expect("goal preview update should succeed");
assert!(goal_updated);
let overwrite_updated = runtime
.set_thread_preview_if_empty(thread_id, "new preview")
.await
.expect("overwrite preview update should succeed");
assert!(!overwrite_updated);
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.preview.as_deref(), Some("goal preview"));
}
#[tokio::test]
async fn update_thread_git_info_preserves_newer_non_git_metadata() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000789").expect("valid thread id");
let metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
runtime
.upsert_thread(&metadata)
.await
.expect("initial upsert should succeed");
let updated_at = datetime_to_epoch_millis(
DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp"),
);
sqlx::query(
"UPDATE threads SET updated_at = ?, updated_at_ms = ?, tokens_used = ?, first_user_message = ?, preview = ? WHERE id = ?",
)
.bind(updated_at / 1000)
.bind(updated_at)
.bind(123_i64)
.bind("newer preview")
.bind("newer preview")
.bind(thread_id.to_string())
.execute(runtime.pool.as_ref())
.await
.expect("concurrent metadata write should succeed");
let updated = runtime
.update_thread_git_info(
thread_id,
Some(Some("abc123")),
Some(Some("feature/branch")),
Some(Some("git@example.com:openai/codex.git")),
)
.await
.expect("git info update should succeed");
assert!(updated, "git info update should touch the thread row");
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.tokens_used, 123);
assert_eq!(
persisted.first_user_message.as_deref(),
Some("newer preview")
);
assert_eq!(persisted.preview.as_deref(), Some("newer preview"));
assert_eq!(datetime_to_epoch_millis(persisted.updated_at), updated_at);
assert_eq!(persisted.git_sha.as_deref(), Some("abc123"));
assert_eq!(persisted.git_branch.as_deref(), Some("feature/branch"));
assert_eq!(
persisted.git_origin_url.as_deref(),
Some("git@example.com:openai/codex.git")
);
}
#[tokio::test]
async fn insert_thread_if_absent_preserves_existing_metadata() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000791").expect("valid thread id");
let mut existing = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
existing.tokens_used = 123;
existing.first_user_message = Some("newer preview".to_string());
existing.preview = Some("newer preview".to_string());
existing.updated_at = DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp");
runtime
.upsert_thread(&existing)
.await
.expect("initial upsert should succeed");
let mut fallback = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
fallback.tokens_used = 0;
fallback.first_user_message = None;
fallback.preview = None;
fallback.updated_at = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("timestamp");
let inserted = runtime
.insert_thread_if_absent(&fallback)
.await
.expect("insert should succeed");
assert!(!inserted, "existing rows should not be overwritten");
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.tokens_used, 123);
assert_eq!(
persisted.first_user_message.as_deref(),
Some("newer preview")
);
assert_eq!(persisted.preview.as_deref(), Some("newer preview"));
assert_eq!(
datetime_to_epoch_millis(persisted.updated_at),
datetime_to_epoch_millis(existing.updated_at)
);
}
#[tokio::test]
async fn update_thread_git_info_can_clear_fields() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000790").expect("valid thread id");
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
metadata.git_sha = Some("abc123".to_string());
metadata.git_branch = Some("feature/branch".to_string());
metadata.git_origin_url = Some("git@example.com:openai/codex.git".to_string());
runtime
.upsert_thread(&metadata)
.await
.expect("initial upsert should succeed");
let updated = runtime
.update_thread_git_info(thread_id, Some(None), Some(None), Some(None))
.await
.expect("git info clear should succeed");
assert!(updated, "git info clear should touch the thread row");
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.git_sha, None);
assert_eq!(persisted.git_branch, None);
assert_eq!(persisted.git_origin_url, None);
}
#[tokio::test]
async fn touch_thread_updated_at_updates_only_updated_at() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000791").expect("valid thread id");
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
metadata.title = "original title".to_string();
metadata.first_user_message = Some("first-user-message".to_string());
metadata.preview = None;
runtime
.upsert_thread(&metadata)
.await
.expect("initial upsert should succeed");
let touched_at = DateTime::<Utc>::from_timestamp(1_700_001_111, 0).expect("timestamp");
let touched = runtime
.touch_thread_updated_at(thread_id, touched_at)
.await
.expect("touch should succeed");
assert!(touched);
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.updated_at, touched_at);
assert_eq!(persisted.title, "original title");
assert_eq!(
persisted.first_user_message.as_deref(),
Some("first-user-message")
);
assert_eq!(persisted.preview.as_deref(), Some("first-user-message"));
}
#[tokio::test]
async fn touch_thread_recency_at_is_monotonic_and_survives_stale_upsert() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000792").expect("valid thread id");
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
let original_recency_at = metadata.recency_at;
runtime
.upsert_thread(&metadata)
.await
.expect("initial upsert should succeed");
let touched_at =
DateTime::<Utc>::from_timestamp_millis(1_700_001_111_123).expect("timestamp");
assert!(
runtime
.touch_thread_recency_at(thread_id, touched_at)
.await
.expect("touch should succeed")
);
metadata.updated_at =
DateTime::<Utc>::from_timestamp_millis(1_700_001_222_456).expect("timestamp");
metadata.title = "updated metadata".to_string();
assert_eq!(metadata.recency_at, original_recency_at);
runtime
.upsert_thread(&metadata)
.await
.expect("stale metadata upsert should succeed");
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.recency_at, touched_at);
assert_eq!(persisted.updated_at, metadata.updated_at);
assert_eq!(persisted.title, "updated metadata");
assert!(
runtime
.touch_thread_recency_at(thread_id, original_recency_at)
.await
.expect("older touch should succeed")
);
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(
datetime_to_epoch_millis(persisted.recency_at),
datetime_to_epoch_millis(touched_at) + 1
);
}
#[tokio::test]
async fn list_threads_orders_and_pages_by_recency_at() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let first_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000793").expect("valid thread id");
let second_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000794").expect("valid thread id");
let third_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000795").expect("valid thread id");
let recency_at =
DateTime::<Utc>::from_timestamp_millis(1_700_002_000_456).expect("timestamp");
for thread_id in [first_id, second_id, third_id] {
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
metadata.recency_at = recency_at;
runtime
.upsert_thread(&metadata)
.await
.expect("thread insert should succeed");
}
sqlx::query("UPDATE threads SET recency_at = ?, recency_at_ms = ?")
.bind(datetime_to_epoch_seconds(recency_at))
.bind(datetime_to_epoch_millis(recency_at))
.execute(runtime.pool.as_ref())
.await
.expect("recency timestamps should match");
let first_page = runtime
.list_threads(
1,
ThreadFilterOptions {
archived_only: false,
allowed_sources: &[],
model_providers: None,
cwd_filters: None,
anchor: None,
sort_key: SortKey::RecencyAt,
sort_direction: SortDirection::Desc,
search_term: None,
},
)
.await
.expect("list should succeed");
assert_eq!(
first_page
.items
.iter()
.map(|item| item.id)
.collect::<Vec<_>>(),
vec![third_id]
);
assert_eq!(
first_page.next_anchor,
Some(Anchor {
ts: recency_at,
id: Some(third_id),
})
);
let second_page = runtime
.list_threads(
1,
ThreadFilterOptions {
archived_only: false,
allowed_sources: &[],
model_providers: None,
cwd_filters: None,
anchor: first_page.next_anchor.as_ref(),
sort_key: SortKey::RecencyAt,
sort_direction: SortDirection::Desc,
search_term: None,
},
)
.await
.expect("second list should succeed");
assert_eq!(
second_page
.items
.iter()
.map(|item| item.id)
.collect::<Vec<_>>(),
vec![second_id]
);
assert_eq!(
second_page.next_anchor,
Some(Anchor {
ts: recency_at,
id: Some(second_id),
})
);
let third_page = runtime
.list_threads(
1,
ThreadFilterOptions {
archived_only: false,
allowed_sources: &[],
model_providers: None,
cwd_filters: None,
anchor: second_page.next_anchor.as_ref(),
sort_key: SortKey::RecencyAt,
sort_direction: SortDirection::Desc,
search_term: None,
},
)
.await
.expect("third list should succeed");
assert_eq!(
third_page
.items
.iter()
.map(|item| item.id)
.collect::<Vec<_>>(),
vec![first_id]
);
assert_eq!(third_page.next_anchor, None);
}
#[tokio::test]
async fn thread_updated_at_uses_unique_epoch_millis_and_reads_legacy_seconds() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let first_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000901").expect("valid thread id");
let second_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000902").expect("valid thread id");
let older_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000903").expect("valid thread id");
let updated_at =
DateTime::<Utc>::from_timestamp_millis(1_700_001_111_123).expect("timestamp millis");
let mut first = test_thread_metadata(&codex_home, first_id, codex_home.clone());
first.updated_at = updated_at;
first.recency_at = updated_at;
let mut second = test_thread_metadata(&codex_home, second_id, codex_home.clone());
second.updated_at = updated_at;
second.recency_at = updated_at;
runtime
.upsert_thread(&first)
.await
.expect("first upsert should succeed");
runtime
.upsert_thread(&second)
.await
.expect("second upsert should succeed");
let first = runtime
.get_thread(first_id)
.await
.expect("thread should load")
.expect("thread should exist");
let second = runtime
.get_thread(second_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(
datetime_to_epoch_millis(first.updated_at),
1_700_001_111_123
);
assert_eq!(
datetime_to_epoch_millis(second.updated_at),
1_700_001_111_124
);
assert_eq!(
datetime_to_epoch_millis(first.recency_at),
1_700_001_111_123
);
assert_eq!(
datetime_to_epoch_millis(second.recency_at),
1_700_001_111_124
);
let second_row: (i64, i64, Option<i64>, Option<i64>) = sqlx::query_as(
"SELECT created_at, updated_at, created_at_ms, updated_at_ms FROM threads WHERE id = ?",
)
.bind(second_id.to_string())
.fetch_one(runtime.pool.as_ref())
.await
.expect("thread timestamp row should load");
assert_eq!(
second_row,
(
datetime_to_epoch_seconds(second.created_at),
1_700_001_111,
Some(datetime_to_epoch_millis(second.created_at)),
Some(1_700_001_111_124)
)
);
let older_updated_at =
DateTime::<Utc>::from_timestamp_millis(1_700_001_100_123).expect("timestamp millis");
let mut older = test_thread_metadata(&codex_home, older_id, codex_home.clone());
older.updated_at = older_updated_at;
runtime
.upsert_thread(&older)
.await
.expect("older upsert should succeed");
let older = runtime
.get_thread(older_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(
datetime_to_epoch_millis(older.updated_at),
1_700_001_100_123
);
sqlx::query("UPDATE threads SET updated_at = ? WHERE id = ?")
.bind(1_700_001_112_i64)
.bind(first_id.to_string())
.execute(runtime.pool.as_ref())
.await
.expect("legacy timestamp write should succeed");
let legacy = runtime
.get_thread(first_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(
datetime_to_epoch_millis(legacy.updated_at),
1_700_001_112_000
);
}
#[tokio::test]
async fn apply_rollout_items_uses_override_updated_at_when_provided() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000792").expect("valid thread id");
let metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
runtime
.upsert_thread(&metadata)
.await
.expect("initial upsert should succeed");
let builder = ThreadMetadataBuilder::new(
thread_id,
metadata.rollout_path.clone(),
metadata.created_at,
SessionSource::Cli,
);
let items = vec![RolloutItem::EventMsg(EventMsg::TokenCount(
codex_protocol::protocol::TokenCountEvent {
info: Some(codex_protocol::protocol::TokenUsageInfo {
total_token_usage: codex_protocol::protocol::TokenUsage {
input_tokens: 0,
cached_input_tokens: 0,
cache_write_input_tokens: 0,
output_tokens: 0,
reasoning_output_tokens: 0,
total_tokens: 321,
},
last_token_usage: codex_protocol::protocol::TokenUsage::default(),
model_context_window: None,
}),
rate_limits: None,
},
))];
let override_updated_at =
DateTime::<Utc>::from_timestamp(1_700_001_234, 0).expect("timestamp");
runtime
.apply_rollout_items(
&builder,
&items,
None,
Some(override_updated_at),
)
.await
.expect("apply_rollout_items should succeed");
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.tokens_used, 321);
assert_eq!(persisted.updated_at, override_updated_at);
}
#[tokio::test]
async fn thread_spawn_edges_track_directional_status() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home, "test-provider".to_string())
.await
.expect("state db should initialize");
let parent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000900").expect("valid thread id");
let child_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000901").expect("valid thread id");
let grandchild_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000902").expect("valid thread id");
runtime
.upsert_thread_spawn_edge(
parent_thread_id,
child_thread_id,
DirectionalThreadSpawnEdgeStatus::Open,
)
.await
.expect("child edge insert should succeed");
runtime
.upsert_thread_spawn_edge(
child_thread_id,
grandchild_thread_id,
DirectionalThreadSpawnEdgeStatus::Open,
)
.await
.expect("grandchild edge insert should succeed");
let children = runtime
.list_thread_spawn_children_with_status(
parent_thread_id,
DirectionalThreadSpawnEdgeStatus::Open,
)
.await
.expect("open child list should load");
assert_eq!(children, vec![child_thread_id]);
let descendants = runtime
.list_thread_spawn_descendants_with_status(
parent_thread_id,
DirectionalThreadSpawnEdgeStatus::Open,
)
.await
.expect("open descendants should load");
assert_eq!(descendants, vec![child_thread_id, grandchild_thread_id]);
runtime
.set_thread_spawn_edge_status(child_thread_id, DirectionalThreadSpawnEdgeStatus::Closed)
.await
.expect("edge close should succeed");
let open_children = runtime
.list_thread_spawn_children_with_status(
parent_thread_id,
DirectionalThreadSpawnEdgeStatus::Open,
)
.await
.expect("open child list should load");
assert_eq!(open_children, Vec::<ThreadId>::new());
let closed_children = runtime
.list_thread_spawn_children_with_status(
parent_thread_id,
DirectionalThreadSpawnEdgeStatus::Closed,
)
.await
.expect("closed child list should load");
assert_eq!(closed_children, vec![child_thread_id]);
let closed_descendants = runtime
.list_thread_spawn_descendants_with_status(
parent_thread_id,
DirectionalThreadSpawnEdgeStatus::Closed,
)
.await
.expect("closed descendants should load");
assert_eq!(closed_descendants, vec![child_thread_id]);
let open_descendants_from_child = runtime
.list_thread_spawn_descendants_with_status(
child_thread_id,
DirectionalThreadSpawnEdgeStatus::Open,
)
.await
.expect("open descendants from child should load");
assert_eq!(open_descendants_from_child, vec![grandchild_thread_id]);
let all_descendants = runtime
.list_thread_spawn_descendants(parent_thread_id)
.await
.expect("all descendants should load");
assert_eq!(all_descendants, vec![child_thread_id, grandchild_thread_id]);
}
#[tokio::test]
async fn thread_spawn_children_without_status_filter_lists_all_statuses() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home, "test-provider".to_string())
.await
.expect("state db should initialize");
let parent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000910").expect("valid thread id");
let open_child_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000911").expect("valid thread id");
let closed_child_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000912").expect("valid thread id");
let future_child_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000913").expect("valid thread id");
runtime
.upsert_thread_spawn_edge(
parent_thread_id,
open_child_thread_id,
DirectionalThreadSpawnEdgeStatus::Open,
)
.await
.expect("open child edge insert should succeed");
runtime
.upsert_thread_spawn_edge(
parent_thread_id,
closed_child_thread_id,
DirectionalThreadSpawnEdgeStatus::Closed,
)
.await
.expect("closed child edge insert should succeed");
sqlx::query(
r#"
INSERT INTO thread_spawn_edges (
parent_thread_id,
child_thread_id,
status
) VALUES (?, ?, ?)
"#,
)
.bind(parent_thread_id.to_string())
.bind(future_child_thread_id.to_string())
.bind("future")
.execute(runtime.pool.as_ref())
.await
.expect("future-status child edge insert should succeed");
let children = runtime
.list_thread_spawn_children(parent_thread_id)
.await
.expect("all children should load");
assert_eq!(
children,
vec![
open_child_thread_id,
closed_child_thread_id,
future_child_thread_id,
]
);
}
}