diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 0da72526d7..af0f34c561 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -779,13 +779,13 @@ mod thread_processor_behavior_tests { reasoning_effort: Option, ) -> Result { let thread_id = ThreadId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0")?; - let mut builder = ThreadMetadataBuilder::new( + let builder = ThreadMetadataBuilder::new( thread_id, PathBuf::from("/tmp/rollout.jsonl"), Utc::now(), codex_protocol::protocol::SessionSource::default(), - ); - builder.model_provider = Some("mock_provider".to_string()); + ) + .with_model_provider(Some("mock_provider".to_string())); let mut metadata = builder.build("mock_provider"); metadata.model = model.map(ToString::to_string); metadata.reasoning_effort = reasoning_effort; diff --git a/codex-rs/app-server/tests/suite/v2/memory_reset.rs b/codex-rs/app-server/tests/suite/v2/memory_reset.rs index 5cfeb3c0ae..bc56cedf97 100644 --- a/codex-rs/app-server/tests/suite/v2/memory_reset.rs +++ b/codex-rs/app-server/tests/suite/v2/memory_reset.rs @@ -88,14 +88,13 @@ pub(super) async fn seed_stage1_output( let now = Utc::now(); let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; let worker_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; - let mut builder = ThreadMetadataBuilder::new( + let builder = ThreadMetadataBuilder::new( thread_id, codex_home.join("sessions").join("test.jsonl"), now, SessionSource::Cli, - ); - builder.updated_at = Some(now); - builder.cwd = codex_home.to_path_buf(); + ) + .with_cwd(codex_home.to_path_buf()); let metadata = builder.build("mock_provider"); state_db.upsert_thread(&metadata).await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 843ddb7363..1310e5af54 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1448,15 +1448,15 @@ async fn thread_list_relation_filters_read_spawn_graph_from_state_db() -> Result ), ] { let created_at = DateTime::parse_from_rfc3339(created_at)?.with_timezone(&Utc); - let mut builder = codex_state::ThreadMetadataBuilder::new( + let builder = codex_state::ThreadMetadataBuilder::new( thread_id, codex_home.path().join(format!("{thread_id}.jsonl")), created_at, source, - ); - builder.model_provider = Some(model_provider.to_string()); - builder.cwd = codex_home.path().to_path_buf(); - builder.cli_version = Some("0.0.0".to_string()); + ) + .with_model_provider(Some(model_provider.to_string())) + .with_cwd(codex_home.path().to_path_buf()) + .with_cli_version("0.0.0".to_string()); let mut metadata = builder.build(model_provider); metadata.preview = Some("child thread".to_string()); metadata.first_user_message = metadata.preview.clone(); diff --git a/codex-rs/cli/src/doctor/thread_inventory.rs b/codex-rs/cli/src/doctor/thread_inventory.rs index 46bd3bbf15..6a58c8be74 100644 --- a/codex-rs/cli/src/doctor/thread_inventory.rs +++ b/codex-rs/cli/src/doctor/thread_inventory.rs @@ -572,8 +572,8 @@ async fn thread_id_from_rollout(path: &Path) -> RolloutThreadId { // Legacy rollouts can omit session metadata, so use the validated filename fallback after // the bounded prefix without retaining the first item or loading the full history. let logical_path = codex_rollout::plain_rollout_path(path); - codex_rollout::builder_from_items(&[], &logical_path) - .map(|builder| RolloutThreadId::Id(builder.id.to_string())) + codex_rollout::thread_id_from_rollout_path(&logical_path) + .map(|id| RolloutThreadId::Id(id.to_string())) .unwrap_or(RolloutThreadId::MalformedName) } diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 883842746c..8cc7191826 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -254,15 +254,15 @@ async fn seed_recent_thread( // rollout path no longer exists, so create the placeholder path that the test metadata points // at without exercising rollout writing in this realtime-context test. std::fs::write(&rollout_path, "")?; - let mut metadata_builder = codex_state::ThreadMetadataBuilder::new( + let metadata_builder = codex_state::ThreadMetadataBuilder::new( thread_id, rollout_path, updated_at, SessionSource::Cli, - ); - metadata_builder.cwd = test.workspace_path(format!("workspace-{slug}")); - metadata_builder.model_provider = Some("test-provider".to_string()); - metadata_builder.git_branch = Some(format!("branch-{slug}")); + ) + .with_cwd(test.workspace_path(format!("workspace-{slug}"))) + .with_model_provider(Some("test-provider".to_string())) + .with_git_branch(Some(format!("branch-{slug}"))); let mut metadata = metadata_builder.build("test-provider"); metadata.title = title.to_string(); metadata.first_user_message = Some(first_user_message.to_string()); diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 771f370fc1..569106811a 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -77,13 +77,13 @@ async fn upsert_thread_metadata( .mark_backfill_complete(/*last_watermark*/ None) .await .unwrap(); - let mut builder = ThreadMetadataBuilder::new( + let builder = ThreadMetadataBuilder::new( thread_id, rollout_path, Utc::now(), SessionSource::default(), - ); - builder.cwd = codex_home.to_path_buf(); + ) + .with_cwd(codex_home.to_path_buf()); let metadata = builder.build("test-provider"); runtime.upsert_thread(&metadata).await.unwrap(); runtime diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index 0c8497d20e..598dfb0b86 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -1324,15 +1324,15 @@ async fn seed_stage1_output( rollout_slug: &str, ) -> anyhow::Result { let thread_id = ThreadId::new(); - let mut metadata_builder = codex_state::ThreadMetadataBuilder::new( + let metadata_builder = codex_state::ThreadMetadataBuilder::new( thread_id, codex_home.join(format!("rollout-{thread_id}.jsonl")), updated_at, SessionSource::Cli, - ); - metadata_builder.cwd = codex_home.join(format!("workspace-{rollout_slug}")); - metadata_builder.model_provider = Some("test-provider".to_string()); - metadata_builder.git_branch = Some(format!("branch-{rollout_slug}")); + ) + .with_cwd(codex_home.join(format!("workspace-{rollout_slug}"))) + .with_model_provider(Some("test-provider".to_string())) + .with_git_branch(Some(format!("branch-{rollout_slug}"))); let metadata = metadata_builder.build("test-provider"); db.upsert_thread(&metadata).await?; @@ -1389,15 +1389,15 @@ async fn seed_stage1_candidate( }); tokio::fs::write(&rollout_path, format!("{meta}\n{jsonl}\n")).await?; - let mut metadata_builder = codex_state::ThreadMetadataBuilder::new( + let metadata_builder = codex_state::ThreadMetadataBuilder::new( thread_id, rollout_path, updated_at, SessionSource::Cli, - ); - metadata_builder.cwd = codex_home.join(format!("workspace-{rollout_slug}")); - metadata_builder.model_provider = Some("test-provider".to_string()); - metadata_builder.git_branch = Some(format!("branch-{rollout_slug}")); + ) + .with_cwd(codex_home.join(format!("workspace-{rollout_slug}"))) + .with_model_provider(Some("test-provider".to_string())) + .with_git_branch(Some(format!("branch-{rollout_slug}"))); let mut metadata = metadata_builder.build("test-provider"); metadata.preview = Some("remember this startup test conversation".to_string()); metadata.first_user_message = metadata.preview.clone(); diff --git a/codex-rs/rollout/src/metadata.rs b/codex-rs/rollout/src/metadata.rs index 8282846590..a8d353e930 100644 --- a/codex-rs/rollout/src/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -40,28 +40,32 @@ pub(crate) fn builder_from_session_meta( rollout_path: &Path, ) -> Option { let created_at = parse_timestamp_to_utc(session_meta.meta.timestamp.as_str())?; - let mut builder = ThreadMetadataBuilder::new( + let builder = ThreadMetadataBuilder::new( session_meta.meta.id, rollout_path.to_path_buf(), created_at, session_meta.meta.source.clone(), - ); - builder.creator_user_id = session_meta.meta.creator_user_id.clone(); - builder.creator_account_id = session_meta.meta.creator_account_id.clone(); - builder.history_mode = session_meta.meta.history_mode; - builder.originator = - (!session_meta.meta.originator.is_empty()).then(|| session_meta.meta.originator.clone()); - builder.model_provider = session_meta.meta.model_provider.clone(); - builder.agent_nickname = session_meta.meta.agent_nickname.clone(); - builder.agent_role = session_meta.meta.agent_role.clone(); - builder.agent_path = session_meta.meta.agent_path.clone(); - builder.cwd = session_meta.meta.cwd.clone(); - builder.cli_version = Some(session_meta.meta.cli_version.clone()); - if let Some(git) = session_meta.git.as_ref() { - builder.git_sha = git.commit_hash.as_ref().map(|sha| sha.0.clone()); - builder.git_branch = git.branch.clone(); - builder.git_origin_url = git.repository_url.clone(); - } + ) + .with_creator_user_id(session_meta.meta.creator_user_id.clone()) + .with_creator_account_id(session_meta.meta.creator_account_id.clone()) + .with_history_mode(session_meta.meta.history_mode) + .with_originator( + (!session_meta.meta.originator.is_empty()).then(|| session_meta.meta.originator.clone()), + ) + .with_model_provider(session_meta.meta.model_provider.clone()) + .with_agent_nickname(session_meta.meta.agent_nickname.clone()) + .with_agent_role(session_meta.meta.agent_role.clone()) + .with_agent_path(session_meta.meta.agent_path.clone()) + .with_cwd(session_meta.meta.cwd.clone()) + .with_cli_version(session_meta.meta.cli_version.clone()); + let builder = if let Some(git) = session_meta.git.as_ref() { + builder + .with_git_sha(git.commit_hash.as_ref().map(|sha| sha.0.clone())) + .with_git_branch(git.branch.clone()) + .with_git_origin_url(git.repository_url.clone()) + } else { + builder + }; Some(builder) } diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index cdb2bfc35d..c3c27f6d2e 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -1441,14 +1441,14 @@ async fn list_threads_db_enabled_preserves_metadata_for_missing_rollout_paths() .with_ymd_and_hms(2025, 1, 3, 13, 0, 0) .single() .expect("valid datetime"); - let mut builder = codex_state::ThreadMetadataBuilder::new( + let builder = codex_state::ThreadMetadataBuilder::new( thread_id, stale_path, created_at, SessionSource::Cli, - ); - builder.model_provider = Some(config.model_provider_id.clone()); - builder.cwd = home.path().to_path_buf(); + ) + .with_model_provider(Some(config.model_provider_id.clone())) + .with_cwd(home.path().to_path_buf()); let mut metadata = builder.build(config.model_provider_id.as_str()); metadata.first_user_message = Some("Hello from user".to_string()); metadata.preview = metadata.first_user_message.clone(); @@ -1464,14 +1464,14 @@ async fn list_threads_db_enabled_preserves_metadata_for_missing_rollout_paths() .with_ymd_and_hms(2025, 1, 2, 13, 0, 0) .single() .expect("valid datetime"); - let mut valid_builder = codex_state::ThreadMetadataBuilder::new( + let valid_builder = codex_state::ThreadMetadataBuilder::new( valid_thread_id, valid_path.clone(), valid_created_at, SessionSource::Cli, - ); - valid_builder.model_provider = Some(config.model_provider_id.clone()); - valid_builder.cwd = home.path().to_path_buf(); + ) + .with_model_provider(Some(config.model_provider_id.clone())) + .with_cwd(home.path().to_path_buf()); let mut valid_metadata = valid_builder.build(config.model_provider_id.as_str()); valid_metadata.first_user_message = Some("Older valid thread".to_string()); valid_metadata.preview = valid_metadata.first_user_message.clone(); @@ -1671,14 +1671,14 @@ async fn list_threads_default_filter_returns_filesystem_scan_results() -> std::i .with_ymd_and_hms(2025, 1, 3, 13, 0, 0) .single() .expect("valid datetime"); - let mut builder = codex_state::ThreadMetadataBuilder::new( + let builder = codex_state::ThreadMetadataBuilder::new( thread_id, real_path, created_at, SessionSource::Cli, - ); - builder.model_provider = Some(config.model_provider_id.clone()); - builder.cwd = stale_cwd.clone(); + ) + .with_model_provider(Some(config.model_provider_id.clone())) + .with_cwd(stale_cwd.clone()); let mut metadata = builder.build(config.model_provider_id.as_str()); metadata.first_user_message = Some("Hello from user".to_string()); metadata.preview = metadata.first_user_message.clone(); @@ -1761,19 +1761,19 @@ async fn list_threads_metadata_filter_overlays_state_db_list_metadata() -> std:: .with_ymd_and_hms(2025, 1, 3, 16, 0, 0) .single() .expect("valid datetime"); - 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.model_provider_id.clone()); - builder.cwd = home.path().to_path_buf(); - builder.git_branch = Some("sqlite-branch".to_string()); - builder.git_sha = Some("sqlite-sha".to_string()); - builder.git_origin_url = Some( + ) + .with_model_provider(Some(config.model_provider_id.clone())) + .with_cwd(home.path().to_path_buf()) + .with_git_branch(Some("sqlite-branch".to_string())) + .with_git_sha(Some("sqlite-sha".to_string())) + .with_git_origin_url(Some( SanitizedGitUrl::try_from("https://example.com/repo.git").expect("valid git remote URL"), - ); + )); let mut metadata = builder.build(config.model_provider_id.as_str()); metadata.first_user_message = Some("Hello from user".to_string()); metadata.preview = metadata.first_user_message.clone(); @@ -1934,14 +1934,14 @@ async fn list_threads_search_repairs_stale_state_db_hits_before_returning() -> s .with_ymd_and_hms(2025, 1, 3, 15, 0, 0) .single() .expect("valid datetime"); - let mut builder = codex_state::ThreadMetadataBuilder::new( + let builder = codex_state::ThreadMetadataBuilder::new( thread_id, real_path, created_at, SessionSource::Cli, - ); - builder.model_provider = Some(config.model_provider_id.clone()); - builder.cwd = home.path().to_path_buf(); + ) + .with_model_provider(Some(config.model_provider_id.clone())) + .with_cwd(home.path().to_path_buf()); let mut metadata = builder.build(config.model_provider_id.as_str()); metadata.title = "needle stale first user".to_string(); metadata.first_user_message = Some(metadata.title.clone()); diff --git a/codex-rs/rollout/src/state_db.rs b/codex-rs/rollout/src/state_db.rs index 439a1e62ba..a53b099bd9 100644 --- a/codex-rs/rollout/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -689,10 +689,10 @@ pub async fn apply_rollout_items( let Some(ctx) = context else { return; }; - let mut builder = match builder { - Some(builder) => builder.clone(), + let mut metadata = Box::new(match builder { + Some(builder) => builder.build(default_provider), None => match metadata::builder_from_items(items, rollout_path) { - Some(builder) => builder, + Some(builder) => builder.build(default_provider), None => { warn!( "state db apply_rollout_items missing builder during {stage}: {}", @@ -702,14 +702,11 @@ pub async fn apply_rollout_items( return; } }, - }; - if builder.model_provider.is_none() { - builder.model_provider = Some(default_provider.to_string()); - } - builder.rollout_path = rollout_path.to_path_buf(); - builder.cwd = normalize_cwd_for_state_db(&builder.cwd); + }); + metadata.rollout_path = rollout_path.to_path_buf(); + metadata.cwd = normalize_cwd_for_state_db(&metadata.cwd); if let Err(err) = ctx - .apply_rollout_items(&builder, items, new_thread_memory_mode, updated_at_override) + .apply_rollout_items(metadata, items, new_thread_memory_mode, updated_at_override) .await { warn!( diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index d16ac03e54..91be8e9a3c 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -140,17 +140,15 @@ async fn insert_state_db_thread( .with_ymd_and_hms(2025, 1, 3, 12, 0, 0) .single() .expect("valid datetime"); - let mut builder = codex_state::ThreadMetadataBuilder::new( + let builder = codex_state::ThreadMetadataBuilder::new( thread_id, rollout_path.to_path_buf(), created_at, SessionSource::Cli, - ); - builder.model_provider = Some(TEST_PROVIDER.to_string()); - builder.cwd = home.to_path_buf(); - if archived { - builder.archived_at = Some(created_at); - } + ) + .with_model_provider(Some(TEST_PROVIDER.to_string())) + .with_cwd(home.to_path_buf()) + .with_archived_at(archived.then_some(created_at)); let mut metadata = builder.build(TEST_PROVIDER); metadata.first_user_message = Some("Hello from user".to_string()); metadata.preview = metadata.first_user_message.clone(); diff --git a/codex-rs/state/src/model/mod.rs b/codex-rs/state/src/model/mod.rs index c0b9f44638..a6a0a0faf2 100644 --- a/codex-rs/state/src/model/mod.rs +++ b/codex-rs/state/src/model/mod.rs @@ -8,6 +8,7 @@ mod rollout_migration_state; mod thread_attachment; mod thread_goal; mod thread_metadata; +mod thread_metadata_builder; pub use backfill_state::BackfillState; pub use backfill_state::BackfillStatus; @@ -44,12 +45,12 @@ pub use thread_metadata::ExtractionOutcome; pub use thread_metadata::SortDirection; pub use thread_metadata::SortKey; pub use thread_metadata::ThreadMetadata; -pub use thread_metadata::ThreadMetadataBuilder; pub use thread_metadata::ThreadRelationFilter; pub use thread_metadata::ThreadSection; pub use thread_metadata::ThreadSectionAppearance; pub use thread_metadata::ThreadSectionsPage; pub use thread_metadata::ThreadsPage; +pub use thread_metadata_builder::ThreadMetadataBuilder; pub(crate) use thread_goal::ThreadGoalRow; pub(crate) use thread_metadata::ThreadRow; diff --git a/codex-rs/state/src/model/thread_metadata.rs b/codex-rs/state/src/model/thread_metadata.rs index 7270dc3d60..75378446c4 100644 --- a/codex-rs/state/src/model/thread_metadata.rs +++ b/codex-rs/state/src/model/thread_metadata.rs @@ -3,10 +3,7 @@ use chrono::DateTime; use chrono::Utc; use codex_protocol::SanitizedGitUrl; use codex_protocol::ThreadId; -use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadSource; use serde::Deserialize; @@ -199,156 +196,6 @@ pub struct ThreadMetadata { pub git_origin_url: Option, } -/// Builder data required to construct [`ThreadMetadata`] without parsing filenames. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ThreadMetadataBuilder { - /// Originator recorded at creation, if available. - pub originator: Option, - /// ChatGPT user that created the thread, if known. - pub creator_user_id: Option, - /// ChatGPT account at creation, if known. - pub creator_account_id: Option, - /// The thread identifier. - pub id: ThreadId, - /// The absolute rollout path on disk. - pub rollout_path: PathBuf, - /// The creation timestamp. - pub created_at: DateTime, - /// The last update timestamp, if known. - pub updated_at: Option>, - /// The product recency timestamp, if known. - pub recency_at: Option>, - /// The session source. - pub source: SessionSource, - /// Persisted thread history contract selected when this thread was created. - pub history_mode: ThreadHistoryMode, - /// Optional analytics source classification for this thread. - pub thread_source: Option, - /// Optional random unique nickname assigned to the session. - pub agent_nickname: Option, - /// Optional role (agent_role) assigned to the session. - pub agent_role: Option, - /// Optional canonical agent path assigned to the session. - pub agent_path: Option, - /// The model provider identifier, if known. - pub model_provider: Option, - /// The working directory for the thread. - pub cwd: PathBuf, - /// Version of the CLI that created the thread. - pub cli_version: Option, - /// The approval mode. - pub approval_mode: AskForApproval, - /// The archive timestamp, if the thread is archived. - pub archived_at: Option>, - /// The git commit SHA, if known. - pub git_sha: Option, - /// The git branch name, if known. - pub git_branch: Option, - /// The git origin URL, if known. - pub git_origin_url: Option, -} - -impl ThreadMetadataBuilder { - /// Create a new builder with required fields and sensible defaults. - pub fn new( - id: ThreadId, - rollout_path: PathBuf, - created_at: DateTime, - source: SessionSource, - ) -> Self { - Self { - id, - rollout_path, - created_at, - updated_at: None, - recency_at: None, - originator: None, - creator_user_id: None, - creator_account_id: None, - source, - history_mode: ThreadHistoryMode::Legacy, - thread_source: None, - agent_nickname: None, - agent_role: None, - agent_path: None, - model_provider: None, - cwd: PathBuf::new(), - cli_version: None, - approval_mode: AskForApproval::OnRequest, - archived_at: None, - git_sha: None, - git_branch: None, - git_origin_url: None, - } - } - - /// Build canonical thread metadata, filling missing values from defaults. - pub fn build(&self, default_provider: &str) -> ThreadMetadata { - let source = crate::extract::enum_to_string(&self.source); - let approval_mode = crate::extract::enum_to_string(&self.approval_mode); - let created_at = canonicalize_datetime(self.created_at); - let updated_at = self - .updated_at - .map(canonicalize_datetime) - .unwrap_or(created_at); - let recency_at = self - .recency_at - .map(canonicalize_datetime) - .unwrap_or(updated_at); - let guardian_review = crate::is_guardian_review_source(&self.source); - ThreadMetadata { - attached_thread_parent_id: None, - originator: self.originator.clone(), - creator_user_id: self.creator_user_id.clone(), - creator_account_id: self.creator_account_id.clone(), - id: self.id, - rollout_path: self.rollout_path.clone(), - created_at, - updated_at, - recency_at, - source, - history_mode: self.history_mode, - thread_source: self.thread_source.clone(), - agent_nickname: self.agent_nickname.clone(), - agent_role: self.agent_role.clone(), - agent_path: self - .agent_path - .clone() - .or_else(|| self.source.get_agent_path().map(Into::into)), - model_provider: self - .model_provider - .clone() - .unwrap_or_else(|| default_provider.to_string()), - model: None, - reasoning_effort: None, - cwd: self.cwd.clone(), - cli_version: self.cli_version.clone().unwrap_or_default(), - title: if guardian_review { - crate::GUARDIAN_THREAD_TITLE.to_string() - } else { - String::new() - }, - name: (guardian_review && self.history_mode == ThreadHistoryMode::Paginated) - .then(|| crate::GUARDIAN_THREAD_TITLE.to_string()), - preview: guardian_review.then(|| crate::GUARDIAN_THREAD_PREVIEW.to_string()), - sandbox_policy: serde_json::to_string(&PermissionProfile::read_only()) - .unwrap_or_default(), - approval_mode, - tokens_used: 0, - first_user_message: None, - archived_at: self.archived_at.map(canonicalize_datetime), - section: None, - section_position: None, - section_entered_at: None, - project_id: None, - daybreak_enabled: None, - git_sha: self.git_sha.clone(), - git_branch: self.git_branch.clone(), - git_origin_url: self.git_origin_url.clone(), - } - } -} - impl ThreadMetadata { /// Preserve SQLite-owned Git fields when rollout-derived metadata is reconciled. pub fn prefer_existing_git_info(&mut self, existing: &Self) { @@ -497,7 +344,7 @@ impl ThreadMetadata { } } -fn canonicalize_datetime(dt: DateTime) -> DateTime { +pub(super) fn canonicalize_datetime(dt: DateTime) -> DateTime { epoch_millis_to_datetime(datetime_to_epoch_millis(dt)).unwrap_or(dt) } diff --git a/codex-rs/state/src/model/thread_metadata_builder.rs b/codex-rs/state/src/model/thread_metadata_builder.rs new file mode 100644 index 0000000000..ffb0808117 --- /dev/null +++ b/codex-rs/state/src/model/thread_metadata_builder.rs @@ -0,0 +1,203 @@ +//! Constructs canonical metadata from explicit creation inputs and optional attributes. + +use super::ThreadMetadata; +use super::thread_metadata::canonicalize_datetime; +use chrono::DateTime; +use chrono::Utc; +use codex_protocol::SanitizedGitUrl; +use codex_protocol::ThreadId; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadHistoryMode; +use codex_protocol::protocol::ThreadSource; +use std::path::PathBuf; + +/// Builds metadata without parsing filenames. ID, rollout path, creation time, and source are required. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThreadMetadataBuilder { + id: ThreadId, + rollout_path: PathBuf, + created_at: DateTime, + source: SessionSource, + originator: Option, + creator_user_id: Option, + creator_account_id: Option, + history_mode: ThreadHistoryMode, + thread_source: Option, + agent_nickname: Option, + agent_role: Option, + agent_path: Option, + model_provider: Option, + cwd: PathBuf, + cli_version: String, + archived_at: Option>, + git_sha: Option, + git_branch: Option, + git_origin_url: Option, +} + +impl ThreadMetadataBuilder { + /// Create a builder with required creation inputs and optional-field defaults. + pub fn new( + id: ThreadId, + rollout_path: PathBuf, + created_at: DateTime, + source: SessionSource, + ) -> Self { + Self { + id, + rollout_path, + created_at, + source, + originator: None, + creator_user_id: None, + creator_account_id: None, + history_mode: ThreadHistoryMode::Legacy, + thread_source: None, + agent_nickname: None, + agent_role: None, + agent_path: None, + model_provider: None, + cwd: PathBuf::new(), + cli_version: String::new(), + archived_at: None, + git_sha: None, + git_branch: None, + git_origin_url: None, + } + } + + pub fn with_originator(mut self, originator: Option) -> Self { + self.originator = originator; + self + } + + pub fn with_creator_user_id(mut self, creator_user_id: Option) -> Self { + self.creator_user_id = creator_user_id; + self + } + + pub fn with_creator_account_id(mut self, creator_account_id: Option) -> Self { + self.creator_account_id = creator_account_id; + self + } + + pub fn with_history_mode(mut self, history_mode: ThreadHistoryMode) -> Self { + self.history_mode = history_mode; + self + } + + pub fn with_thread_source(mut self, thread_source: Option) -> Self { + self.thread_source = thread_source; + self + } + + pub fn with_agent_nickname(mut self, agent_nickname: Option) -> Self { + self.agent_nickname = agent_nickname; + self + } + + pub fn with_agent_role(mut self, agent_role: Option) -> Self { + self.agent_role = agent_role; + self + } + + pub fn with_agent_path(mut self, agent_path: Option) -> Self { + self.agent_path = agent_path; + self + } + + pub fn with_model_provider(mut self, model_provider: Option) -> Self { + self.model_provider = model_provider; + self + } + + pub fn with_cwd(mut self, cwd: PathBuf) -> Self { + self.cwd = cwd; + self + } + + pub fn with_cli_version(mut self, cli_version: String) -> Self { + self.cli_version = cli_version; + self + } + + pub fn with_archived_at(mut self, archived_at: Option>) -> Self { + self.archived_at = archived_at; + self + } + + pub fn with_git_sha(mut self, git_sha: Option) -> Self { + self.git_sha = git_sha; + self + } + + pub fn with_git_branch(mut self, git_branch: Option) -> Self { + self.git_branch = git_branch; + self + } + + pub fn with_git_origin_url(mut self, git_origin_url: Option) -> Self { + self.git_origin_url = git_origin_url; + self + } + + /// Build canonical thread metadata, filling missing values from defaults. + pub fn build(&self, default_provider: &str) -> ThreadMetadata { + let source = crate::extract::enum_to_string(&self.source); + let approval_mode = crate::extract::enum_to_string(&AskForApproval::OnRequest); + let created_at = canonicalize_datetime(self.created_at); + let guardian_review = crate::is_guardian_review_source(&self.source); + ThreadMetadata { + attached_thread_parent_id: None, + originator: self.originator.clone(), + creator_user_id: self.creator_user_id.clone(), + creator_account_id: self.creator_account_id.clone(), + id: self.id, + rollout_path: self.rollout_path.clone(), + created_at, + updated_at: created_at, + recency_at: created_at, + source, + history_mode: self.history_mode, + thread_source: self.thread_source.clone(), + agent_nickname: self.agent_nickname.clone(), + agent_role: self.agent_role.clone(), + agent_path: self + .agent_path + .clone() + .or_else(|| self.source.get_agent_path().map(Into::into)), + model_provider: self + .model_provider + .clone() + .unwrap_or_else(|| default_provider.to_string()), + model: None, + reasoning_effort: None, + cwd: self.cwd.clone(), + cli_version: self.cli_version.clone(), + title: if guardian_review { + crate::GUARDIAN_THREAD_TITLE.to_string() + } else { + String::new() + }, + name: (guardian_review && self.history_mode == ThreadHistoryMode::Paginated) + .then(|| crate::GUARDIAN_THREAD_TITLE.to_string()), + preview: guardian_review.then(|| crate::GUARDIAN_THREAD_PREVIEW.to_string()), + sandbox_policy: serde_json::to_string(&PermissionProfile::read_only()) + .unwrap_or_default(), + approval_mode, + tokens_used: 0, + first_user_message: None, + archived_at: self.archived_at.map(canonicalize_datetime), + section: None, + section_position: None, + section_entered_at: None, + project_id: None, + daybreak_enabled: None, + git_sha: self.git_sha.clone(), + git_branch: self.git_branch.clone(), + git_origin_url: self.git_origin_url.clone(), + } + } +} diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index b562efb498..85ac134aae 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1151,10 +1151,16 @@ ON CONFLICT(id) DO UPDATE SET Ok(()) } - /// Apply rollout items incrementally using the underlying database. + /// Build metadata using the provider configured for this state runtime. + pub fn build_thread_metadata(&self, builder: &ThreadMetadataBuilder) -> crate::ThreadMetadata { + builder.build(&self.default_provider) + } + + /// Apply rollout items incrementally, keeping existing fields except the seed rollout path. + /// The seed stays boxed to keep callers' async frames small. pub async fn apply_rollout_items( &self, - builder: &ThreadMetadataBuilder, + mut metadata: Box, items: &[RolloutItem], new_thread_memory_mode: Option<&str>, updated_at_override: Option>, @@ -1162,11 +1168,13 @@ ON CONFLICT(id) DO UPDATE SET 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(); + let thread_id = metadata.id; + let existing_metadata = self.get_thread(thread_id).await?; + if let Some(existing) = existing_metadata.as_ref() { + let rollout_path = std::mem::take(&mut metadata.rollout_path); + *metadata = existing.clone(); + metadata.rollout_path = rollout_path; + } for item in items { apply_rollout_item(&mut metadata, item, &self.default_provider); } @@ -1175,7 +1183,7 @@ ON CONFLICT(id) DO UPDATE SET } let updated_at = match updated_at_override { Some(updated_at) => Some(updated_at), - None => file_modified_time_utc(builder.rollout_path.as_path()).await, + None => file_modified_time_utc(metadata.rollout_path.as_path()).await, }; if let Some(updated_at) = updated_at { metadata.updated_at = updated_at; @@ -1189,7 +1197,7 @@ ON CONFLICT(id) DO UPDATE SET 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()) + .set_thread_memory_mode(thread_id, memory_mode.as_str()) .await { return Err(err); @@ -3019,7 +3027,9 @@ mod tests { runtime .apply_rollout_items( - &builder, &items, /*new_thread_memory_mode*/ None, + Box::new(runtime.build_thread_metadata(&builder)), + &items, + /*new_thread_memory_mode*/ None, /*updated_at_override*/ None, ) .await @@ -3102,7 +3112,9 @@ mod tests { runtime .apply_rollout_items( - &builder, &items, /*new_thread_memory_mode*/ None, + Box::new(runtime.build_thread_metadata(&builder)), + &items, + /*new_thread_memory_mode*/ None, /*updated_at_override*/ None, ) .await @@ -3880,7 +3892,7 @@ mod tests { runtime .apply_rollout_items( - &builder, + Box::new(runtime.build_thread_metadata(&builder)), &items, /*new_thread_memory_mode*/ None, Some(override_updated_at), diff --git a/codex-rs/thread-store/src/local/archive_thread.rs b/codex-rs/thread-store/src/local/archive_thread.rs index 397ab0f9e2..4fd105d538 100644 --- a/codex-rs/thread-store/src/local/archive_thread.rs +++ b/codex-rs/thread-store/src/local/archive_thread.rs @@ -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) diff --git a/codex-rs/thread-store/src/local/list_threads.rs b/codex-rs/thread-store/src/local/list_threads.rs index fea544636d..5abdecaf53 100644 --- a/codex-rs/thread-store/src/local/list_threads.rs +++ b/codex-rs/thread-store/src/local/list_threads.rs @@ -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(); diff --git a/codex-rs/thread-store/src/local/live_writer.rs b/codex-rs/thread-store/src/local/live_writer.rs index 0fba3f55f8..04a623930d 100644 --- a/codex-rs/thread-store/src/local/live_writer.rs +++ b/codex-rs/thread-store/src/local/live_writer.rs @@ -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, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index c61c125f7f..f779f97cd0 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -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) diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index a774ec8578..9b1435c908 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -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 diff --git a/codex-rs/thread-store/src/local/rollout_lineage_tests.rs b/codex-rs/thread-store/src/local/rollout_lineage_tests.rs index dfdd0ebe18..9e47bd1594 100644 --- a/codex-rs/thread-store/src/local/rollout_lineage_tests.rs +++ b/codex-rs/thread-store/src/local/rollout_lineage_tests.rs @@ -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 diff --git a/codex-rs/thread-store/src/local/thread_history/read_tests.rs b/codex-rs/thread-store/src/local/thread_history/read_tests.rs index 62b1f5680c..9c22fe4b4f 100644 --- a/codex-rs/thread-store/src/local/thread_history/read_tests.rs +++ b/codex-rs/thread-store/src/local/thread_history/read_tests.rs @@ -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 diff --git a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs index eede8d5386..7cebac97e3 100644 --- a/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs +++ b/codex-rs/thread-store/src/local/thread_history_materialization_tests.rs @@ -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 diff --git a/codex-rs/thread-store/src/local/unarchive_thread.rs b/codex-rs/thread-store/src/local/unarchive_thread.rs index 986633acc1..2a4976a208 100644 --- a/codex-rs/thread-store/src/local/unarchive_thread.rs +++ b/codex-rs/thread-store/src/local/unarchive_thread.rs @@ -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 { diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index e1a4c93828..e58f4e8472 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -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 { diff --git a/codex-rs/tui/src/named_session_lookup_tests.rs b/codex-rs/tui/src/named_session_lookup_tests.rs index f42775547c..d3a1961b09 100644 --- a/codex-rs/tui/src/named_session_lookup_tests.rs +++ b/codex-rs/tui/src/named_session_lookup_tests.rs @@ -95,14 +95,14 @@ fn thread_metadata( let created_at = chrono::DateTime::parse_from_rfc3339("2025-02-01T10:00:00Z") .expect("timestamp should parse") .with_timezone(&chrono::Utc); - let mut builder = ThreadMetadataBuilder::new( + let builder = ThreadMetadataBuilder::new( thread_id, rollout_path, created_at, serde_json::from_value(serde_json::json!("cli")) .expect("cli session source should deserialize"), - ); - builder.cwd = config.codex_home.join("project").to_path_buf(); + ) + .with_cwd(config.codex_home.join("project").to_path_buf()); let mut metadata = builder.build(config.model_provider_id.as_str()); metadata.title = title.to_string(); metadata.first_user_message = Some("preview text".to_string()); diff --git a/codex-rs/tui/src/session_archive_commands_tests.rs b/codex-rs/tui/src/session_archive_commands_tests.rs index 8d94c50c31..18657465d0 100644 --- a/codex-rs/tui/src/session_archive_commands_tests.rs +++ b/codex-rs/tui/src/session_archive_commands_tests.rs @@ -132,14 +132,14 @@ fn thread_metadata( let created_at = DateTime::parse_from_rfc3339("2025-02-01T10:00:00Z") .expect("timestamp should parse") .with_timezone(&Utc); - let mut builder = ThreadMetadataBuilder::new( + let builder = ThreadMetadataBuilder::new( thread_id, rollout_path, created_at, serde_json::from_value(serde_json::json!("cli")) .expect("cli session source should deserialize"), - ); - builder.cwd = config.codex_home.join("project").to_path_buf(); + ) + .with_cwd(config.codex_home.join("project").to_path_buf()); let mut metadata = builder.build(config.model_provider_id.as_str()); metadata.title = name.to_string(); metadata.first_user_message = Some("preview text".to_string());