Files
codex/codex-rs/thread-store/src/local/helpers.rs
T
Eric TrautandGitHub f10ddc3f13 Use goal preview metadata for goal-first threads (#21981)
Fixes #20792

## Why

`/goal`-first threads are valid resumable threads, but they can be
missing from `codex resume` and app recents because discovery depends on
metadata derived from a normal first user message.

PR #21489 attempted to fix this by using the goal objective as
`first_user_message`. Review feedback pointed out that
`first_user_message` does more than provide visible text today: it gates
listing, supplies preview text, and participates in deciding whether a
later title should surface as a distinct thread name. Reusing it for the
goal objective could leave a `/goal`-first thread with
`first_user_message=<goal>` and `title=<later prompt>`, even though the
goal should only provide the initial visible preview.

This PR follows that feedback by and keeps the `first_user_message` as
is but introduces a new `preview` field to separate concerns. The
`preview` field is populated from the first user message or the goal
objective. We can extend it in the future to include other sources.

## What Changed

- Added internal thread `preview` metadata in `codex-state`, including a
SQLite migration that backfills from `first_user_message` and from
existing `thread_goals` objectives when needed.
- Treated `ThreadGoalUpdated` as preview-bearing metadata so goal-first
threads can be listed and searched without mutating
`first_user_message`.
- Updated rollout listing, state queries, thread-store conversion, and
app-server mapping to use preview metadata while continuing to expose
the existing public `preview` field.
- Preserved title/name distinctness behavior around literal
`first_user_message`, so a later normal prompt after `/goal` does not
surface as a separate name just because the goal supplied the initial
preview.
- Preserved compatibility for older/internal metadata writes by deriving
preview from `first_user_message` when explicit preview metadata is
absent.

## Verification

- Manually verified that a thread that starts with a `/goal <objective>`
shows up in the resume picker.
2026-05-11 10:12:46 -07:00

199 lines
6.1 KiB
Rust

use std::ffi::OsStr;
use std::fs::FileTimes;
use std::fs::OpenOptions;
use std::path::Path;
use std::path::PathBuf;
use std::time::SystemTime;
use chrono::DateTime;
use chrono::Utc;
use codex_git_utils::GitSha;
use codex_protocol::ThreadId;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GitInfo;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
use codex_rollout::ThreadItem;
use codex_state::ThreadMetadata;
use crate::StoredThread;
use crate::ThreadStoreError;
use crate::ThreadStoreResult;
pub(super) fn scoped_rollout_path(
root: PathBuf,
rollout_path: &Path,
root_name: &str,
) -> ThreadStoreResult<PathBuf> {
let canonical_root =
std::fs::canonicalize(&root).map_err(|err| ThreadStoreError::Internal {
message: format!(
"failed to resolve {root_name} directory `{}`: {err}",
root.display()
),
})?;
let canonical_rollout_path =
std::fs::canonicalize(rollout_path).map_err(|_| ThreadStoreError::InvalidRequest {
message: format!(
"rollout path `{}` must be in {root_name} directory",
rollout_path.display()
),
})?;
if canonical_rollout_path.starts_with(&canonical_root) {
Ok(canonical_rollout_path)
} else {
Err(ThreadStoreError::InvalidRequest {
message: format!(
"rollout path `{}` must be in {root_name} directory",
rollout_path.display()
),
})
}
}
pub(super) fn rollout_path_is_archived(codex_home: &Path, path: &Path) -> bool {
path.starts_with(codex_home.join(ARCHIVED_SESSIONS_SUBDIR))
|| path
.components()
.any(|component| component.as_os_str() == OsStr::new(ARCHIVED_SESSIONS_SUBDIR))
}
pub(super) fn matching_rollout_file_name(
rollout_path: &Path,
thread_id: ThreadId,
display_path: &Path,
) -> ThreadStoreResult<std::ffi::OsString> {
let Some(file_name) = rollout_path.file_name().map(OsStr::to_owned) else {
return Err(ThreadStoreError::InvalidRequest {
message: format!(
"rollout path `{}` missing file name",
display_path.display()
),
});
};
let required_suffix = format!("{thread_id}.jsonl");
if file_name
.to_string_lossy()
.ends_with(required_suffix.as_str())
{
Ok(file_name)
} else {
Err(ThreadStoreError::InvalidRequest {
message: format!(
"rollout path `{}` does not match thread id {thread_id}",
display_path.display()
),
})
}
}
pub(super) fn touch_modified_time(path: &Path) -> std::io::Result<()> {
let times = FileTimes::new().set_modified(SystemTime::now());
OpenOptions::new().append(true).open(path)?.set_times(times)
}
pub(super) fn stored_thread_from_rollout_item(
item: ThreadItem,
archived: bool,
default_provider: &str,
) -> Option<StoredThread> {
let thread_id = item
.thread_id
.or_else(|| thread_id_from_rollout_path(item.path.as_path()))?;
let created_at = parse_rfc3339(item.created_at.as_deref()).unwrap_or_else(Utc::now);
let updated_at = parse_rfc3339(item.updated_at.as_deref()).unwrap_or(created_at);
let archived_at = archived.then_some(updated_at);
let git_info = git_info_from_parts(
item.git_sha.clone(),
item.git_branch.clone(),
item.git_origin_url.clone(),
);
let source = item.source.unwrap_or(SessionSource::Unknown);
let preview = item
.preview
.clone()
.or_else(|| item.first_user_message.clone())
.unwrap_or_default();
Some(StoredThread {
thread_id,
rollout_path: Some(item.path),
forked_from_id: None,
preview,
name: None,
model_provider: item
.model_provider
.filter(|provider| !provider.is_empty())
.unwrap_or_else(|| default_provider.to_string()),
model: None,
reasoning_effort: None,
created_at,
updated_at,
archived_at,
cwd: item.cwd.unwrap_or_default(),
cli_version: item.cli_version.unwrap_or_default(),
source,
thread_source: None,
agent_nickname: item.agent_nickname,
agent_role: item.agent_role,
agent_path: None,
git_info,
approval_mode: AskForApproval::OnRequest,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
token_usage: None,
first_user_message: item.first_user_message,
history: None,
})
}
pub(super) fn distinct_thread_metadata_title(metadata: &ThreadMetadata) -> Option<String> {
let title = metadata.title.trim();
if title.is_empty() || metadata.first_user_message.as_deref().map(str::trim) == Some(title) {
None
} else {
Some(title.to_string())
}
}
pub(super) fn set_thread_name_from_title(thread: &mut StoredThread, title: String) {
if title.trim().is_empty() || thread.preview.trim() == title.trim() {
return;
}
thread.name = Some(title);
}
fn parse_rfc3339(value: Option<&str>) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(value?)
.ok()
.map(|dt| dt.with_timezone(&Utc))
}
pub(super) fn git_info_from_parts(
sha: Option<String>,
branch: Option<String>,
origin_url: Option<String>,
) -> Option<GitInfo> {
if sha.is_none() && branch.is_none() && origin_url.is_none() {
return None;
}
Some(GitInfo {
commit_hash: sha.as_deref().map(GitSha::new),
branch,
repository_url: origin_url,
})
}
fn thread_id_from_rollout_path(path: &Path) -> Option<ThreadId> {
let file_name = path.file_name()?.to_str()?;
let stem = file_name.strip_suffix(".jsonl")?;
if stem.len() < 37 {
return None;
}
let uuid_start = stem.len().saturating_sub(36);
if !stem[..uuid_start].ends_with('-') {
return None;
}
ThreadId::from_string(&stem[uuid_start..]).ok()
}