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
27pub 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
39pub 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
61pub 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 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 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
214pub 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 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 None,
240 );
241 return None;
242 }
243 };
244 require_backfill_complete(runtime, config.sqlite_home()).await
245}
246
247pub 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 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 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#[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#[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 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
458pub 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
491pub 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 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 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
569pub 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 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 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 None,
628 &[],
629 archived_only,
630 None,
631 )
632 .await;
633}
634
635#[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;