use anyhow::Result;use chrono::DateTime;use chrono::Utc;use codex_protocol::ThreadId;use codex_protocol::openai_models::ReasoningEffort;use codex_protocol::protocol::AskForApproval;use codex_protocol::protocol::SandboxPolicy;use codex_protocol::protocol::SessionSource;use codex_protocol::protocol::ThreadSource;use sqlx::Row;use sqlx::sqlite::SqliteRow;use std::path::PathBuf;/// The sort key to use when listing threads.#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum SortKey { /// Sort by the thread's creation timestamp. CreatedAt, /// Sort by the thread's last update timestamp. UpdatedAt,}/// Sort direction to use when listing threads.#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub enum SortDirection { Asc, Desc,}/// A pagination anchor used for keyset pagination.#[derive(Debug, Clone, PartialEq, Eq)]pub struct Anchor { /// The timestamp component of the anchor. pub ts: DateTime<Utc>,}/// A single page of thread metadata results.#[derive(Debug, Clone, PartialEq, Eq)]pub struct ThreadsPage { /// The thread metadata items in this page. pub items: Vec<ThreadMetadata>, /// The next anchor to use for pagination, if any. pub next_anchor: Option<Anchor>, /// The number of rows scanned to produce this page. pub num_scanned_rows: usize,}/// The outcome of extracting metadata from a rollout.#[derive(Debug, Clone, PartialEq, Eq)]pub struct ExtractionOutcome { /// The extracted thread metadata. pub metadata: ThreadMetadata, /// The explicit thread memory mode from rollout metadata, if present. pub memory_mode: Option<String>, /// The number of rollout lines that failed to parse. pub parse_errors: usize,}/// Canonical thread metadata derived from rollout files.#[derive(Debug, Clone, PartialEq, Eq)]pub struct ThreadMetadata { /// The thread identifier. pub id: ThreadId, /// The absolute rollout path on disk. pub rollout_path: PathBuf, /// The creation timestamp. pub created_at: DateTime<Utc>, /// The last update timestamp. pub updated_at: DateTime<Utc>, /// The session source (stringified enum). pub source: String, /// Optional analytics source classification for this thread. pub thread_source: Option<ThreadSource>, /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. pub agent_nickname: Option<String>, /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. pub agent_role: Option<String>, /// Optional canonical agent path assigned to an AgentControl-spawned sub-agent. pub agent_path: Option<String>, /// The model provider identifier. pub model_provider: String, /// The latest observed model for the thread. pub model: Option<String>, /// The latest observed reasoning effort for the thread. pub reasoning_effort: Option<ReasoningEffort>, /// The working directory for the thread. pub cwd: PathBuf, /// Version of the CLI that created the thread. pub cli_version: String, /// A best-effort thread title. pub title: String, /// Best available user-facing preview for discovery and list display. pub preview: Option<String>, /// The sandbox policy (stringified enum). pub sandbox_policy: String, /// The approval mode (stringified enum). pub approval_mode: String, /// The last observed token usage. pub tokens_used: i64, /// First user message observed for this thread, if any. pub first_user_message: Option<String>, /// The archive timestamp, if the thread is archived. pub archived_at: Option<DateTime<Utc>>, /// The git commit SHA, if known. pub git_sha: Option<String>, /// The git branch name, if known. pub git_branch: Option<String>, /// The git origin URL, if known. pub git_origin_url: Option<String>,}/// Builder data required to construct [`ThreadMetadata`] without parsing filenames.#[derive(Debug, Clone, PartialEq, Eq)]pub struct ThreadMetadataBuilder { /// The thread identifier. pub id: ThreadId, /// The absolute rollout path on disk. pub rollout_path: PathBuf, /// The creation timestamp. pub created_at: DateTime<Utc>, /// The last update timestamp, if known. pub updated_at: Option<DateTime<Utc>>, /// The session source. pub source: SessionSource, /// Optional analytics source classification for this thread. pub thread_source: Option<ThreadSource>, /// Optional random unique nickname assigned to the session. pub agent_nickname: Option<String>, /// Optional role (agent_role) assigned to the session. pub agent_role: Option<String>, /// Optional canonical agent path assigned to the session. pub agent_path: Option<String>, /// The model provider identifier, if known. pub model_provider: Option<String>, /// The working directory for the thread. pub cwd: PathBuf, /// Version of the CLI that created the thread. pub cli_version: Option<String>, /// The sandbox policy. pub sandbox_policy: SandboxPolicy, /// The approval mode. pub approval_mode: AskForApproval, /// The archive timestamp, if the thread is archived. pub archived_at: Option<DateTime<Utc>>, /// The git commit SHA, if known. pub git_sha: Option<String>, /// The git branch name, if known. pub git_branch: Option<String>, /// The git origin URL, if known. pub git_origin_url: Option<String>,}impl ThreadMetadataBuilder { /// Create a new builder with required fields and sensible defaults. pub fn new( id: ThreadId, rollout_path: PathBuf, created_at: DateTime<Utc>, source: SessionSource, ) -> Self { Self { id, rollout_path, created_at, updated_at: None, source, thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, model_provider: None, cwd: PathBuf::new(), cli_version: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), 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 sandbox_policy = crate::extract::enum_to_string(&self.sandbox_policy); 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); ThreadMetadata { id: self.id, rollout_path: self.rollout_path.clone(), created_at, updated_at, source, 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: String::new(), preview: None, sandbox_policy, approval_mode, tokens_used: 0, first_user_message: None, archived_at: self.archived_at.map(canonicalize_datetime), git_sha: self.git_sha.clone(), git_branch: self.git_branch.clone(), git_origin_url: self.git_origin_url.clone(), } }}impl ThreadMetadata { /// Preserve existing non-null Git fields when rollout-derived metadata is reconciled. pub fn prefer_existing_git_info(&mut self, existing: &Self) { if existing.git_sha.is_some() { self.git_sha = existing.git_sha.clone(); } if existing.git_branch.is_some() { self.git_branch = existing.git_branch.clone(); } if existing.git_origin_url.is_some() { self.git_origin_url = existing.git_origin_url.clone(); } } /// Preserve an existing user-facing title when reconciling rollout-derived metadata. pub fn prefer_existing_explicit_title(&mut self, existing: &Self) { let existing_title = existing.title.trim(); if existing_title.is_empty() || existing.first_user_message.as_deref().map(str::trim) == Some(existing_title) { return; } let title = self.title.trim(); if title.is_empty() || self.first_user_message.as_deref().map(str::trim) == Some(title) { self.title = existing.title.clone(); } } /// Return the list of field names that differ between `self` and `other`. pub fn diff_fields(&self, other: &Self) -> Vec<&'static str> { let mut diffs = Vec::new(); if self.id != other.id { diffs.push("id"); } if self.rollout_path != other.rollout_path { diffs.push("rollout_path"); } if self.created_at != other.created_at { diffs.push("created_at"); } if self.updated_at != other.updated_at { diffs.push("updated_at"); } if self.source != other.source { diffs.push("source"); } if self.agent_nickname != other.agent_nickname { diffs.push("agent_nickname"); } if self.agent_role != other.agent_role { diffs.push("agent_role"); } if self.agent_path != other.agent_path { diffs.push("agent_path"); } if self.model_provider != other.model_provider { diffs.push("model_provider"); } if self.model != other.model { diffs.push("model"); } if self.reasoning_effort != other.reasoning_effort { diffs.push("reasoning_effort"); } if self.cwd != other.cwd { diffs.push("cwd"); } if self.cli_version != other.cli_version { diffs.push("cli_version"); } if self.title != other.title { diffs.push("title"); } if self.preview != other.preview { diffs.push("preview"); } if self.sandbox_policy != other.sandbox_policy { diffs.push("sandbox_policy"); } if self.approval_mode != other.approval_mode { diffs.push("approval_mode"); } if self.tokens_used != other.tokens_used { diffs.push("tokens_used"); } if self.first_user_message != other.first_user_message { diffs.push("first_user_message"); } if self.archived_at != other.archived_at { diffs.push("archived_at"); } if self.git_sha != other.git_sha { diffs.push("git_sha"); } if self.git_branch != other.git_branch { diffs.push("git_branch"); } if self.git_origin_url != other.git_origin_url { diffs.push("git_origin_url"); } diffs }}fn canonicalize_datetime(dt: DateTime<Utc>) -> DateTime<Utc> { epoch_millis_to_datetime(datetime_to_epoch_millis(dt)).unwrap_or(dt)}#[derive(Debug)]pub(crate) struct ThreadRow { id: String, rollout_path: String, created_at: i64, updated_at: i64, source: String, thread_source: Option<String>, agent_nickname: Option<String>, agent_role: Option<String>, agent_path: Option<String>, model_provider: String, model: Option<String>, reasoning_effort: Option<String>, cwd: String, cli_version: String, title: String, preview: String, sandbox_policy: String, approval_mode: String, tokens_used: i64, first_user_message: String, archived_at: Option<i64>, git_sha: Option<String>, git_branch: Option<String>, git_origin_url: Option<String>,}impl ThreadRow { pub(crate) fn try_from_row(row: &SqliteRow) -> Result<Self> { Ok(Self { id: row.try_get("id")?, rollout_path: row.try_get("rollout_path")?, created_at: row.try_get("created_at")?, updated_at: row.try_get("updated_at")?, source: row.try_get("source")?, thread_source: row.try_get("thread_source")?, agent_nickname: row.try_get("agent_nickname")?, agent_role: row.try_get("agent_role")?, agent_path: row.try_get("agent_path")?, model_provider: row.try_get("model_provider")?, model: row.try_get("model")?, reasoning_effort: row.try_get("reasoning_effort")?, cwd: row.try_get("cwd")?, cli_version: row.try_get("cli_version")?, title: row.try_get("title")?, preview: row.try_get("preview")?, sandbox_policy: row.try_get("sandbox_policy")?, approval_mode: row.try_get("approval_mode")?, tokens_used: row.try_get("tokens_used")?, first_user_message: row.try_get("first_user_message")?, archived_at: row.try_get("archived_at")?, git_sha: row.try_get("git_sha")?, git_branch: row.try_get("git_branch")?, git_origin_url: row.try_get("git_origin_url")?, }) }}impl TryFrom<ThreadRow> for ThreadMetadata { type Error = anyhow::Error; fn try_from(row: ThreadRow) -> std::result::Result<Self, Self::Error> { let ThreadRow { id, rollout_path, created_at, updated_at, source, thread_source, agent_nickname, agent_role, agent_path, model_provider, model, reasoning_effort, cwd, cli_version, title, preview, sandbox_policy, approval_mode, tokens_used, first_user_message, archived_at, git_sha, git_branch, git_origin_url, } = row; let thread_source = thread_source .map(|thread_source| thread_source.parse()) .transpose() .map_err(anyhow::Error::msg)?; Ok(Self { id: ThreadId::try_from(id)?, rollout_path: PathBuf::from(rollout_path), created_at: epoch_millis_to_datetime(created_at)?, updated_at: epoch_millis_to_datetime(updated_at)?, source, thread_source, agent_nickname, agent_role, agent_path, model_provider, model, reasoning_effort: reasoning_effort .and_then(|value| value.parse::<ReasoningEffort>().ok()), cwd: PathBuf::from(cwd), cli_version, title, preview: (!preview.is_empty()).then_some(preview), sandbox_policy, approval_mode, tokens_used, first_user_message: (!first_user_message.is_empty()).then_some(first_user_message), archived_at: archived_at.map(epoch_seconds_to_datetime).transpose()?, git_sha, git_branch, git_origin_url, }) }}pub(crate) fn anchor_from_item(item: &ThreadMetadata, sort_key: SortKey) -> Option<Anchor> { let ts = match sort_key { SortKey::CreatedAt => item.created_at, SortKey::UpdatedAt => item.updated_at, }; Some(Anchor { ts })}pub(crate) fn datetime_to_epoch_millis(dt: DateTime<Utc>) -> i64 { dt.timestamp_millis()}pub(crate) fn datetime_to_epoch_seconds(dt: DateTime<Utc>) -> i64 { dt.timestamp()}pub(crate) fn epoch_millis_to_datetime(value: i64) -> Result<DateTime<Utc>> { // Values older than 2020 if interpreted as milliseconds are legacy second-precision rows. // Convert them in memory so old state DBs keep ordering correctly after new writes use ms. const MIN_EPOCH_MILLIS: i64 = 1_577_836_800_000; let millis = if value < MIN_EPOCH_MILLIS { value.saturating_mul(1000) } else { value }; DateTime::<Utc>::from_timestamp_millis(millis) .ok_or_else(|| anyhow::anyhow!("invalid unix timestamp millis: {value}"))}pub(crate) fn epoch_seconds_to_datetime(value: i64) -> Result<DateTime<Utc>> { DateTime::<Utc>::from_timestamp(value, 0) .ok_or_else(|| anyhow::anyhow!("invalid unix timestamp seconds: {value}"))}/// Statistics about a backfill operation.#[derive(Debug, Clone)]pub struct BackfillStats { /// The number of rollout files scanned. pub scanned: usize, /// The number of rows upserted successfully. pub upserted: usize, /// The number of rows that failed to upsert. pub failed: usize,}#[cfg(test)]mod tests { use super::ThreadMetadata; use super::ThreadRow; use chrono::DateTime; use chrono::Utc; use codex_protocol::ThreadId; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; use std::path::PathBuf; fn thread_row(reasoning_effort: Option<&str>) -> ThreadRow { ThreadRow { id: "00000000-0000-0000-0000-000000000123".to_string(), rollout_path: "/tmp/rollout-123.jsonl".to_string(), created_at: 1_700_000_000, updated_at: 1_700_000_100, source: "cli".to_string(), thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, model_provider: "openai".to_string(), model: Some("gpt-5".to_string()), reasoning_effort: reasoning_effort.map(str::to_string), cwd: "/tmp/workspace".to_string(), cli_version: "0.0.0".to_string(), title: String::new(), preview: String::new(), sandbox_policy: "read-only".to_string(), approval_mode: "on-request".to_string(), tokens_used: 1, first_user_message: String::new(), archived_at: None, git_sha: None, git_branch: None, git_origin_url: None, } } fn expected_thread_metadata(reasoning_effort: Option<ReasoningEffort>) -> ThreadMetadata { ThreadMetadata { id: ThreadId::from_string("00000000-0000-0000-0000-000000000123") .expect("valid thread id"), rollout_path: PathBuf::from("/tmp/rollout-123.jsonl"), created_at: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("timestamp"), updated_at: DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp"), source: "cli".to_string(), thread_source: None, agent_nickname: None, agent_role: None, agent_path: None, model_provider: "openai".to_string(), model: Some("gpt-5".to_string()), reasoning_effort, cwd: PathBuf::from("/tmp/workspace"), cli_version: "0.0.0".to_string(), title: String::new(), preview: None, sandbox_policy: "read-only".to_string(), approval_mode: "on-request".to_string(), tokens_used: 1, first_user_message: None, archived_at: None, git_sha: None, git_branch: None, git_origin_url: None, } } #[test] fn thread_row_parses_reasoning_effort() { let metadata = ThreadMetadata::try_from(thread_row(Some("high"))) .expect("thread metadata should parse"); assert_eq!( metadata, expected_thread_metadata(Some(ReasoningEffort::High)) ); } #[test] fn thread_row_preserves_model_defined_reasoning_effort_values() { let metadata = ThreadMetadata::try_from(thread_row(Some("future"))) .expect("thread metadata should parse"); assert_eq!( metadata, expected_thread_metadata(Some(ReasoningEffort::Custom("future".to_string()))) ); }}