mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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.
199 lines
6.1 KiB
Rust
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()
|
|
}
|