feat: dedicated goal DB (#23300)

## Why

Thread goals are moving toward extension-owned runtime behavior, but
their persisted state was still stored in the shared state database.
This makes the goal store harder to isolate and keeps future storage
splits tied to ad hoc runtime plumbing.

This PR gives goals their own SQLite database while keeping the existing
`StateRuntime` entry point. The goal is to make this the pattern for
adding more dedicated runtime databases later.

This also reduce load on existing DB and reduce contention

## Limitation
Thread preview from goal is not supported anymore. I'm looking into this
[EDIT]: solved

## What changed

- Added a dedicated `goals_1.sqlite` database with its own
`goals_migrations` directory.
- Moved `thread_goals` creation into the goals DB migration set.
- Dropped the old `thread_goals` table from the main state DB with a
normal state migration. There is intentionally no backfill for existing
goal rows.
- Changed `GoalStore` to be backed only by the goals DB pool.
- Removed the old goal-write side effect that filled empty
`threads.preview` values from the goal objective.
- Added shared runtime DB path metadata so startup, telemetry, `codex
doctor`, and repair handling can include future DBs without bespoke path
lists.
- Updated Bazel compile data so the new goals migration directory is
available to `sqlx::migrate!`.

## Verification

- `cargo check --tests -p codex-state -p codex-cli -p codex-core -p
codex-app-server`
- `just fix -p codex-state`
- `just fix -p codex-cli`
- `just fix -p codex-app-server`
This commit is contained in:
jif-oai
2026-05-19 11:11:41 +02:00
committed by GitHub
parent 826b2182ed
commit ba57aab13a
18 changed files with 330 additions and 116 deletions
+64
View File
@@ -51,6 +51,29 @@ WHERE threads.id = ?
Ok(row.and_then(|row| row.try_get("memory_mode").ok()))
}
pub async fn set_thread_preview_if_empty(
&self,
thread_id: ThreadId,
preview: &str,
) -> anyhow::Result<bool> {
let preview = preview.trim();
if preview.is_empty() {
return Ok(false);
}
let result = sqlx::query(
r#"
UPDATE threads
SET preview = ?
WHERE id = ? AND preview = ''
"#,
)
.bind(preview)
.bind(thread_id.to_string())
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
/// Get dynamic tools for a thread, if present.
pub async fn get_dynamic_tools(
&self,
@@ -1569,6 +1592,47 @@ mod tests {
assert_eq!(persisted.preview.as_deref(), Some("migrated goal preview"));
}
#[tokio::test]
async fn set_thread_preview_if_empty_only_fills_blank_preview() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000460").expect("valid thread id");
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
metadata.first_user_message = None;
metadata.preview = None;
runtime
.upsert_thread(&metadata)
.await
.expect("initial upsert should succeed");
let empty_updated = runtime
.set_thread_preview_if_empty(thread_id, " ")
.await
.expect("empty preview update should succeed");
assert!(!empty_updated);
let goal_updated = runtime
.set_thread_preview_if_empty(thread_id, " goal preview ")
.await
.expect("goal preview update should succeed");
assert!(goal_updated);
let overwrite_updated = runtime
.set_thread_preview_if_empty(thread_id, "new preview")
.await
.expect("overwrite preview update should succeed");
assert!(!overwrite_updated);
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.preview.as_deref(), Some("goal preview"));
}
#[tokio::test]
async fn update_thread_git_info_preserves_newer_non_git_metadata() {
let codex_home = unique_temp_dir();