Encapsulate thread metadata construction (#60524)

## What changed

Move `ThreadMetadataBuilder` into its own module, make its fields private, and replace direct field assignments with fluent `with_*` methods. Initialize `updated_at` and `recency_at` from the creation time and default the approval mode to `OnRequest`.

Update `apply_rollout_items` to accept a boxed `ThreadMetadata` seed, keeping callers' async frames small while preserving existing database fields except for the seed rollout path. Add `build_thread_metadata` to use the state runtime's configured provider, and use `thread_id_from_rollout_path` directly for the doctor's filename fallback.

GitOrigin-RevId: a27e4c6b22832e902922dbdd60d7f5c11b4a1a83
This commit is contained in:
ioanbudea-OAI
2026-11-10 04:56:49 +00:00
committed by copyberry
parent b514f638aa
commit ab541fb990
27 changed files with 426 additions and 363 deletions
@@ -335,15 +335,15 @@ mod tests {
.mark_backfill_complete(/*last_watermark*/ None)
.await
.expect("backfill should be complete");
let mut builder = codex_state::ThreadMetadataBuilder::new(
let builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
selected_rollout_path,
Utc::now(),
SessionSource::Cli,
);
builder.model_provider = Some(config.default_model_provider_id.clone());
builder.cwd = home.path().to_path_buf();
builder.cli_version = Some("test_version".to_string());
)
.with_model_provider(Some(config.default_model_provider_id.clone()))
.with_cwd(home.path().to_path_buf())
.with_cli_version("test_version".to_string());
let metadata = builder.build(config.default_model_provider_id.as_str());
runtime
.upsert_thread(&metadata)
+11 -11
View File
@@ -426,15 +426,15 @@ mod tests {
.await
.expect("backfill should be complete");
let created_at = Utc::now();
let mut builder = codex_state::ThreadMetadataBuilder::new(
let builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
rollout_path,
created_at,
SessionSource::Cli,
);
builder.model_provider = Some(config.default_model_provider_id.clone());
builder.cwd = home.path().to_path_buf();
builder.cli_version = Some("test_version".to_string());
)
.with_model_provider(Some(config.default_model_provider_id.clone()))
.with_cwd(home.path().to_path_buf())
.with_cli_version("test_version".to_string());
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.title = "needle title".to_string();
metadata.first_user_message = Some("plain preview".to_string());
@@ -495,16 +495,16 @@ mod tests {
.mark_backfill_complete(/*last_watermark*/ None)
.await
.expect("backfill should be complete");
let mut builder = codex_state::ThreadMetadataBuilder::new(
let builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
rollout_path,
Utc::now(),
SessionSource::Cli,
);
builder.history_mode = ThreadHistoryMode::Paginated;
builder.model_provider = Some(config.default_model_provider_id.clone());
builder.cwd = home.path().to_path_buf();
builder.cli_version = Some("test_version".to_string());
)
.with_history_mode(ThreadHistoryMode::Paginated)
.with_model_provider(Some(config.default_model_provider_id.clone()))
.with_cwd(home.path().to_path_buf())
.with_cli_version("test_version".to_string());
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.name = Some("canonical paginated name".to_string());
metadata.title = "stale title name".to_string();
@@ -279,17 +279,18 @@ async fn index_attached_thread(
.map_err(thread_store_io_error)?;
let memory_mode = meta.meta.memory_mode.clone();
let items = [RolloutItem::SessionMeta(Box::new(meta))];
let mut builder = codex_rollout::builder_from_items(&items, path).ok_or_else(|| {
let builder = codex_rollout::builder_from_items(&items, path).ok_or_else(|| {
ThreadStoreError::Internal {
message: format!("invalid attached thread metadata at {}", path.display()),
}
})?;
let mut metadata = Box::new(state.build_thread_metadata(&builder));
if super::helpers::rollout_path_is_archived(&store.config.codex_home, path) {
builder.archived_at = Some(builder.updated_at.unwrap_or(builder.created_at));
metadata.archived_at = Some(metadata.created_at);
}
state
.apply_rollout_items(
&builder,
metadata,
&items,
memory_mode.as_deref(),
/*updated_at_override*/ None,
+8 -8
View File
@@ -1072,14 +1072,14 @@ mod tests {
tokio::fs::write(&stale_rollout_path, "malformed session metadata\n")
.await
.expect("write stale rollout");
let mut builder = codex_state::ThreadMetadataBuilder::new(
let builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
stale_rollout_path,
chrono::Utc::now(),
SessionSource::Cli,
);
builder.history_mode = ThreadHistoryMode::Paginated;
builder.cwd = home.path().to_path_buf();
)
.with_history_mode(ThreadHistoryMode::Paginated)
.with_cwd(home.path().to_path_buf());
let mut metadata = builder.build("test-provider");
metadata.preview = Some("original user message".to_string());
metadata.first_user_message = Some("original user message".to_string());
@@ -1784,14 +1784,14 @@ mod tests {
.await
.expect("shutdown initial writer");
let mut builder = codex_state::ThreadMetadataBuilder::new(
let builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
home.path().join("missing-rollout.jsonl"),
chrono::Utc::now(),
SessionSource::Exec,
);
builder.history_mode = ThreadHistoryMode::Legacy;
builder.cwd = home.path().to_path_buf();
)
.with_history_mode(ThreadHistoryMode::Legacy)
.with_cwd(home.path().to_path_buf());
let metadata = builder.build("test-provider");
runtime
.upsert_thread(&metadata)
+48 -48
View File
@@ -723,19 +723,19 @@ mod tests {
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder = ThreadMetadataBuilder::new(
let builder = ThreadMetadataBuilder::new(
thread_id,
active_path.clone(),
Utc::now(),
SessionSource::Cli,
);
builder.model_provider = Some(config.default_model_provider_id.clone());
builder.git_branch = Some("sqlite-branch".to_string());
)
.with_model_provider(Some(config.default_model_provider_id.clone()))
.with_git_branch(Some("sqlite-branch".to_string()));
let recency_at = chrono::DateTime::parse_from_rfc3339("2026-01-03T12:00:00Z")
.expect("timestamp should parse")
.with_timezone(&Utc);
builder.recency_at = Some(recency_at);
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.recency_at = recency_at;
metadata.title = "Stale SQLite name".to_string();
metadata.section = Some(codex_state::ThreadSection {
id: codex_state::PINNED_THREAD_SECTION_ID.to_string(),
@@ -895,11 +895,11 @@ mod tests {
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder =
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli);
builder.model_provider = Some(config.default_model_provider_id.clone());
builder.cwd = home.path().to_path_buf();
builder.cli_version = Some("test_version".to_string());
let builder =
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli)
.with_model_provider(Some(config.default_model_provider_id.clone()))
.with_cwd(home.path().to_path_buf())
.with_cli_version("test_version".to_string());
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.title = "Saved title".to_string();
metadata.first_user_message = Some("Hello from user".to_string());
@@ -956,15 +956,15 @@ mod tests {
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder = ThreadMetadataBuilder::new(
let builder = ThreadMetadataBuilder::new(
thread_id,
rollout_path.clone(),
Utc::now(),
SessionSource::Cli,
);
builder.history_mode = ThreadHistoryMode::Paginated;
builder.model_provider = Some("Canonical SQLite provider".to_string());
builder.cwd = home.path().to_path_buf();
)
.with_history_mode(ThreadHistoryMode::Paginated)
.with_model_provider(Some("Canonical SQLite provider".to_string()))
.with_cwd(home.path().to_path_buf());
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.name = Some("Canonical SQLite name".to_string());
metadata.preview = Some("Canonical SQLite preview".to_string());
@@ -1033,10 +1033,10 @@ mod tests {
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder =
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli);
builder.model_provider = Some(config.default_model_provider_id.clone());
builder.cwd = home.path().to_path_buf();
let builder =
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli)
.with_model_provider(Some(config.default_model_provider_id.clone()))
.with_cwd(home.path().to_path_buf());
let mut metadata = builder.build(config.default_model_provider_id.as_str());
for (value, expected) in [
(
@@ -1083,10 +1083,10 @@ mod tests {
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder =
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli);
builder.model_provider = Some(config.default_model_provider_id.clone());
builder.cwd = home.path().to_path_buf();
let builder =
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli)
.with_model_provider(Some(config.default_model_provider_id.clone()))
.with_cwd(home.path().to_path_buf());
let mut metadata = builder.build(config.default_model_provider_id.as_str());
for value in [
"danger-full-access".to_string(),
@@ -1156,15 +1156,15 @@ mod tests {
});
writeln!(file, "{user_event}").expect("write user event");
let mut builder = ThreadMetadataBuilder::new(
let builder = ThreadMetadataBuilder::new(
thread_id,
rollout_path.clone(),
Utc::now(),
SessionSource::Cli,
);
builder.model_provider = Some(config.default_model_provider_id.clone());
)
.with_model_provider(Some(config.default_model_provider_id.clone()));
let sqlite_cwd = home.path().join("sqlite-workspace");
builder.cwd = sqlite_cwd.clone();
let builder = builder.with_cwd(sqlite_cwd.clone());
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.title = "Saved title".to_string();
metadata.first_user_message = Some("Hello from sqlite".to_string());
@@ -1321,15 +1321,15 @@ mod tests {
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder = ThreadMetadataBuilder::new(
let builder = ThreadMetadataBuilder::new(
thread_id,
rollout_path.clone(),
Utc::now(),
SessionSource::Cli,
);
builder.model_provider = Some("sqlite-provider".to_string());
builder.cwd = home.path().join("workspace");
builder.cli_version = Some("sqlite-cli".to_string());
)
.with_model_provider(Some("sqlite-provider".to_string()))
.with_cwd(home.path().join("workspace"))
.with_cli_version("sqlite-cli".to_string());
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.title = "Command-only thread".to_string();
runtime
@@ -1394,13 +1394,13 @@ mod tests {
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder = ThreadMetadataBuilder::new(
let builder = ThreadMetadataBuilder::new(
thread_id,
stale_path.clone(),
Utc::now(),
SessionSource::Cli,
);
builder.model_provider = Some("stale-sqlite-provider".to_string());
)
.with_model_provider(Some("stale-sqlite-provider".to_string()));
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.first_user_message = Some("stale sqlite preview".to_string());
metadata.model = Some("persisted-model".to_string());
@@ -1451,9 +1451,9 @@ mod tests {
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder =
ThreadMetadataBuilder::new(thread_id, stale_path, Utc::now(), SessionSource::Cli);
builder.model_provider = Some("wrong-sqlite-provider".to_string());
let builder =
ThreadMetadataBuilder::new(thread_id, stale_path, Utc::now(), SessionSource::Cli)
.with_model_provider(Some("wrong-sqlite-provider".to_string()));
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.first_user_message = Some("wrong sqlite preview".to_string());
runtime
@@ -1550,15 +1550,15 @@ mod tests {
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder = ThreadMetadataBuilder::new(
let builder = ThreadMetadataBuilder::new(
thread_id,
rollout_path.clone(),
Utc::now(),
SessionSource::Exec,
);
builder.model_provider = Some("sqlite-provider".to_string());
builder.cwd = external.path().join("workspace");
builder.cli_version = Some("sqlite-cli".to_string());
)
.with_model_provider(Some("sqlite-provider".to_string()))
.with_cwd(external.path().join("workspace"))
.with_cli_version("sqlite-cli".to_string());
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.preview = Some("optimize the benchmark".to_string());
metadata.first_user_message = Some("next normal prompt".to_string());
@@ -1611,10 +1611,10 @@ mod tests {
)
.await
.expect("state db should initialize");
let mut builder =
let builder =
ThreadMetadataBuilder::new(thread_id, rollout_path, Utc::now(), SessionSource::Cli);
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
builder.archived_at = Some(Utc::now());
let builder = builder.with_archived_at(Some(Utc::now()));
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.first_user_message = Some("Archived SQLite preview".to_string());
runtime
@@ -1667,13 +1667,13 @@ mod tests {
.await
.expect("state db should initialize");
let store = LocalThreadStore::new(config.clone(), Some(runtime.clone()));
let mut builder = ThreadMetadataBuilder::new(
let builder = ThreadMetadataBuilder::new(
thread_id,
archived_path.clone(),
Utc::now(),
SessionSource::Cli,
);
builder.archived_at = Some(Utc::now());
)
.with_archived_at(Some(Utc::now()));
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.first_user_message = Some("Archived SQLite preview".to_string());
runtime
@@ -314,13 +314,13 @@ async fn indexed_ancestor_must_be_managed_and_owned_by_the_indexed_thread() {
let end = history_position(&active, root, /*end_ordinal_exclusive*/ 3);
write_rollout(&home, child, Some(end), /*next_ordinal*/ 2);
let assert_lookup = async |indexed: &Path, expected: &Path| {
let mut metadata = codex_state::ThreadMetadataBuilder::new(
let metadata = codex_state::ThreadMetadataBuilder::new(
root,
indexed.to_owned(),
chrono::Utc::now(),
codex_protocol::protocol::SessionSource::Cli,
);
metadata.history_mode = ThreadHistoryMode::Paginated;
)
.with_history_mode(ThreadHistoryMode::Paginated);
state
.upsert_thread(&metadata.build("test-provider"))
.await
@@ -1661,13 +1661,13 @@ async fn store_with_mode(history_mode: ThreadHistoryMode) -> (TempDir, LocalThre
)
.await
.expect("state runtime");
let mut builder = codex_state::ThreadMetadataBuilder::new(
let builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
rollout_path,
Utc::now(),
SessionSource::Cli,
);
builder.history_mode = history_mode;
)
.with_history_mode(history_mode);
runtime
.upsert_thread(&builder.build(config.default_model_provider_id.as_str()))
.await
@@ -1795,13 +1795,13 @@ async fn summary_items_use_final_answers_and_ignore_nonterminal_messages() {
)
.await
.expect("state runtime");
let mut builder = codex_state::ThreadMetadataBuilder::new(
let builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
home.path().join("missing-rollout.jsonl"),
Utc::now(),
SessionSource::Cli,
);
builder.history_mode = ThreadHistoryMode::Paginated;
)
.with_history_mode(ThreadHistoryMode::Paginated);
runtime
.upsert_thread(&builder.build(config.default_model_provider_id.as_str()))
.await
@@ -300,15 +300,15 @@ mod tests {
.mark_backfill_complete(/*last_watermark*/ None)
.await
.expect("backfill should be complete");
let mut builder = codex_state::ThreadMetadataBuilder::new(
let builder = codex_state::ThreadMetadataBuilder::new(
thread_id,
selected_archived_path,
Utc::now(),
SessionSource::Cli,
);
builder.model_provider = Some(config.default_model_provider_id.clone());
builder.cwd = home.path().to_path_buf();
builder.cli_version = Some("test_version".to_string());
)
.with_model_provider(Some(config.default_model_provider_id.clone()))
.with_cwd(home.path().to_path_buf())
.with_cli_version("test_version".to_string());
let mut metadata = builder.build(config.default_model_provider_id.as_str());
metadata.archived_at = Some(metadata.updated_at);
metadata.section = Some(codex_state::ThreadSection {
@@ -671,22 +671,23 @@ async fn metadata_for_missing_sqlite_row(
.created_at
.or(patch.updated_at)
.unwrap_or_else(Utc::now);
let mut builder = ThreadMetadataBuilder::new(
let builder = ThreadMetadataBuilder::new(
thread_id,
rollout_path.to_path_buf(),
created_at,
patch.source.clone().unwrap_or(SessionSource::Unknown),
);
builder.model_provider = patch.model_provider.clone();
)
.with_model_provider(patch.model_provider.clone());
let (history_mode, attached_thread_parent_id) =
canonical_history_mode_and_attachment(store, thread_id, rollout_path).await?;
builder.history_mode = history_mode;
builder.thread_source = patch.thread_source.clone().flatten();
builder.agent_nickname = patch.agent_nickname.clone().flatten();
builder.agent_role = patch.agent_role.clone().flatten();
builder.agent_path = patch.agent_path.clone().flatten();
builder.cwd = patch.cwd.clone().map(normalize_cwd).unwrap_or_default();
builder.cli_version = patch.cli_version.clone();
let builder = builder
.with_history_mode(history_mode)
.with_thread_source(patch.thread_source.clone().flatten())
.with_agent_nickname(patch.agent_nickname.clone().flatten())
.with_agent_role(patch.agent_role.clone().flatten())
.with_agent_path(patch.agent_path.clone().flatten())
.with_cwd(patch.cwd.clone().map(normalize_cwd).unwrap_or_default())
.with_cli_version(patch.cli_version.clone().unwrap_or_default());
let mut metadata = builder.build(store.config.default_model_provider_id.as_str());
metadata.attached_thread_parent_id = attached_thread_parent_id;
if rollout_path_archived {