mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Unify thread metadata updates above store (#22236)
- make ThreadStore::update_thread_metadata accept a broad range of metadata patches - keep ThreadStore::append_items as raw canonical history append (no metadata side effects) - in the local store, write these metadata updates to a combination of sqlite and rollout jsonl files for backwards-compat. It special cases which fields need to go into jsonl vs sqlite vs whatever, confining the awkwardness to just this implementation - in remote stores we can simply persist the metadata directly to a database, no special casing required. - move the "implicit metadata updates triggered by appending rollout items" from the RolloutRecorder (which is local-threadstore-specific) to the LiveThread layer above the ThreadStore, inside of a private helper utility called ThreadMetadataSync. LiveThread calls ThreadStore append_items and update_metadata separately. - Add a generic update metadata method to ThreadManager that works on both live threads and "cold" threads - Call that ThreadManager method from app server code, so app server doesn't need to worry about whether the thread is live or not
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
use super::LocalThreadStore;
|
||||
use crate::CreateThreadParams;
|
||||
use crate::ThreadEventPersistenceMode;
|
||||
use crate::ThreadStoreError;
|
||||
use crate::ThreadStoreResult;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
use codex_rollout::EventPersistenceMode;
|
||||
use codex_rollout::RolloutConfig;
|
||||
use codex_rollout::RolloutRecorder;
|
||||
use codex_rollout::RolloutRecorderParams;
|
||||
@@ -27,7 +25,6 @@ pub(super) async fn create_thread(
|
||||
model_provider_id: params.metadata.model_provider.clone(),
|
||||
generate_memories: matches!(params.metadata.memory_mode, ThreadMemoryMode::Enabled),
|
||||
};
|
||||
let state_db_ctx = store.state_db().await;
|
||||
let recorder = RolloutRecorder::new(
|
||||
&config,
|
||||
RolloutRecorderParams::new(
|
||||
@@ -37,10 +34,7 @@ pub(super) async fn create_thread(
|
||||
params.thread_source,
|
||||
params.base_instructions,
|
||||
params.dynamic_tools,
|
||||
event_persistence_mode(params.event_persistence_mode),
|
||||
),
|
||||
state_db_ctx,
|
||||
/*state_builder*/ None,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
@@ -49,10 +43,3 @@ pub(super) async fn create_thread(
|
||||
|
||||
Ok(recorder)
|
||||
}
|
||||
|
||||
pub(super) fn event_persistence_mode(mode: ThreadEventPersistenceMode) -> EventPersistenceMode {
|
||||
match mode {
|
||||
ThreadEventPersistenceMode::Limited => EventPersistenceMode::Limited,
|
||||
ThreadEventPersistenceMode::Extended => EventPersistenceMode::Extended,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use codex_protocol::protocol::ThreadMemoryMode;
|
||||
use codex_rollout::RolloutConfig;
|
||||
use codex_rollout::RolloutRecorder;
|
||||
use codex_rollout::RolloutRecorderParams;
|
||||
use codex_rollout::builder_from_items;
|
||||
use tracing::warn;
|
||||
|
||||
use super::LocalThreadStore;
|
||||
use super::create_thread;
|
||||
@@ -31,8 +31,8 @@ pub(super) async fn resume_thread(
|
||||
params: ResumeThreadParams,
|
||||
) -> ThreadStoreResult<()> {
|
||||
store.ensure_live_recorder_absent(params.thread_id).await?;
|
||||
let (rollout_path, history) = match (params.rollout_path, params.history) {
|
||||
(Some(rollout_path), history) => (rollout_path, history),
|
||||
let rollout_path = match (params.rollout_path, params.history) {
|
||||
(Some(rollout_path), _history) => rollout_path,
|
||||
(None, history) => {
|
||||
let thread = super::read_thread::read_thread(
|
||||
store,
|
||||
@@ -43,20 +43,14 @@ pub(super) async fn resume_thread(
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let rollout_path = thread
|
||||
|
||||
thread
|
||||
.rollout_path
|
||||
.ok_or_else(|| ThreadStoreError::Internal {
|
||||
message: format!("thread {} does not have a rollout path", params.thread_id),
|
||||
})?;
|
||||
(
|
||||
rollout_path,
|
||||
history.or_else(|| thread.history.map(|history| history.items)),
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
let state_builder = history
|
||||
.as_deref()
|
||||
.and_then(|items| builder_from_items(items, rollout_path.as_path()));
|
||||
let cwd = params
|
||||
.metadata
|
||||
.cwd
|
||||
@@ -71,20 +65,11 @@ pub(super) async fn resume_thread(
|
||||
model_provider_id: params.metadata.model_provider.clone(),
|
||||
generate_memories: matches!(params.metadata.memory_mode, ThreadMemoryMode::Enabled),
|
||||
};
|
||||
let state_db_ctx = store.state_db().await;
|
||||
let recorder = RolloutRecorder::new(
|
||||
&config,
|
||||
RolloutRecorderParams::resume(
|
||||
rollout_path,
|
||||
create_thread::event_persistence_mode(params.event_persistence_mode),
|
||||
),
|
||||
state_db_ctx,
|
||||
state_builder,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
message: format!("failed to resume local thread recorder: {err}"),
|
||||
})?;
|
||||
let recorder = RolloutRecorder::new(&config, RolloutRecorderParams::resume(rollout_path))
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
message: format!("failed to resume local thread recorder: {err}"),
|
||||
})?;
|
||||
store.insert_live_recorder(params.thread_id, recorder).await
|
||||
}
|
||||
|
||||
@@ -92,12 +77,14 @@ pub(super) async fn append_items(
|
||||
store: &LocalThreadStore,
|
||||
params: AppendThreadItemsParams,
|
||||
) -> ThreadStoreResult<()> {
|
||||
store
|
||||
.live_recorder(params.thread_id)
|
||||
.await?
|
||||
.record_items(params.items.as_slice())
|
||||
let recorder = store.live_recorder(params.thread_id).await?;
|
||||
recorder
|
||||
.record_canonical_items(params.items.as_slice())
|
||||
.await
|
||||
.map_err(thread_store_io_error)
|
||||
.map_err(thread_store_io_error)?;
|
||||
// LiveThread applies metadata immediately after append_items returns. Wait for the local
|
||||
// writer so SQLite never gets ahead of JSONL for accepted live appends.
|
||||
recorder.flush().await.map_err(thread_store_io_error)
|
||||
}
|
||||
|
||||
pub(super) async fn persist_thread(
|
||||
@@ -109,7 +96,8 @@ pub(super) async fn persist_thread(
|
||||
.await?
|
||||
.persist()
|
||||
.await
|
||||
.map_err(thread_store_io_error)
|
||||
.map_err(thread_store_io_error)?;
|
||||
sync_materialized_rollout_path(store, thread_id).await
|
||||
}
|
||||
|
||||
pub(super) async fn flush_thread(
|
||||
@@ -121,7 +109,8 @@ pub(super) async fn flush_thread(
|
||||
.await?
|
||||
.flush()
|
||||
.await
|
||||
.map_err(thread_store_io_error)
|
||||
.map_err(thread_store_io_error)?;
|
||||
sync_materialized_rollout_path(store, thread_id).await
|
||||
}
|
||||
|
||||
pub(super) async fn shutdown_thread(
|
||||
@@ -130,6 +119,7 @@ pub(super) async fn shutdown_thread(
|
||||
) -> ThreadStoreResult<()> {
|
||||
let recorder = store.live_recorder(thread_id).await?;
|
||||
recorder.shutdown().await.map_err(thread_store_io_error)?;
|
||||
sync_materialized_rollout_path(store, thread_id).await?;
|
||||
store.live_recorders.lock().await.remove(&thread_id);
|
||||
Ok(())
|
||||
}
|
||||
@@ -161,6 +151,49 @@ pub(super) async fn rollout_path(
|
||||
.to_path_buf())
|
||||
}
|
||||
|
||||
async fn sync_materialized_rollout_path(
|
||||
store: &LocalThreadStore,
|
||||
thread_id: ThreadId,
|
||||
) -> ThreadStoreResult<()> {
|
||||
let rollout_path = rollout_path(store, thread_id).await?;
|
||||
if !tokio::fs::try_exists(rollout_path.as_path())
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let Some(state_db) = store.state_db().await else {
|
||||
return Ok(());
|
||||
};
|
||||
let result: ThreadStoreResult<()> = async {
|
||||
let Some(mut metadata) =
|
||||
state_db
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
message: format!("failed to read thread metadata for {thread_id}: {err}"),
|
||||
})?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if metadata.rollout_path != rollout_path {
|
||||
metadata.rollout_path = rollout_path;
|
||||
state_db
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
message: format!("failed to update thread metadata for {thread_id}: {err}"),
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
if let Err(err) = result {
|
||||
warn!("failed to sync materialized rollout path for thread {thread_id}: {err}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn thread_store_io_error(err: std::io::Error) -> ThreadStoreError {
|
||||
ThreadStoreError::Internal {
|
||||
message: err.to_string(),
|
||||
|
||||
@@ -37,6 +37,18 @@ use crate::ThreadStoreResult;
|
||||
use crate::UpdateThreadMetadataParams;
|
||||
|
||||
/// Local filesystem/SQLite-backed implementation of [`ThreadStore`].
|
||||
///
|
||||
/// Local storage has two compatibility surfaces. Rollout JSONL files are the
|
||||
/// durable replay format and remain readable without SQLite, including older
|
||||
/// files that encode metadata in `SessionMeta` items and name-index entries.
|
||||
/// The SQLite state DB, when available, is the queryable metadata index used by
|
||||
/// list/read paths for fast lookup.
|
||||
///
|
||||
/// Live appends still write canonical JSONL history, but append-derived
|
||||
/// metadata is observed above the store and applied through
|
||||
/// [`ThreadStore::update_thread_metadata`]. This implementation applies that
|
||||
/// patch literally to SQLite while keeping the JSONL/name-index compatibility
|
||||
/// behavior needed for SQLite-less reads, repair, and old local rollout files.
|
||||
#[derive(Clone)]
|
||||
pub struct LocalThreadStore {
|
||||
pub(super) config: LocalThreadStoreConfig,
|
||||
@@ -270,6 +282,8 @@ impl ThreadStore for LocalThreadStore {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -280,6 +294,7 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
use crate::LiveThread;
|
||||
use crate::ThreadEventPersistenceMode;
|
||||
use crate::ThreadPersistenceMetadata;
|
||||
use crate::local::test_support::test_config;
|
||||
@@ -335,6 +350,267 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn raw_append_items_does_not_update_sqlite_metadata() {
|
||||
// This pins the ThreadStore contract: raw appends are history-only. Callers that need
|
||||
// metadata updates must use LiveThread or call update_thread_metadata explicitly.
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = LocalThreadStore::new(config, Some(runtime.clone()));
|
||||
let thread_id = ThreadId::default();
|
||||
|
||||
store
|
||||
.create_thread(create_thread_params(thread_id))
|
||||
.await
|
||||
.expect("create live thread");
|
||||
store
|
||||
.append_items(AppendThreadItemsParams {
|
||||
thread_id,
|
||||
items: vec![user_message_item("raw append")],
|
||||
})
|
||||
.await
|
||||
.expect("append raw item");
|
||||
store.flush_thread(thread_id).await.expect("flush thread");
|
||||
|
||||
assert_eq!(
|
||||
runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("sqlite metadata read"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_thread_observes_appended_items_into_sqlite_metadata() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone())));
|
||||
let thread_id = ThreadId::default();
|
||||
let live_thread = LiveThread::create(store.clone(), create_thread_params(thread_id))
|
||||
.await
|
||||
.expect("create live thread");
|
||||
|
||||
live_thread
|
||||
.append_items(&[user_message_item("observed append")])
|
||||
.await
|
||||
.expect("append observed item");
|
||||
live_thread.flush().await.expect("flush thread");
|
||||
|
||||
let metadata = runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("sqlite metadata read")
|
||||
.expect("sqlite metadata");
|
||||
assert_eq!(
|
||||
metadata.first_user_message.as_deref(),
|
||||
Some("observed append")
|
||||
);
|
||||
assert_eq!(metadata.preview.as_deref(), Some("observed append"));
|
||||
assert_eq!(metadata.title, "observed append");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_thread_shutdown_does_not_materialize_empty_thread_metadata() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone())));
|
||||
let thread_id = ThreadId::default();
|
||||
let live_thread = LiveThread::create(store.clone(), create_thread_params(thread_id))
|
||||
.await
|
||||
.expect("create live thread");
|
||||
let rollout_path = store
|
||||
.live_rollout_path(thread_id)
|
||||
.await
|
||||
.expect("live rollout path");
|
||||
|
||||
live_thread.shutdown().await.expect("shutdown thread");
|
||||
|
||||
assert!(
|
||||
!tokio::fs::try_exists(rollout_path.as_path())
|
||||
.await
|
||||
.expect("rollout path should be checkable")
|
||||
);
|
||||
assert_eq!(
|
||||
runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("sqlite metadata read"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_thread_shutdown_with_buffered_items_materializes_before_metadata_read() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone())));
|
||||
let thread_id = ThreadId::default();
|
||||
let live_thread = LiveThread::create(store.clone(), create_thread_params(thread_id))
|
||||
.await
|
||||
.expect("create live thread");
|
||||
let rollout_path = store
|
||||
.live_rollout_path(thread_id)
|
||||
.await
|
||||
.expect("live rollout path");
|
||||
|
||||
live_thread
|
||||
.append_items(&[RolloutItem::EventMsg(EventMsg::TokenCount(
|
||||
codex_protocol::protocol::TokenCountEvent {
|
||||
info: None,
|
||||
rate_limits: None,
|
||||
},
|
||||
))])
|
||||
.await
|
||||
.expect("append metadata-only item");
|
||||
live_thread.shutdown().await.expect("shutdown thread");
|
||||
|
||||
assert!(
|
||||
tokio::fs::try_exists(rollout_path.as_path())
|
||||
.await
|
||||
.expect("rollout path should be checkable")
|
||||
);
|
||||
let metadata = runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("sqlite metadata read")
|
||||
.expect("sqlite metadata");
|
||||
assert_eq!(metadata.rollout_path, rollout_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_thread_resume_loads_history_before_observing_metadata() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone())));
|
||||
let uuid = uuid::Uuid::from_u128(401);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
let rollout_path =
|
||||
write_session_file(home.path(), "2025-01-03T17-00-00", uuid).expect("session file");
|
||||
let live_thread = LiveThread::resume(
|
||||
store,
|
||||
ResumeThreadParams {
|
||||
thread_id,
|
||||
rollout_path: Some(rollout_path),
|
||||
history: None,
|
||||
include_archived: false,
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(home.path().to_path_buf()),
|
||||
model_provider: "different-provider".to_string(),
|
||||
memory_mode: ThreadMemoryMode::Enabled,
|
||||
},
|
||||
event_persistence_mode: ThreadEventPersistenceMode::Limited,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("resume live thread");
|
||||
|
||||
live_thread
|
||||
.append_items(&[user_message_item("new live append")])
|
||||
.await
|
||||
.expect("append after resume");
|
||||
|
||||
let metadata = runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("sqlite metadata read")
|
||||
.expect("sqlite metadata");
|
||||
assert_eq!(
|
||||
metadata.created_at.to_rfc3339(),
|
||||
"2025-01-03T17:00:00+00:00"
|
||||
);
|
||||
assert_eq!(metadata.model_provider, "test-provider");
|
||||
assert_eq!(
|
||||
metadata.first_user_message.as_deref(),
|
||||
Some("Hello from user")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_thread_resume_loads_history_from_explicit_external_rollout_path() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let external_home = TempDir::new().expect("external temp dir");
|
||||
let config = test_config(home.path());
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = Arc::new(LocalThreadStore::new(config, Some(runtime.clone())));
|
||||
let uuid = uuid::Uuid::from_u128(402);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
let rollout_path = write_session_file(external_home.path(), "2025-01-03T17-30-00", uuid)
|
||||
.expect("external session file");
|
||||
let live_thread = LiveThread::resume(
|
||||
store,
|
||||
ResumeThreadParams {
|
||||
thread_id,
|
||||
rollout_path: Some(rollout_path),
|
||||
history: None,
|
||||
include_archived: false,
|
||||
metadata: ThreadPersistenceMetadata {
|
||||
cwd: Some(home.path().to_path_buf()),
|
||||
model_provider: "different-provider".to_string(),
|
||||
memory_mode: ThreadMemoryMode::Enabled,
|
||||
},
|
||||
event_persistence_mode: ThreadEventPersistenceMode::Limited,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("resume external live thread");
|
||||
|
||||
live_thread
|
||||
.append_items(&[user_message_item("new external append")])
|
||||
.await
|
||||
.expect("append after external resume");
|
||||
|
||||
let metadata = runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("sqlite metadata read")
|
||||
.expect("sqlite metadata");
|
||||
assert_eq!(
|
||||
metadata.created_at.to_rfc3339(),
|
||||
"2025-01-03T17:30:00+00:00"
|
||||
);
|
||||
assert_eq!(metadata.model_provider, "test-provider");
|
||||
assert_eq!(
|
||||
metadata.first_user_message.as_deref(),
|
||||
Some("Hello from user")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_thread_rejects_missing_cwd() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
|
||||
@@ -273,7 +273,8 @@ async fn stored_thread_from_sqlite_metadata(
|
||||
None => find_thread_name_by_id(store.config.codex_home.as_path(), &metadata.id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten(),
|
||||
.flatten()
|
||||
.filter(|title| !title.trim().is_empty()),
|
||||
};
|
||||
let session_meta = read_session_meta_line(metadata.rollout_path.as_path())
|
||||
.await
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::GitInfo;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::ThreadMemoryMode;
|
||||
use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
|
||||
use codex_rollout::append_rollout_item_to_path;
|
||||
@@ -11,6 +13,7 @@ use codex_rollout::append_thread_name;
|
||||
use codex_rollout::find_archived_thread_path_by_id_str;
|
||||
use codex_rollout::find_thread_path_by_id_str;
|
||||
use codex_rollout::read_session_meta_line;
|
||||
use codex_state::ThreadMetadataBuilder;
|
||||
|
||||
use super::LocalThreadStore;
|
||||
use super::helpers::git_info_from_parts;
|
||||
@@ -18,6 +21,7 @@ use super::live_writer;
|
||||
use crate::GitInfoPatch;
|
||||
use crate::ReadThreadParams;
|
||||
use crate::StoredThread;
|
||||
use crate::ThreadMetadataPatch;
|
||||
use crate::ThreadStoreError;
|
||||
use crate::ThreadStoreResult;
|
||||
use crate::UpdateThreadMetadataParams;
|
||||
@@ -32,25 +36,35 @@ pub(super) async fn update_thread_metadata(
|
||||
store: &LocalThreadStore,
|
||||
params: UpdateThreadMetadataParams,
|
||||
) -> ThreadStoreResult<StoredThread> {
|
||||
let field_count = usize::from(params.patch.name.is_some())
|
||||
+ usize::from(params.patch.memory_mode.is_some())
|
||||
+ usize::from(params.patch.git_info.is_some());
|
||||
if field_count > 1 {
|
||||
return Err(ThreadStoreError::InvalidRequest {
|
||||
message: "local thread store applies one metadata field per patch in this slice"
|
||||
.to_string(),
|
||||
});
|
||||
let thread_id = params.thread_id;
|
||||
let patch = params.patch;
|
||||
if patch.is_empty() {
|
||||
return read_thread::read_thread(
|
||||
store,
|
||||
ReadThreadParams {
|
||||
thread_id,
|
||||
include_archived: params.include_archived,
|
||||
include_history: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let needs_rollout_compat = needs_rollout_compatibility_update(&patch);
|
||||
let updated =
|
||||
apply_metadata_update(store, thread_id, patch.clone(), params.include_archived).await?;
|
||||
if !needs_rollout_compat {
|
||||
return Ok(updated);
|
||||
}
|
||||
|
||||
let thread_id = params.thread_id;
|
||||
if live_writer::rollout_path(store, thread_id).await.is_ok() {
|
||||
live_writer::persist_thread(store, thread_id).await?;
|
||||
}
|
||||
let resolved_rollout_path =
|
||||
resolve_rollout_path(store, thread_id, params.include_archived).await?;
|
||||
let name = params.patch.name;
|
||||
let git_info = params.patch.git_info;
|
||||
if let Some(memory_mode) = params.patch.memory_mode {
|
||||
let name = patch.name;
|
||||
let git_info = patch.git_info;
|
||||
if let Some(memory_mode) = patch.memory_mode {
|
||||
apply_thread_memory_mode(resolved_rollout_path.path.as_path(), thread_id, memory_mode)
|
||||
.await?;
|
||||
}
|
||||
@@ -68,7 +82,7 @@ pub(super) async fn update_thread_metadata(
|
||||
.await;
|
||||
|
||||
if let Some(name) = name {
|
||||
apply_thread_name(store, thread_id, name).await?;
|
||||
apply_thread_name(store, thread_id, name.unwrap_or_default()).await?;
|
||||
}
|
||||
|
||||
let resolved_git_info = match git_info {
|
||||
@@ -150,6 +164,218 @@ pub(super) async fn update_thread_metadata(
|
||||
Ok(thread)
|
||||
}
|
||||
|
||||
async fn apply_metadata_update(
|
||||
store: &LocalThreadStore,
|
||||
thread_id: ThreadId,
|
||||
patch: ThreadMetadataPatch,
|
||||
include_archived: bool,
|
||||
) -> ThreadStoreResult<StoredThread> {
|
||||
let live_rollout_path = live_writer::rollout_path(store, thread_id).await.ok();
|
||||
let mut rollout_path = patch.rollout_path.clone().or(live_rollout_path);
|
||||
let mut rollout_path_archived = rollout_path
|
||||
.as_deref()
|
||||
.is_some_and(|path| rollout_path_is_archived(store, path));
|
||||
let state_db = store.state_db().await;
|
||||
let sqlite_write_result: ThreadStoreResult<()> = if let Some(state_db) = state_db.as_ref() {
|
||||
let patch = patch.clone();
|
||||
async {
|
||||
let existing =
|
||||
state_db
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
message: format!("failed to read thread metadata for {thread_id}: {err}"),
|
||||
})?;
|
||||
if existing.is_none() && rollout_path.is_none() {
|
||||
let resolved = resolve_rollout_path(store, thread_id, include_archived).await?;
|
||||
rollout_path_archived = resolved.archived;
|
||||
rollout_path = Some(resolved.path);
|
||||
}
|
||||
let mut metadata = existing.clone().unwrap_or_else(|| {
|
||||
let created_at = patch
|
||||
.created_at
|
||||
.or(patch.updated_at)
|
||||
.unwrap_or_else(Utc::now);
|
||||
let mut builder = ThreadMetadataBuilder::new(
|
||||
thread_id,
|
||||
rollout_path.clone().unwrap_or_default(),
|
||||
created_at,
|
||||
patch.source.clone().unwrap_or(SessionSource::Unknown),
|
||||
);
|
||||
builder.model_provider = patch.model_provider.clone();
|
||||
builder.thread_source = patch.thread_source.flatten();
|
||||
builder.agent_nickname = patch.agent_nickname.clone().flatten();
|
||||
builder.agent_role = patch.agent_role.clone().flatten();
|
||||
builder.agent_path = patch.agent_path.clone().flatten();
|
||||
builder.cwd = patch.cwd.clone().map(normalize_cwd).unwrap_or_default();
|
||||
builder.cli_version = patch.cli_version.clone();
|
||||
let mut metadata = builder.build(store.config.default_model_provider_id.as_str());
|
||||
if rollout_path_archived {
|
||||
metadata.archived_at = Some(metadata.updated_at);
|
||||
}
|
||||
metadata
|
||||
});
|
||||
if let Some(rollout_path) = rollout_path {
|
||||
metadata.rollout_path = rollout_path;
|
||||
}
|
||||
if let Some(preview) = patch.preview {
|
||||
metadata.preview = Some(preview);
|
||||
}
|
||||
if let Some(name) = patch.name {
|
||||
metadata.title = name.unwrap_or_default();
|
||||
}
|
||||
if let Some(title) = patch.title {
|
||||
metadata.title = title;
|
||||
}
|
||||
if let Some(model_provider) = patch.model_provider {
|
||||
metadata.model_provider = model_provider;
|
||||
}
|
||||
if let Some(model) = patch.model {
|
||||
metadata.model = Some(model);
|
||||
}
|
||||
if let Some(reasoning_effort) = patch.reasoning_effort {
|
||||
metadata.reasoning_effort = Some(reasoning_effort);
|
||||
}
|
||||
if let Some(created_at) = patch.created_at {
|
||||
metadata.created_at = created_at;
|
||||
}
|
||||
if let Some(updated_at) = patch.updated_at {
|
||||
metadata.updated_at = updated_at;
|
||||
}
|
||||
if let Some(source) = patch.source {
|
||||
metadata.source = enum_to_string(&source);
|
||||
}
|
||||
if let Some(thread_source) = patch.thread_source {
|
||||
metadata.thread_source = thread_source;
|
||||
}
|
||||
if let Some(agent_nickname) = patch.agent_nickname {
|
||||
metadata.agent_nickname = agent_nickname;
|
||||
}
|
||||
if let Some(agent_role) = patch.agent_role {
|
||||
metadata.agent_role = agent_role;
|
||||
}
|
||||
if let Some(agent_path) = patch.agent_path {
|
||||
metadata.agent_path = agent_path;
|
||||
}
|
||||
if let Some(cwd) = patch.cwd {
|
||||
metadata.cwd = normalize_cwd(cwd);
|
||||
}
|
||||
if let Some(cli_version) = patch.cli_version {
|
||||
metadata.cli_version = cli_version;
|
||||
}
|
||||
if let Some(approval_mode) = patch.approval_mode {
|
||||
metadata.approval_mode = enum_to_string(&approval_mode);
|
||||
}
|
||||
if let Some(sandbox_policy) = patch.sandbox_policy {
|
||||
metadata.sandbox_policy = enum_to_string(&sandbox_policy);
|
||||
}
|
||||
if let Some(token_usage) = patch.token_usage {
|
||||
metadata.tokens_used = token_usage.total_tokens.max(0);
|
||||
}
|
||||
if let Some(first_user_message) = patch.first_user_message {
|
||||
metadata.first_user_message = Some(first_user_message);
|
||||
}
|
||||
if let Some(git_info) = patch.git_info {
|
||||
let existing_git_info = git_info_from_parts(
|
||||
metadata.git_sha.clone(),
|
||||
metadata.git_branch.clone(),
|
||||
metadata.git_origin_url.clone(),
|
||||
);
|
||||
let (sha, branch, origin_url) = resolve_git_info_patch(existing_git_info, git_info);
|
||||
metadata.git_sha = sha;
|
||||
metadata.git_branch = branch;
|
||||
metadata.git_origin_url = origin_url;
|
||||
}
|
||||
state_db
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
message: format!("failed to update thread metadata for {thread_id}: {err}"),
|
||||
})?;
|
||||
if let Some(memory_mode) = patch.memory_mode {
|
||||
state_db
|
||||
.set_thread_memory_mode(thread_id, memory_mode_as_str(memory_mode))
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
message: format!("failed to update memory mode for {thread_id}: {err}"),
|
||||
})?;
|
||||
}
|
||||
if let Some(dynamic_tools) = patch.dynamic_tools {
|
||||
state_db
|
||||
.persist_dynamic_tools(thread_id, Some(dynamic_tools.as_slice()))
|
||||
.await
|
||||
.map_err(|err| ThreadStoreError::Internal {
|
||||
message: format!("failed to update dynamic tools for {thread_id}: {err}"),
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
match (state_db.is_some(), sqlite_write_result) {
|
||||
(true, Ok(())) => {}
|
||||
(true, Err(err)) => return Err(err),
|
||||
(false, Ok(())) => {}
|
||||
(false, Err(err)) => return Err(err),
|
||||
}
|
||||
|
||||
read_thread::read_thread(
|
||||
store,
|
||||
ReadThreadParams {
|
||||
thread_id,
|
||||
include_archived,
|
||||
include_history: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn needs_rollout_compatibility_update(patch: &ThreadMetadataPatch) -> bool {
|
||||
if patch.name.is_some() {
|
||||
return true;
|
||||
}
|
||||
if patch.memory_mode.is_none() && patch.git_info.is_none() {
|
||||
return false;
|
||||
}
|
||||
!has_observed_metadata_facts(patch)
|
||||
}
|
||||
|
||||
fn has_observed_metadata_facts(patch: &ThreadMetadataPatch) -> bool {
|
||||
patch.rollout_path.is_some()
|
||||
|| patch.preview.is_some()
|
||||
|| patch.title.is_some()
|
||||
|| patch.model_provider.is_some()
|
||||
|| patch.model.is_some()
|
||||
|| patch.reasoning_effort.is_some()
|
||||
|| patch.created_at.is_some()
|
||||
|| patch.source.is_some()
|
||||
|| patch.thread_source.is_some()
|
||||
|| patch.agent_nickname.is_some()
|
||||
|| patch.agent_role.is_some()
|
||||
|| patch.agent_path.is_some()
|
||||
|| patch.cwd.is_some()
|
||||
|| patch.cli_version.is_some()
|
||||
|| patch.approval_mode.is_some()
|
||||
|| patch.sandbox_policy.is_some()
|
||||
|| patch.token_usage.is_some()
|
||||
|| patch.first_user_message.is_some()
|
||||
|| patch.dynamic_tools.is_some()
|
||||
}
|
||||
|
||||
fn enum_to_string<T: serde::Serialize>(value: &T) -> String {
|
||||
match serde_json::to_value(value) {
|
||||
Ok(serde_json::Value::String(value)) => value,
|
||||
Ok(other) => other.to_string(),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_cwd(cwd: PathBuf) -> PathBuf {
|
||||
codex_utils_path::normalize_for_path_comparison(cwd.as_path()).unwrap_or(cwd)
|
||||
}
|
||||
|
||||
async fn apply_thread_git_info(
|
||||
store: &LocalThreadStore,
|
||||
thread_id: ThreadId,
|
||||
@@ -363,10 +589,13 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::GitInfoPatch;
|
||||
use crate::ListThreadsParams;
|
||||
use crate::ResumeThreadParams;
|
||||
use crate::SortDirection;
|
||||
use crate::ThreadEventPersistenceMode;
|
||||
use crate::ThreadMetadataPatch;
|
||||
use crate::ThreadPersistenceMetadata;
|
||||
use crate::ThreadSortKey;
|
||||
use crate::ThreadStore;
|
||||
use crate::local::LocalThreadStore;
|
||||
use crate::local::test_support::test_config;
|
||||
@@ -385,7 +614,7 @@ mod tests {
|
||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
||||
thread_id,
|
||||
patch: ThreadMetadataPatch {
|
||||
name: Some("A sharper name".to_string()),
|
||||
name: Some(Some("A sharper name".to_string())),
|
||||
..Default::default()
|
||||
},
|
||||
include_archived: false,
|
||||
@@ -855,44 +1084,306 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_thread_metadata_rejects_multi_field_patch_without_partial_write() {
|
||||
async fn update_thread_metadata_applies_combined_explicit_patch() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
|
||||
let config = test_config(home.path());
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
home.path().to_path_buf(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = LocalThreadStore::new(config, Some(runtime.clone()));
|
||||
let uuid = Uuid::from_u128(305);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
let path =
|
||||
write_session_file(home.path(), "2025-01-03T15-30-00", uuid).expect("session file");
|
||||
let original = std::fs::read_to_string(&path).expect("read rollout");
|
||||
|
||||
let err = store
|
||||
let thread = store
|
||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
||||
thread_id,
|
||||
patch: ThreadMetadataPatch {
|
||||
name: Some("Should not persist".to_string()),
|
||||
name: Some(Some("Combined metadata".to_string())),
|
||||
memory_mode: Some(ThreadMemoryMode::Disabled),
|
||||
git_info: Some(GitInfoPatch {
|
||||
branch: Some(Some("combined".to_string())),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
include_archived: false,
|
||||
})
|
||||
.await
|
||||
.expect_err("multi-field patch should fail");
|
||||
.expect("combined patch should apply");
|
||||
|
||||
assert!(matches!(err, ThreadStoreError::InvalidRequest { .. }));
|
||||
assert_eq!(thread.name.as_deref(), Some("Combined metadata"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path).expect("read rollout"),
|
||||
original
|
||||
thread.git_info.expect("git info").branch.as_deref(),
|
||||
Some("combined")
|
||||
);
|
||||
let appended = last_rollout_item(path.as_path());
|
||||
assert_eq!(appended["type"], "session_meta");
|
||||
assert_eq!(appended["payload"]["memory_mode"], "disabled");
|
||||
assert_eq!(appended["payload"]["git"]["branch"], "combined");
|
||||
let latest_name = codex_rollout::find_thread_name_by_id(home.path(), &thread_id)
|
||||
.await
|
||||
.expect("find thread name");
|
||||
assert_eq!(latest_name, None);
|
||||
assert_eq!(latest_name.as_deref(), Some("Combined metadata"));
|
||||
let memory_mode = runtime
|
||||
.get_thread_memory_mode(thread_id)
|
||||
.await
|
||||
.expect("thread memory mode should be readable");
|
||||
assert_eq!(memory_mode.as_deref(), Some("disabled"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_patch_applies_title_over_existing_name() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
home.path().to_path_buf(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = LocalThreadStore::new(config, Some(runtime));
|
||||
let uuid = Uuid::from_u128(306);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
write_session_file(home.path(), "2025-01-03T15-45-00", uuid).expect("session file");
|
||||
|
||||
store
|
||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
||||
thread_id,
|
||||
patch: ThreadMetadataPatch {
|
||||
name: Some(Some("User chosen name".to_string())),
|
||||
..Default::default()
|
||||
},
|
||||
include_archived: false,
|
||||
})
|
||||
.await
|
||||
.expect("set explicit name");
|
||||
|
||||
let thread = store
|
||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
||||
thread_id,
|
||||
patch: ThreadMetadataPatch {
|
||||
title: Some("Derived first message".to_string()),
|
||||
preview: Some("Derived first message".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
include_archived: false,
|
||||
})
|
||||
.await
|
||||
.expect("apply observed metadata");
|
||||
|
||||
assert_eq!(thread.name.as_deref(), Some("Derived first message"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_patch_applies_latest_preview_and_first_user_message() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
home.path().to_path_buf(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = LocalThreadStore::new(config, Some(runtime.clone()));
|
||||
let uuid = Uuid::from_u128(313);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
write_session_file(home.path(), "2025-01-03T19-00-00", uuid).expect("session file");
|
||||
|
||||
store
|
||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
||||
thread_id,
|
||||
patch: ThreadMetadataPatch {
|
||||
preview: Some("Original preview".to_string()),
|
||||
first_user_message: Some("Original first message".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
include_archived: false,
|
||||
})
|
||||
.await
|
||||
.expect("set observed metadata");
|
||||
|
||||
let thread = store
|
||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
||||
thread_id,
|
||||
patch: ThreadMetadataPatch {
|
||||
preview: Some("Later preview".to_string()),
|
||||
first_user_message: Some("Later first message".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
include_archived: false,
|
||||
})
|
||||
.await
|
||||
.expect("apply later observed metadata");
|
||||
|
||||
assert_eq!(thread.preview, "Hello from user");
|
||||
assert_eq!(
|
||||
thread.first_user_message.as_deref(),
|
||||
Some("Hello from user")
|
||||
);
|
||||
let metadata = runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("sqlite metadata read")
|
||||
.expect("sqlite metadata");
|
||||
assert_eq!(metadata.preview.as_deref(), Some("Later preview"));
|
||||
assert_eq!(
|
||||
metadata.first_user_message.as_deref(),
|
||||
Some("Later first message")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn observed_metadata_rejects_unknown_thread_without_rollout() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
home.path().to_path_buf(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = LocalThreadStore::new(config, Some(runtime.clone()));
|
||||
let uuid = Uuid::from_u128(314);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
|
||||
let err = store
|
||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
||||
thread_id,
|
||||
patch: ThreadMetadataPatch {
|
||||
preview: Some("phantom".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
include_archived: false,
|
||||
})
|
||||
.await
|
||||
.expect_err("metadata-only update should not create a missing thread");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
ThreadStoreError::InvalidRequest { message }
|
||||
if message == format!("thread not found: {thread_id}")
|
||||
));
|
||||
let metadata = runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("sqlite metadata read");
|
||||
assert!(metadata.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_thread_metadata_recreates_missing_archived_sqlite_row_as_archived() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let uuid = Uuid::from_u128(315);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
write_archived_session_file(home.path(), "2025-01-03T19-30-00", uuid)
|
||||
.expect("archived session file");
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
home.path().to_path_buf(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = LocalThreadStore::new(config, Some(runtime.clone()));
|
||||
|
||||
let thread = store
|
||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
||||
thread_id,
|
||||
patch: ThreadMetadataPatch {
|
||||
preview: Some("Archived missing sqlite row".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
include_archived: true,
|
||||
})
|
||||
.await
|
||||
.expect("update archived thread without sqlite row");
|
||||
|
||||
assert!(thread.archived_at.is_some());
|
||||
assert!(
|
||||
runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("get metadata")
|
||||
.expect("metadata")
|
||||
.archived_at
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn observed_metadata_normalizes_cwd_for_list_filters() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
home.path().to_path_buf(),
|
||||
config.default_model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let store = LocalThreadStore::new(config, Some(runtime.clone()));
|
||||
let uuid = Uuid::from_u128(316);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
write_session_file(home.path(), "2025-01-03T20-00-00", uuid).expect("session file");
|
||||
let workspace = home.path().join("workspace");
|
||||
let child = workspace.join("child");
|
||||
std::fs::create_dir_all(child.as_path()).expect("create workspace");
|
||||
let unnormalized_cwd = child.join("..");
|
||||
let normalized_cwd = codex_utils_path::normalize_for_path_comparison(workspace.as_path())
|
||||
.expect("normalize cwd");
|
||||
|
||||
store
|
||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
||||
thread_id,
|
||||
patch: ThreadMetadataPatch {
|
||||
cwd: Some(unnormalized_cwd),
|
||||
preview: Some("cwd preview".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
include_archived: false,
|
||||
})
|
||||
.await
|
||||
.expect("update observed cwd");
|
||||
|
||||
let metadata = runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("get metadata")
|
||||
.expect("metadata");
|
||||
assert_eq!(metadata.cwd, normalized_cwd);
|
||||
let page = store
|
||||
.list_threads(ListThreadsParams {
|
||||
page_size: 10,
|
||||
cursor: None,
|
||||
sort_key: ThreadSortKey::UpdatedAt,
|
||||
sort_direction: SortDirection::Desc,
|
||||
allowed_sources: Vec::new(),
|
||||
model_providers: Some(Vec::new()),
|
||||
cwd_filters: Some(vec![workspace]),
|
||||
archived: false,
|
||||
search_term: None,
|
||||
use_state_db_only: true,
|
||||
})
|
||||
.await
|
||||
.expect("list threads by cwd");
|
||||
assert_eq!(
|
||||
page.items
|
||||
.iter()
|
||||
.map(|thread| thread.thread_id)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![thread_id]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_thread_metadata_keeps_archived_thread_archived_in_sqlite() {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = test_config(home.path());
|
||||
let uuid = Uuid::from_u128(306);
|
||||
let uuid = Uuid::from_u128(307);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
let archived_path = write_archived_session_file(home.path(), "2025-01-03T16-00-00", uuid)
|
||||
.expect("archived session file");
|
||||
@@ -931,7 +1422,7 @@ mod tests {
|
||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
||||
thread_id,
|
||||
patch: ThreadMetadataPatch {
|
||||
name: Some("Archived title".to_string()),
|
||||
name: Some(Some("Archived title".to_string())),
|
||||
..Default::default()
|
||||
},
|
||||
include_archived: true,
|
||||
@@ -996,7 +1487,7 @@ mod tests {
|
||||
.update_thread_metadata(UpdateThreadMetadataParams {
|
||||
thread_id,
|
||||
patch: ThreadMetadataPatch {
|
||||
name: Some("Live archived title".to_string()),
|
||||
name: Some(Some("Live archived title".to_string())),
|
||||
..Default::default()
|
||||
},
|
||||
include_archived: true,
|
||||
|
||||
Reference in New Issue
Block a user