mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Add thread recencyAt for sidebar ordering (#27910)
## Summary Add a server-owned `recencyAt` timestamp and `recency_at` thread-list sort key for product recency ordering while preserving the existing meaning of `updatedAt` as the latest persisted thread mutation. This is the server-side alternative to #27697. Rather than narrowing `updatedAt`, clients can sort the sidebar by `recency_at` and continue treating `updatedAt` as mutation time. Paired Codex Apps PR: [openai/openai#1024599](https://github.com/openai/openai/pull/1024599) ## Contract - `recencyAt` initializes when a thread is created. - A turn start advances `recencyAt` monotonically. - Commentary, agent output, tool results, token/accounting updates, turn completion, archive, unarchive, resume, and generic metadata writes do not advance it. - `updatedAt` retains its existing behavior and continues to advance for persisted thread mutations. - Current servers populate `recencyAt`; the response field is optional in generated TypeScript so clients connected to older servers can fall back to `updatedAt`. - Filesystem-only fallback uses existing updated/mtime ordering when SQLite is unavailable. ## Persistence and compatibility Migration 0038 adds second- and millisecond-precision recency columns, backfills them from the existing updated timestamp, creates list indexes, and includes an insert trigger so older binaries writing to a migrated database seed recency without causing later mutations to advance it. Generic metadata upserts preserve existing recency values. Turn-start updates use a dedicated monotonic touch, and process-local allocation keeps millisecond cursor values unique. State DB list, search, read, filtered-list repair, rollout fallback propagation, and app-server conversions all carry the new field. ## API `Thread` responses include: ```ts recencyAt?: number ``` `thread/list` and `thread/search` accept: ```json { "sortKey": "recency_at" } ``` Generated TypeScript and JSON schemas are included. ## Validation - `just test -p codex-state` — 146 passed - `just test -p codex-rollout` — 69 passed - `just test -p codex-thread-store` — 81 passed - `just test -p codex-app-server-protocol` — 231 passed - Focused app-server list ordering, response mapping, archive/unarchive, and resume lifecycle tests passed - Scoped `just fix` for state, rollout, thread-store, app-server-protocol, and app-server - `just fmt` - `git diff --check` - Independent correctness, simplicity, elegance, security, and test-quality reviews; actionable ordering, lifecycle, query-projection, and timestamp-uniqueness findings were addressed
This commit is contained in:
1 parent
f0cb96bcb1
commit
fac3158c2a
67 files changed
+1153
-99
No files matched your search
@@ -77,6 +77,8 @@ pub struct ThreadItem {
|
||||
pub created_at: Option<String>,
|
||||
/// RFC3339 timestamp string for the most recent update (from file mtime).
|
||||
pub updated_at: Option<String>,
|
||||
/// RFC3339 timestamp string used for product recency ordering.
|
||||
pub recency_at: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -115,6 +117,7 @@ const USER_EVENT_SCAN_LIMIT: usize = 200;
|
||||
pub enum ThreadSortKey {
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
RecencyAt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -137,20 +140,29 @@ pub struct ThreadListConfig<'a> {
|
||||
pub layout: ThreadListLayout,
|
||||
}
|
||||
|
||||
/// Pagination cursor identifying the timestamp of the last item in a page.
|
||||
/// Pagination cursor identifying the last item in a page.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Cursor {
|
||||
ts: OffsetDateTime,
|
||||
id: Option<ThreadId>,
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
fn new(ts: OffsetDateTime) -> Self {
|
||||
Self { ts }
|
||||
pub(crate) fn new(ts: OffsetDateTime) -> Self {
|
||||
Self { ts, id: None }
|
||||
}
|
||||
|
||||
pub(crate) fn with_thread_id(ts: OffsetDateTime, id: ThreadId) -> Self {
|
||||
Self { ts, id: Some(id) }
|
||||
}
|
||||
|
||||
pub(crate) fn timestamp(&self) -> OffsetDateTime {
|
||||
self.ts
|
||||
}
|
||||
|
||||
pub(crate) fn thread_id(&self) -> Option<ThreadId> {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps track of where a paginated listing left off. As the file scan goes newest -> oldest,
|
||||
@@ -287,7 +299,10 @@ impl serde::Serialize for Cursor {
|
||||
.ts
|
||||
.format(&Rfc3339)
|
||||
.map_err(|e| serde::ser::Error::custom(format!("format error: {e}")))?;
|
||||
serializer.serialize_str(&ts_str)
|
||||
match self.id {
|
||||
Some(id) => serializer.serialize_str(&format!("{ts_str}|{id}")),
|
||||
None => serializer.serialize_str(&ts_str),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +323,7 @@ impl From<codex_state::Anchor> for Cursor {
|
||||
.timestamp_nanos_opt()
|
||||
.and_then(|nanos| OffsetDateTime::from_unix_timestamp_nanos(nanos as i128).ok())
|
||||
.unwrap_or(OffsetDateTime::UNIX_EPOCH);
|
||||
Self::new(ts)
|
||||
Self { ts, id: anchor.id }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,7 +434,7 @@ async fn traverse_directories_for_paths(
|
||||
)
|
||||
.await
|
||||
}
|
||||
ThreadSortKey::UpdatedAt => {
|
||||
ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => {
|
||||
traverse_directories_for_paths_updated(
|
||||
root,
|
||||
page_size,
|
||||
@@ -454,7 +469,7 @@ async fn traverse_flat_paths(
|
||||
)
|
||||
.await
|
||||
}
|
||||
ThreadSortKey::UpdatedAt => {
|
||||
ThreadSortKey::UpdatedAt | ThreadSortKey::RecencyAt => {
|
||||
traverse_flat_paths_updated(
|
||||
root,
|
||||
page_size,
|
||||
@@ -702,35 +717,48 @@ async fn traverse_flat_paths_updated(
|
||||
})
|
||||
}
|
||||
|
||||
/// Pagination cursor token format: an RFC3339 timestamp.
|
||||
/// Pagination cursor token format: an RFC3339 timestamp with an optional thread ID tie-breaker.
|
||||
pub fn parse_cursor(token: &str) -> Option<Cursor> {
|
||||
if token.contains('|') {
|
||||
return None;
|
||||
}
|
||||
let (timestamp, id) = match token.rsplit_once('|') {
|
||||
Some((timestamp, id)) => (timestamp, Some(ThreadId::from_string(id).ok()?)),
|
||||
None => (token, None),
|
||||
};
|
||||
|
||||
let ts = OffsetDateTime::parse(token, &Rfc3339).ok().or_else(|| {
|
||||
let format: &[FormatItem] =
|
||||
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
|
||||
PrimitiveDateTime::parse(token, format)
|
||||
.ok()
|
||||
.map(PrimitiveDateTime::assume_utc)
|
||||
})?;
|
||||
let ts = OffsetDateTime::parse(timestamp, &Rfc3339)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
let format: &[FormatItem] =
|
||||
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
|
||||
PrimitiveDateTime::parse(timestamp, format)
|
||||
.ok()
|
||||
.map(PrimitiveDateTime::assume_utc)
|
||||
})?;
|
||||
|
||||
Some(Cursor::new(ts))
|
||||
Some(Cursor { ts, id })
|
||||
}
|
||||
|
||||
fn build_next_cursor(items: &[ThreadItem], sort_key: ThreadSortKey) -> Option<Cursor> {
|
||||
let last = items.last()?;
|
||||
let file_name = last.path.file_name()?.to_string_lossy();
|
||||
let (created_ts, _id) = parse_timestamp_uuid_from_filename(&file_name)?;
|
||||
let (created_ts, id) = parse_timestamp_uuid_from_filename(&file_name)?;
|
||||
let ts = match sort_key {
|
||||
ThreadSortKey::CreatedAt => created_ts,
|
||||
ThreadSortKey::UpdatedAt => {
|
||||
let updated_at = last.updated_at.as_deref()?;
|
||||
OffsetDateTime::parse(updated_at, &Rfc3339).ok()?
|
||||
}
|
||||
ThreadSortKey::RecencyAt => {
|
||||
let recency_at = last.recency_at.as_deref().or(last.updated_at.as_deref())?;
|
||||
OffsetDateTime::parse(recency_at, &Rfc3339).ok()?
|
||||
}
|
||||
};
|
||||
Some(Cursor::new(ts))
|
||||
match sort_key {
|
||||
ThreadSortKey::RecencyAt => Some(Cursor::with_thread_id(
|
||||
ts,
|
||||
ThreadId::from_string(&id.to_string()).ok()?,
|
||||
)),
|
||||
ThreadSortKey::CreatedAt | ThreadSortKey::UpdatedAt => Some(Cursor::new(ts)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_thread_item(
|
||||
@@ -806,6 +834,7 @@ async fn build_thread_item(
|
||||
model_provider,
|
||||
cli_version,
|
||||
created_at,
|
||||
recency_at: summary_updated_at.clone(),
|
||||
updated_at: summary_updated_at,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user