Skip to main content

codex_rollout/
state_db.rs

1use crate::config::RolloutConfig;
2use crate::config::RolloutConfigView;
3use crate::list::Cursor;
4use crate::list::SortDirection;
5use crate::list::ThreadSortKey;
6use crate::metadata;
7use crate::sqlite_metrics;
8use anyhow::Context;
9use chrono::DateTime;
10use chrono::Utc;
11use codex_protocol::ThreadId;
12use codex_protocol::protocol::RolloutItem;
13use codex_protocol::protocol::SessionSource;
14use codex_protocol::protocol::ThreadHistoryMode;
15pub use codex_state::LogEntry;
16use codex_state::ThreadMetadataBuilder;
17use codex_utils_path::normalize_for_path_comparison;
18use serde_json::Value;
19use std::path::Path;
20use std::path::PathBuf;
21use std::sync::Arc;
22use std::time::Duration;
23use std::time::Instant;
24use tracing::info;
25use tracing::warn;
26
27/// Core-facing handle to the SQLite-backed state runtime.
28pub type StateDbHandle = Arc<codex_state::StateRuntime>;
29
30#[cfg(not(test))]
31const STARTUP_BACKFILL_POLL_INTERVAL: Duration = Duration::from_secs(1);
32#[cfg(test)]
33const STARTUP_BACKFILL_POLL_INTERVAL: Duration = Duration::from_millis(10);
34#[cfg(not(test))]
35const STARTUP_BACKFILL_WAIT_TIMEOUT: Duration = Duration::from_secs(30);
36#[cfg(test)]
37const STARTUP_BACKFILL_WAIT_TIMEOUT: Duration = Duration::from_secs(2);
38
39/// Initialize the state runtime for thread state persistence.
40///
41/// This is the process entry point for local state: it opens the SQLite-backed
42/// runtime, applies rollout metadata backfills as needed, and returns the
43/// initialized handle.
44pub async fn init(config: &impl RolloutConfigView) -> Option<StateDbHandle> {
45    let config = RolloutConfig::from_view(config);
46    match try_init_with_roots(
47        config.codex_home,
48        config.sqlite_home,
49        config.model_provider_id,
50    )
51    .await
52    {
53        Ok(runtime) => Some(runtime),
54        Err(err) => {
55            emit_startup_warning(&format!("failed to initialize state runtime: {err:#}"));
56            None
57        }
58    }
59}
60
61/// Initialize the state runtime and return any initialization error to the caller.
62///
63/// Prefer [`init`] unless the caller needs to surface the exact failure after
64/// tracing or UI setup has completed.
65pub async fn try_init(config: &impl RolloutConfigView) -> anyhow::Result<StateDbHandle> {
66    let config = RolloutConfig::from_view(config);
67    try_init_with_roots(
68        config.codex_home,
69        config.sqlite_home,
70        config.model_provider_id,
71    )
72    .await
73}
74
75async fn try_init_with_roots(
76    codex_home: PathBuf,
77    sqlite_home: PathBuf,
78    default_model_provider_id: String,
79) -> anyhow::Result<StateDbHandle> {
80    try_init_with_roots_inner(
81        codex_home,
82        sqlite_home,
83        default_model_provider_id,
84        /*backfill_lease_seconds*/ None,
85    )
86    .await
87}
88
89#[cfg(test)]
90async fn try_init_with_roots_and_backfill_lease(
91    codex_home: PathBuf,
92    sqlite_home: PathBuf,
93    default_model_provider_id: String,
94    backfill_lease_seconds: i64,
95) -> anyhow::Result<StateDbHandle> {
96    try_init_with_roots_inner(
97        codex_home,
98        sqlite_home,
99        default_model_provider_id,
100        Some(backfill_lease_seconds),
101    )
102    .await
103}
104
105async fn try_init_with_roots_inner(
106    codex_home: PathBuf,
107    sqlite_home: PathBuf,
108    default_model_provider_id: String,
109    backfill_lease_seconds: Option<i64>,
110) -> anyhow::Result<StateDbHandle> {
111    let runtime =
112        codex_state::StateRuntime::init(sqlite_home.clone(), default_model_provider_id.clone())
113            .await
114            .with_context(|| {
115                format!(
116                    "failed to initialize state runtime at {}",
117                    sqlite_home.display()
118                )
119            })?;
120    let backfill_gate_started = Instant::now();
121    let backfill_gate_result = wait_for_backfill_gate(
122        runtime.as_ref(),
123        codex_home.as_path(),
124        default_model_provider_id.as_str(),
125        backfill_lease_seconds,
126    )
127    .await;
128    codex_state::record_backfill_gate(
129        /*telemetry*/ None,
130        backfill_gate_started.elapsed(),
131        &backfill_gate_result,
132    );
133    if let Err(err) = backfill_gate_result {
134        runtime.close().await;
135        return Err(err);
136    }
137    Ok(runtime)
138}
139
140async fn wait_for_backfill_gate(
141    runtime: &codex_state::StateRuntime,
142    codex_home: &Path,
143    default_model_provider_id: &str,
144    backfill_lease_seconds: Option<i64>,
145) -> anyhow::Result<()> {
146    let wait_started = Instant::now();
147    let mut reported_wait = false;
148    loop {
149        let backfill_state = runtime.get_backfill_state().await.map_err(|err| {
150            anyhow::anyhow!(
151                "failed to read backfill state at {}: {err}",
152                codex_home.display()
153            )
154        })?;
155        if backfill_state.status == codex_state::BackfillStatus::Complete {
156            return Ok(());
157        }
158
159        if let Some(backfill_lease_seconds) = backfill_lease_seconds {
160            metadata::backfill_sessions_with_lease(
161                runtime,
162                codex_home,
163                default_model_provider_id,
164                backfill_lease_seconds,
165            )
166            .await;
167        } else {
168            metadata::backfill_sessions(runtime, codex_home, default_model_provider_id).await;
169        }
170        let backfill_state = runtime.get_backfill_state().await.map_err(|err| {
171            anyhow::anyhow!(
172                "failed to read backfill state at {} after startup backfill: {err}",
173                codex_home.display()
174            )
175        })?;
176        if backfill_state.status == codex_state::BackfillStatus::Complete {
177            return Ok(());
178        }
179        if wait_started.elapsed() >= STARTUP_BACKFILL_WAIT_TIMEOUT {
180            return Err(anyhow::anyhow!(
181                "timed out waiting for state db backfill at {} after {:?} (status: {})",
182                codex_home.display(),
183                STARTUP_BACKFILL_WAIT_TIMEOUT,
184                backfill_state.status.as_str()
185            ));
186        }
187
188        let message = format!(
189            "state db backfill is {} at {}; waiting up to {:?} before retrying startup initialization",
190            backfill_state.status.as_str(),
191            codex_home.display(),
192            STARTUP_BACKFILL_WAIT_TIMEOUT,
193        );
194        if reported_wait {
195            info!("{message}");
196        } else {
197            emit_startup_warning(&message);
198            reported_wait = true;
199        }
200        tokio::time::sleep(STARTUP_BACKFILL_POLL_INTERVAL).await;
201    }
202}
203
204fn emit_startup_warning(message: &str) {
205    warn!("{message}");
206    if !tracing::dispatcher::has_been_set() {
207        #[allow(clippy::print_stderr)]
208        {
209            eprintln!("{message}");
210        }
211    }
212}
213
214/// Open the DB if it exists and its startup backfill has already completed.
215///
216/// Unlike [`init`], this helper does not run rollout backfill. It is for
217/// optional local reads from non-owning contexts such as remote app-server mode.
218pub async fn get_state_db(config: &impl RolloutConfigView) -> Option<StateDbHandle> {
219    let state_path = codex_state::state_db_path(config.sqlite_home());
220    if !tokio::fs::try_exists(&state_path).await.unwrap_or(false) {
221        codex_state::record_fallback(
222            "get_state_db",
223            "db_unavailable",
224            /*telemetry_override*/ None,
225        );
226        return None;
227    }
228    let runtime = match codex_state::StateRuntime::init(
229        config.sqlite_home().to_path_buf(),
230        config.model_provider_id().to_string(),
231    )
232    .await
233    {
234        Ok(runtime) => runtime,
235        Err(_) => {
236            codex_state::record_fallback(
237                "get_state_db",
238                "db_error",
239                /*telemetry_override*/ None,
240            );
241            return None;
242        }
243    };
244    require_backfill_complete(runtime, config.sqlite_home()).await
245}
246
247/// Build a SQLite telemetry recorder backed by an OTEL metrics client.
248pub fn sqlite_telemetry_recorder(
249    metrics: codex_otel::MetricsClient,
250    originator: &str,
251) -> codex_state::DbTelemetryHandle {
252    sqlite_metrics::recorder(metrics, originator)
253}
254
255async fn require_backfill_complete(
256    runtime: StateDbHandle,
257    codex_home: &Path,
258) -> Option<StateDbHandle> {
259    match runtime.get_backfill_state().await {
260        Ok(state) if state.status == codex_state::BackfillStatus::Complete => Some(runtime),
261        Ok(state) => {
262            warn!(
263                "state db backfill not complete at {} (status: {})",
264                codex_home.display(),
265                state.status.as_str()
266            );
267            codex_state::record_fallback(
268                "get_state_db",
269                "backfill_incomplete",
270                /*telemetry_override*/ None,
271            );
272            None
273        }
274        Err(err) => {
275            warn!(
276                "failed to read backfill state at {}: {err}",
277                codex_home.display()
278            );
279            codex_state::record_fallback(
280                "get_state_db",
281                "db_error",
282                /*telemetry_override*/ None,
283            );
284            None
285        }
286    }
287}
288
289fn cursor_to_anchor(cursor: Option<&Cursor>) -> Option<codex_state::Anchor> {
290    let cursor = cursor?;
291    let millis = cursor.timestamp().unix_timestamp_nanos() / 1_000_000;
292    let millis = i64::try_from(millis).ok()?;
293    let ts = chrono::DateTime::<Utc>::from_timestamp_millis(millis)?;
294    Some(codex_state::Anchor {
295        ts,
296        id: cursor.thread_id(),
297    })
298}
299
300pub fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf {
301    normalize_for_path_comparison(cwd).unwrap_or_else(|_| cwd.to_path_buf())
302}
303
304/// List thread ids from SQLite for parity checks without rollout scanning.
305#[allow(clippy::too_many_arguments)]
306pub async fn list_thread_ids_db(
307    context: Option<&codex_state::StateRuntime>,
308    codex_home: &Path,
309    page_size: usize,
310    cursor: Option<&Cursor>,
311    sort_key: ThreadSortKey,
312    allowed_sources: &[SessionSource],
313    model_providers: Option<&[String]>,
314    archived_only: bool,
315    stage: &str,
316) -> Option<Vec<ThreadId>> {
317    let ctx = context?;
318    if ctx.codex_home() != codex_home {
319        warn!(
320            "state db codex_home mismatch: expected {}, got {}",
321            ctx.codex_home().display(),
322            codex_home.display()
323        );
324    }
325
326    let anchor = cursor_to_anchor(cursor);
327    let allowed_sources: Vec<String> = allowed_sources
328        .iter()
329        .map(|value| match serde_json::to_value(value) {
330            Ok(Value::String(s)) => s,
331            Ok(other) => other.to_string(),
332            Err(_) => String::new(),
333        })
334        .collect();
335    let model_providers = model_providers.map(<[String]>::to_vec);
336    match ctx
337        .list_thread_ids(
338            page_size,
339            anchor.as_ref(),
340            match sort_key {
341                ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt,
342                ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt,
343                ThreadSortKey::RecencyAt => codex_state::SortKey::RecencyAt,
344            },
345            allowed_sources.as_slice(),
346            model_providers.as_deref(),
347            archived_only,
348        )
349        .await
350    {
351        Ok(ids) => Some(ids),
352        Err(err) => {
353            warn!("state db list_thread_ids failed during {stage}: {err}");
354            None
355        }
356    }
357}
358
359/// List thread metadata from SQLite without rollout directory traversal.
360#[allow(clippy::too_many_arguments)]
361pub async fn list_threads_db(
362    context: Option<&codex_state::StateRuntime>,
363    codex_home: &Path,
364    page_size: usize,
365    cursor: Option<&Cursor>,
366    sort_key: ThreadSortKey,
367    sort_direction: SortDirection,
368    allowed_sources: &[SessionSource],
369    model_providers: Option<&[String]>,
370    cwd_filters: Option<&[PathBuf]>,
371    relation_filter: Option<codex_state::ThreadRelationFilter>,
372    archived: bool,
373    search_term: Option<&str>,
374) -> Option<codex_state::ThreadsPage> {
375    let ctx = context?;
376    if ctx.codex_home() != codex_home {
377        warn!(
378            "state db codex_home mismatch: expected {}, got {}",
379            ctx.codex_home().display(),
380            codex_home.display()
381        );
382    }
383
384    let anchor = cursor_to_anchor(cursor);
385    let allowed_sources: Vec<String> = allowed_sources
386        .iter()
387        .map(|value| match serde_json::to_value(value) {
388            Ok(Value::String(s)) => s,
389            Ok(other) => other.to_string(),
390            Err(_) => String::new(),
391        })
392        .collect();
393    let model_providers = model_providers.map(<[String]>::to_vec);
394    let normalized_cwd_filters = cwd_filters.map(|filters| {
395        filters
396            .iter()
397            .map(|cwd| normalize_cwd_for_state_db(cwd))
398            .collect::<Vec<_>>()
399    });
400    let filters = codex_state::ThreadFilterOptions {
401        archived_only: archived,
402        allowed_sources: allowed_sources.as_slice(),
403        model_providers: model_providers.as_deref(),
404        cwd_filters: normalized_cwd_filters.as_deref(),
405        anchor: anchor.as_ref(),
406        sort_key: match sort_key {
407            ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt,
408            ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt,
409            ThreadSortKey::RecencyAt => codex_state::SortKey::RecencyAt,
410        },
411        sort_direction: match sort_direction {
412            SortDirection::Asc => codex_state::SortDirection::Asc,
413            SortDirection::Desc => codex_state::SortDirection::Desc,
414        },
415        search_term,
416    };
417    let page = match relation_filter {
418        Some(relation_filter) => {
419            ctx.list_threads_by_relation(page_size, relation_filter, filters)
420                .await
421        }
422        None => ctx.list_threads(page_size, filters).await,
423    };
424    match page {
425        Ok(mut page) => {
426            // Relationship-filtered listings intentionally treat persisted state as authoritative.
427            if relation_filter.is_some() {
428                return Some(page);
429            }
430            let mut valid_items = Vec::with_capacity(page.items.len());
431            for item in page.items {
432                if let Some(existing_path) =
433                    crate::compression::existing_rollout_path(item.rollout_path.as_path()).await
434                {
435                    let mut item = item;
436                    item.rollout_path = existing_path;
437                    valid_items.push(item);
438                } else {
439                    warn!(
440                        "state db list_threads returned stale rollout path for thread {}: {}",
441                        item.id,
442                        item.rollout_path.display()
443                    );
444                    warn!("state db discrepancy during list_threads_db: stale_db_path_dropped");
445                    let _ = ctx.delete_thread(item.id).await;
446                }
447            }
448            page.items = valid_items;
449            Some(page)
450        }
451        Err(err) => {
452            warn!("state db list_threads failed: {err}");
453            None
454        }
455    }
456}
457
458/// Look up the rollout path for a thread id using SQLite.
459pub async fn find_rollout_path_by_id(
460    context: Option<&codex_state::StateRuntime>,
461    thread_id: ThreadId,
462    archived_only: Option<bool>,
463    stage: &str,
464) -> Option<PathBuf> {
465    let ctx = context?;
466    ctx.find_rollout_path_by_id(thread_id, archived_only)
467        .await
468        .unwrap_or_else(|err| {
469            warn!("state db find_rollout_path_by_id failed during {stage}: {err}");
470            None
471        })
472}
473
474pub async fn mark_thread_memory_mode_polluted(
475    context: Option<&codex_state::StateRuntime>,
476    thread_id: ThreadId,
477    stage: &str,
478) {
479    let Some(ctx) = context else {
480        return;
481    };
482    if let Err(err) = ctx
483        .memories()
484        .mark_thread_memory_mode_polluted(thread_id)
485        .await
486    {
487        warn!("memories db mark_thread_memory_mode_polluted failed during {stage}: {err}");
488    }
489}
490
491/// Reconcile rollout items into SQLite, falling back to scanning the rollout file.
492pub async fn reconcile_rollout(
493    context: Option<&codex_state::StateRuntime>,
494    rollout_path: &Path,
495    default_provider: &str,
496    builder: Option<&ThreadMetadataBuilder>,
497    items: &[RolloutItem],
498    archived_only: Option<bool>,
499    new_thread_memory_mode: Option<&str>,
500) {
501    let Some(ctx) = context else {
502        return;
503    };
504    if builder.is_some() || !items.is_empty() {
505        apply_rollout_items(
506            Some(ctx),
507            rollout_path,
508            default_provider,
509            builder,
510            items,
511            "reconcile_rollout",
512            new_thread_memory_mode,
513            /*updated_at_override*/ None,
514        )
515        .await;
516        return;
517    }
518    let outcome =
519        match metadata::extract_metadata_from_rollout(rollout_path, default_provider).await {
520            Ok(outcome) => outcome,
521            Err(err) => {
522                warn!(
523                    "state db reconcile_rollout extraction failed {}: {err}",
524                    rollout_path.display()
525                );
526                return;
527            }
528        };
529    let mut metadata = outcome.metadata;
530    let memory_mode = outcome.memory_mode.unwrap_or_else(|| "enabled".to_string());
531    metadata.cwd = normalize_cwd_for_state_db(&metadata.cwd);
532    let existing_metadata = ctx.get_thread(metadata.id).await.ok().flatten();
533    // Paginated metadata updates are SQLite-only. Use the rollout mode to seed a
534    // missing row, then keep the value from SQLite.
535    let restore_memory_mode_from_rollout =
536        existing_metadata.is_none() || matches!(metadata.history_mode, ThreadHistoryMode::Legacy);
537    if let Some(existing_metadata) = existing_metadata.as_ref() {
538        metadata.prefer_existing_git_info(existing_metadata);
539        metadata.prefer_existing_explicit_title(existing_metadata);
540    }
541    match archived_only {
542        Some(true) if metadata.archived_at.is_none() => {
543            metadata.archived_at = Some(metadata.updated_at);
544        }
545        Some(false) => {
546            metadata.archived_at = None;
547        }
548        Some(true) | None => {}
549    }
550    if let Err(err) = ctx.upsert_thread(&metadata).await {
551        warn!(
552            "state db reconcile_rollout upsert failed {}: {err}",
553            rollout_path.display()
554        );
555        return;
556    }
557    if restore_memory_mode_from_rollout
558        && let Err(err) = ctx
559            .set_thread_memory_mode(metadata.id, memory_mode.as_str())
560            .await
561    {
562        warn!(
563            "state db reconcile_rollout memory_mode update failed {}: {err}",
564            rollout_path.display()
565        );
566    }
567}
568
569/// Repair a thread's rollout path after filesystem fallback succeeds.
570pub async fn read_repair_rollout_path(
571    context: Option<&codex_state::StateRuntime>,
572    thread_id: Option<ThreadId>,
573    archived_only: Option<bool>,
574    rollout_path: &Path,
575) {
576    let Some(ctx) = context else {
577        return;
578    };
579
580    // Fast path: update an existing metadata row in place, but avoid writes when
581    // read-repair computes no effective change.
582    let mut saw_existing_metadata = false;
583    if let Some(thread_id) = thread_id
584        && let Ok(Some(metadata)) = ctx.get_thread(thread_id).await
585    {
586        saw_existing_metadata = true;
587        let mut repaired = metadata.clone();
588        repaired.rollout_path = rollout_path.to_path_buf();
589        repaired.cwd = normalize_cwd_for_state_db(&repaired.cwd);
590        match archived_only {
591            Some(true) if repaired.archived_at.is_none() => {
592                repaired.archived_at = Some(repaired.updated_at);
593            }
594            Some(false) => {
595                repaired.archived_at = None;
596            }
597            Some(true) | None => {}
598        }
599        if repaired == metadata {
600            return;
601        }
602        warn!("state db discrepancy during read_repair_rollout_path: upsert_needed (fast path)");
603        if let Err(err) = ctx.upsert_thread(&repaired).await {
604            warn!(
605                "state db read-repair upsert failed for {}: {err}",
606                rollout_path.display()
607            );
608        } else {
609            return;
610        }
611    }
612
613    // Slow path: when the row is missing/unreadable (or direct upsert failed),
614    // rebuild metadata from rollout contents and reconcile it into SQLite.
615    if !saw_existing_metadata {
616        warn!("state db discrepancy during read_repair_rollout_path: upsert_needed (slow path)");
617    }
618    let default_provider = crate::list::read_session_meta_line(rollout_path)
619        .await
620        .ok()
621        .and_then(|meta| meta.meta.model_provider)
622        .unwrap_or_default();
623    reconcile_rollout(
624        Some(ctx),
625        rollout_path,
626        default_provider.as_str(),
627        /*builder*/ None,
628        &[],
629        archived_only,
630        /*new_thread_memory_mode*/ None,
631    )
632    .await;
633}
634
635/// Apply rollout items incrementally to SQLite.
636#[allow(clippy::too_many_arguments)]
637pub async fn apply_rollout_items(
638    context: Option<&codex_state::StateRuntime>,
639    rollout_path: &Path,
640    default_provider: &str,
641    builder: Option<&ThreadMetadataBuilder>,
642    items: &[RolloutItem],
643    stage: &str,
644    new_thread_memory_mode: Option<&str>,
645    updated_at_override: Option<DateTime<Utc>>,
646) {
647    let Some(ctx) = context else {
648        return;
649    };
650    let mut builder = match builder {
651        Some(builder) => builder.clone(),
652        None => match metadata::builder_from_items(items, rollout_path) {
653            Some(builder) => builder,
654            None => {
655                warn!(
656                    "state db apply_rollout_items missing builder during {stage}: {}",
657                    rollout_path.display()
658                );
659                warn!("state db discrepancy during apply_rollout_items: {stage}, missing_builder");
660                return;
661            }
662        },
663    };
664    if builder.model_provider.is_none() {
665        builder.model_provider = Some(default_provider.to_string());
666    }
667    builder.rollout_path = rollout_path.to_path_buf();
668    builder.cwd = normalize_cwd_for_state_db(&builder.cwd);
669    if let Err(err) = ctx
670        .apply_rollout_items(&builder, items, new_thread_memory_mode, updated_at_override)
671        .await
672    {
673        warn!(
674            "state db apply_rollout_items failed during {stage} for {}: {err}",
675            rollout_path.display()
676        );
677    }
678}
679
680pub async fn touch_thread_updated_at(
681    context: Option<&codex_state::StateRuntime>,
682    thread_id: Option<ThreadId>,
683    updated_at: DateTime<Utc>,
684    stage: &str,
685) -> bool {
686    let Some(ctx) = context else {
687        return false;
688    };
689    let Some(thread_id) = thread_id else {
690        return false;
691    };
692    ctx.touch_thread_updated_at(thread_id, updated_at)
693        .await
694        .unwrap_or_else(|err| {
695            warn!("state db touch_thread_updated_at failed during {stage} for {thread_id}: {err}");
696            false
697        })
698}
699
700#[cfg(test)]
701#[path = "state_db_tests.rs"]
702mod tests;