Add thread metadata update endpoint to app server (#13280)

## Summary
- add the v2 `thread/metadata/update` API, including
protocol/schema/TypeScript exports and app-server docs
- patch stored thread `gitInfo` in sqlite without resuming the thread,
with validation plus support for explicit `null` clears
- repair missing sqlite thread rows from rollout data before patching,
and make those repairs safe by inserting only when absent and updating
only git columns so newer metadata is not clobbered
- keep sqlite authoritative for mutable thread git metadata by
preserving existing sqlite git fields during reconcile/backfill and only
using rollout `SessionMeta` git fields to fill gaps
- add regression coverage for the endpoint, repair paths, concurrent
sqlite writes, clearing git fields, and rollout/backfill reconciliation
- fix the login server shutdown race so cancelling before the waiter
starts still terminates `block_until_done()` correctly

## Testing
- `cargo test -p codex-state
apply_rollout_items_preserves_existing_git_branch_and_fills_missing_git_fields`
- `cargo test -p codex-state
update_thread_git_info_preserves_newer_non_git_metadata`
- `cargo test -p codex-core
backfill_sessions_preserves_existing_git_branch_and_fills_missing_git_fields`
- `cargo test -p codex-app-server thread_metadata_update`
- `cargo test`
- currently fails in existing `codex-core` grep-files tests with
`unsupported call: grep_files`:
    - `suite::grep_files::grep_files_tool_collects_matches`
    - `suite::grep_files::grep_files_tool_reports_empty_results`
This commit is contained in:
joeytrasatti-openai
2026-03-03 15:56:11 -08:00
committed by GitHub
parent 299b8ac445
commit 935754baa3
24 changed files with 3251 additions and 6 deletions

View File

@@ -207,6 +207,64 @@ FROM threads
.await
}
pub async fn insert_thread_if_absent(
&self,
metadata: &crate::ThreadMetadata,
) -> anyhow::Result<bool> {
let result = sqlx::query(
r#"
INSERT INTO threads (
id,
rollout_path,
created_at,
updated_at,
source,
agent_nickname,
agent_role,
model_provider,
cwd,
cli_version,
title,
sandbox_policy,
approval_mode,
tokens_used,
first_user_message,
archived,
archived_at,
git_sha,
git_branch,
git_origin_url,
memory_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
"#,
)
.bind(metadata.id.to_string())
.bind(metadata.rollout_path.display().to_string())
.bind(datetime_to_epoch_seconds(metadata.created_at))
.bind(datetime_to_epoch_seconds(metadata.updated_at))
.bind(metadata.source.as_str())
.bind(metadata.agent_nickname.as_deref())
.bind(metadata.agent_role.as_deref())
.bind(metadata.model_provider.as_str())
.bind(metadata.cwd.display().to_string())
.bind(metadata.cli_version.as_str())
.bind(metadata.title.as_str())
.bind(metadata.sandbox_policy.as_str())
.bind(metadata.approval_mode.as_str())
.bind(metadata.tokens_used)
.bind(metadata.first_user_message.as_deref().unwrap_or_default())
.bind(metadata.archived_at.is_some())
.bind(metadata.archived_at.map(datetime_to_epoch_seconds))
.bind(metadata.git_sha.as_deref())
.bind(metadata.git_branch.as_deref())
.bind(metadata.git_origin_url.as_deref())
.bind("enabled")
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn set_thread_memory_mode(
&self,
thread_id: ThreadId,
@@ -220,6 +278,35 @@ FROM threads
Ok(result.rows_affected() > 0)
}
pub async fn update_thread_git_info(
&self,
thread_id: ThreadId,
git_sha: Option<Option<&str>>,
git_branch: Option<Option<&str>>,
git_origin_url: Option<Option<&str>>,
) -> anyhow::Result<bool> {
let result = sqlx::query(
r#"
UPDATE threads
SET
git_sha = CASE WHEN ? THEN ? ELSE git_sha END,
git_branch = CASE WHEN ? THEN ? ELSE git_branch END,
git_origin_url = CASE WHEN ? THEN ? ELSE git_origin_url END
WHERE id = ?
"#,
)
.bind(git_sha.is_some())
.bind(git_sha.flatten())
.bind(git_branch.is_some())
.bind(git_branch.flatten())
.bind(git_origin_url.is_some())
.bind(git_origin_url.flatten())
.bind(thread_id.to_string())
.execute(self.pool.as_ref())
.await?;
Ok(result.rows_affected() > 0)
}
async fn upsert_thread_with_creation_memory_mode(
&self,
metadata: &crate::ThreadMetadata,
@@ -361,6 +448,9 @@ ON CONFLICT(thread_id, position) DO NOTHING
for item in items {
apply_rollout_item(&mut metadata, item, &self.default_provider);
}
if let Some(existing_metadata) = existing_metadata.as_ref() {
metadata.prefer_existing_git_info(existing_metadata);
}
if let Some(updated_at) = file_modified_time_utc(builder.rollout_path.as_path()).await {
metadata.updated_at = updated_at;
}
@@ -559,6 +649,7 @@ mod tests {
use super::*;
use crate::runtime::test_support::test_thread_metadata;
use crate::runtime::test_support::unique_temp_dir;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
@@ -654,4 +745,206 @@ mod tests {
.expect("memory mode should load");
assert_eq!(memory_mode.as_deref(), Some("polluted"));
}
#[tokio::test]
async fn apply_rollout_items_preserves_existing_git_branch_and_fills_missing_git_fields() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000457").expect("valid thread id");
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
metadata.git_branch = Some("sqlite-branch".to_string());
runtime
.upsert_thread(&metadata)
.await
.expect("initial upsert should succeed");
let created_at = metadata.created_at.to_rfc3339();
let builder = ThreadMetadataBuilder::new(
thread_id,
metadata.rollout_path.clone(),
metadata.created_at,
SessionSource::Cli,
);
let items = vec![RolloutItem::SessionMeta(SessionMetaLine {
meta: SessionMeta {
id: thread_id,
forked_from_id: None,
timestamp: created_at,
cwd: PathBuf::new(),
originator: String::new(),
cli_version: String::new(),
source: SessionSource::Cli,
agent_nickname: None,
agent_role: None,
model_provider: None,
base_instructions: None,
dynamic_tools: None,
memory_mode: None,
},
git: Some(GitInfo {
commit_hash: Some("rollout-sha".to_string()),
branch: Some("rollout-branch".to_string()),
repository_url: Some("git@example.com:openai/codex.git".to_string()),
}),
})];
runtime
.apply_rollout_items(&builder, &items, None, None)
.await
.expect("apply_rollout_items should succeed");
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.git_sha.as_deref(), Some("rollout-sha"));
assert_eq!(persisted.git_branch.as_deref(), Some("sqlite-branch"));
assert_eq!(
persisted.git_origin_url.as_deref(),
Some("git@example.com:openai/codex.git")
);
}
#[tokio::test]
async fn update_thread_git_info_preserves_newer_non_git_metadata() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000789").expect("valid thread id");
let metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
runtime
.upsert_thread(&metadata)
.await
.expect("initial upsert should succeed");
let updated_at = datetime_to_epoch_seconds(
DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp"),
);
sqlx::query(
"UPDATE threads SET updated_at = ?, tokens_used = ?, first_user_message = ? WHERE id = ?",
)
.bind(updated_at)
.bind(123_i64)
.bind("newer preview")
.bind(thread_id.to_string())
.execute(runtime.pool.as_ref())
.await
.expect("concurrent metadata write should succeed");
let updated = runtime
.update_thread_git_info(
thread_id,
Some(Some("abc123")),
Some(Some("feature/branch")),
Some(Some("git@example.com:openai/codex.git")),
)
.await
.expect("git info update should succeed");
assert!(updated, "git info update should touch the thread row");
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.tokens_used, 123);
assert_eq!(
persisted.first_user_message.as_deref(),
Some("newer preview")
);
assert_eq!(datetime_to_epoch_seconds(persisted.updated_at), updated_at);
assert_eq!(persisted.git_sha.as_deref(), Some("abc123"));
assert_eq!(persisted.git_branch.as_deref(), Some("feature/branch"));
assert_eq!(
persisted.git_origin_url.as_deref(),
Some("git@example.com:openai/codex.git")
);
}
#[tokio::test]
async fn insert_thread_if_absent_preserves_existing_metadata() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000791").expect("valid thread id");
let mut existing = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
existing.tokens_used = 123;
existing.first_user_message = Some("newer preview".to_string());
existing.updated_at = DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp");
runtime
.upsert_thread(&existing)
.await
.expect("initial upsert should succeed");
let mut fallback = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
fallback.tokens_used = 0;
fallback.first_user_message = None;
fallback.updated_at = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("timestamp");
let inserted = runtime
.insert_thread_if_absent(&fallback)
.await
.expect("insert should succeed");
assert!(!inserted, "existing rows should not be overwritten");
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.tokens_used, 123);
assert_eq!(
persisted.first_user_message.as_deref(),
Some("newer preview")
);
assert_eq!(
datetime_to_epoch_seconds(persisted.updated_at),
datetime_to_epoch_seconds(existing.updated_at)
);
}
#[tokio::test]
async fn update_thread_git_info_can_clear_fields() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000790").expect("valid thread id");
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
metadata.git_sha = Some("abc123".to_string());
metadata.git_branch = Some("feature/branch".to_string());
metadata.git_origin_url = Some("git@example.com:openai/codex.git".to_string());
runtime
.upsert_thread(&metadata)
.await
.expect("initial upsert should succeed");
let updated = runtime
.update_thread_git_info(thread_id, Some(None), Some(None), Some(None))
.await
.expect("git info clear should succeed");
assert!(updated, "git info clear should touch the thread row");
let persisted = runtime
.get_thread(thread_id)
.await
.expect("thread should load")
.expect("thread should exist");
assert_eq!(persisted.git_sha, None);
assert_eq!(persisted.git_branch, None);
assert_eq!(persisted.git_origin_url, None);
}
}