feat(resume): unify session catalog and add e2e tests

This commit is contained in:
James Peter
2025-11-16 19:40:47 +10:00
parent 36b2287c32
commit 0b266279b6
15 changed files with 2072 additions and 376 deletions
+4
View File
@@ -1062,6 +1062,7 @@ dependencies = [
"code-tui",
"code-version",
"ctor 0.5.0",
"filetime",
"flate2",
"futures",
"owo-colors",
@@ -1226,6 +1227,7 @@ dependencies = [
"code-core",
"code-ollama",
"code-protocol",
"filetime",
"libc",
"opentelemetry-appender-tracing",
"owo-colors",
@@ -1233,6 +1235,7 @@ dependencies = [
"serde_json",
"shlex",
"supports-color",
"tempfile",
"tokio",
"tracing",
"tracing-subscriber",
@@ -1510,6 +1513,7 @@ dependencies = [
"color-eyre",
"crossterm",
"diffy",
"filetime",
"fs2",
"futures",
"image 0.25.8",
+1
View File
@@ -59,3 +59,4 @@ tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
[dev-dependencies]
filetime = { workspace = true }
+152 -27
View File
@@ -18,8 +18,7 @@ use code_cli::login::run_logout;
mod llm;
use llm::{LlmCli, run_llm};
use code_common::CliConfigOverrides;
use code_core::find_conversation_path_by_id_str;
use code_core::RolloutRecorder;
use code_core::{entry_to_rollout_path, SessionCatalog, SessionQuery};
use code_cloud_tasks::Cli as CloudTasksCli;
use code_exec::Cli as ExecCli;
use code_responses_api_proxy::Args as ResponsesApiProxyArgs;
@@ -585,21 +584,29 @@ fn resolve_resume_path(session_id: Option<&str>, last: bool) -> anyhow::Result<O
let code_home = code_core::config::find_code_home()
.context("failed to locate Codex home directory")?;
// Build the async work once, then execute it either on the existing
// runtime (from a helper thread) or a fresh current-thread runtime.
// Clone borrowed inputs so the async task can be 'static when spawned.
let sess = session_id.map(|s| s.to_string());
let fetch = async move {
let catalog = SessionCatalog::new(code_home.clone());
if let Some(id) = sess.as_deref() {
let maybe = find_conversation_path_by_id_str(&code_home, id)
let entry = catalog
.find_by_id(id)
.await
.context("failed to look up session by id")?;
Ok(maybe)
Ok(entry.map(|entry| entry_to_rollout_path(&code_home, &entry)))
} else if last {
let page = RolloutRecorder::list_conversations(&code_home, 1, None, &[])
let query = SessionQuery {
cwd: None,
git_root: None,
sources: Vec::new(),
include_archived: false,
include_deleted: false,
limit: Some(1),
};
let entry = catalog
.get_latest(&query)
.await
.context("failed to list recorded sessions")?;
Ok(page.items.first().map(|it| it.path.clone()))
.context("failed to get latest session from catalog")?;
Ok(entry.map(|entry| entry_to_rollout_path(&code_home, &entry)))
} else {
Ok(None)
}
@@ -1040,11 +1047,14 @@ mod tests {
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, SystemTime};
use filetime::{set_file_mtime, FileTime};
use tempfile::TempDir;
use uuid::Uuid;
use code_protocol::mcp_protocol::ConversationId;
use code_protocol::models::{ContentItem, ResponseItem};
use code_protocol::protocol::EventMsg as ProtoEventMsg;
use code_protocol::protocol::RecordedEvent;
use code_protocol::protocol::RolloutItem;
@@ -1137,6 +1147,26 @@ mod tests {
}
fn create_session_fixture(code_home: &Path, id: &Uuid) -> PathBuf {
create_session_fixture_with_details(
code_home,
id,
"2025-10-06T12:00:00Z",
"2025-10-06T12:00:00Z",
Path::new("/project"),
SessionSource::Cli,
"Hello",
)
}
fn create_session_fixture_with_details(
code_home: &Path,
id: &Uuid,
created_at: &str,
last_event_at: &str,
cwd: &Path,
source: SessionSource,
user_message: &str,
) -> PathBuf {
let sessions_dir = code_home
.join("sessions")
.join("2025")
@@ -1144,24 +1174,27 @@ mod tests {
.join("06");
std::fs::create_dir_all(&sessions_dir).expect("create sessions dir");
let timestamp = "2025-10-06T12-00-00";
let filename = format!("rollout-{timestamp}-{id}.jsonl");
let filename = format!(
"rollout-{}-{}.jsonl",
created_at.replace(':', "-"),
id
);
let path = sessions_dir.join(filename);
let session_id_str = id.to_string();
let session_meta = SessionMeta {
id: ConversationId::from(*id),
timestamp: timestamp.to_string(),
cwd: Path::new(".").to_path_buf(),
timestamp: created_at.to_string(),
cwd: cwd.to_path_buf(),
originator: "test".to_string(),
cli_version: "0.0.0-test".to_string(),
instructions: None,
source: SessionSource::Cli,
source,
};
let session_line = RolloutLine {
timestamp: timestamp.to_string(),
timestamp: created_at.to_string(),
item: RolloutItem::SessionMeta(SessionMetaLine {
meta: session_meta,
git: None,
@@ -1169,35 +1202,39 @@ mod tests {
};
let event_line = RolloutLine {
timestamp: timestamp.to_string(),
timestamp: last_event_at.to_string(),
item: RolloutItem::Event(RecordedEvent {
id: "event-0".to_string(),
event_seq: 0,
order: None,
msg: ProtoEventMsg::UserMessage(UserMessageEvent {
message: "Hello".to_string(),
message: user_message.to_string(),
kind: None,
images: None,
}),
}),
};
let response_line = RolloutLine {
timestamp: last_event_at.to_string(),
item: RolloutItem::ResponseItem(ResponseItem::Message {
id: Some(format!("msg-{}", id)),
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: format!("Ack: {}", user_message),
}],
}),
};
let mut writer = std::io::BufWriter::new(std::fs::File::create(&path).expect("open session file"));
serde_json::to_writer(&mut writer, &session_line).expect("write session meta");
writer.write_all(b"\n").expect("newline");
serde_json::to_writer(&mut writer, &event_line).expect("write event");
writer.write_all(b"\n").expect("newline");
serde_json::to_writer(&mut writer, &response_line).expect("write response");
writer.write_all(b"\n").expect("newline");
writer.flush().expect("flush session file");
let runtime = TokioRuntimeBuilder::new_current_thread()
.enable_all()
.build()
.expect("build runtime for session lookup");
runtime
.block_on(find_conversation_path_by_id_str(code_home, &session_id_str))
.expect("lookup session by id")
.expect("session file should be discoverable");
path
}
@@ -1235,6 +1272,94 @@ mod tests {
});
}
#[test]
fn resolve_resume_path_uses_catalog_for_last() {
with_temp_code_home(|code_home| {
let cwd = Path::new("/project");
let older_id = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap();
let newer_id = Uuid::parse_str("22222222-2222-4222-8222-222222222222").unwrap();
create_session_fixture_with_details(
code_home,
&older_id,
"2025-11-15T10:00:00Z",
"2025-11-15T10:00:10Z",
cwd,
SessionSource::Cli,
"older",
);
create_session_fixture_with_details(
code_home,
&newer_id,
"2025-11-16T10:00:00Z",
"2025-11-16T10:00:10Z",
cwd,
SessionSource::Exec,
"newer",
);
let path = resolve_resume_path(None, true).expect("query").expect("path");
assert!(path.ends_with("22222222-2222-4222-8222-222222222222.jsonl"));
});
}
#[test]
fn resolve_resume_path_prefix_lookup() {
with_temp_code_home(|code_home| {
let cwd = Path::new("/project");
let session_id = Uuid::parse_str("33333333-3333-4333-8333-333333333333").unwrap();
create_session_fixture_with_details(
code_home,
&session_id,
"2025-11-16T12:00:00Z",
"2025-11-16T12:00:05Z",
cwd,
SessionSource::Cli,
"prefix",
);
let result = resolve_resume_path(Some("33333333"), false)
.expect("query")
.expect("path");
assert!(result.ends_with("33333333-3333-4333-8333-333333333333.jsonl"));
});
}
#[test]
fn resolve_resume_path_handles_sync_like_mtime() {
with_temp_code_home(|code_home| {
let cwd = Path::new("/project");
let older_id = Uuid::parse_str("44444444-4444-4444-8444-444444444444").unwrap();
let newer_id = Uuid::parse_str("55555555-5555-4555-8555-555555555555").unwrap();
let older_path = create_session_fixture_with_details(
code_home,
&older_id,
"2025-11-10T10:00:00Z",
"2025-11-10T10:05:00Z",
cwd,
SessionSource::Cli,
"older",
);
let newer_path = create_session_fixture_with_details(
code_home,
&newer_id,
"2025-11-16T10:00:00Z",
"2025-11-16T10:05:00Z",
cwd,
SessionSource::Exec,
"newer",
);
let base = SystemTime::now();
set_file_mtime(&older_path, FileTime::from_system_time(base + Duration::from_secs(300))).unwrap();
set_file_mtime(&newer_path, FileTime::from_system_time(base + Duration::from_secs(60))).unwrap();
let path = resolve_resume_path(None, true).expect("query").expect("path");
assert!(path.ends_with("55555555-5555-4555-8555-555555555555.jsonl"));
});
}
#[test]
fn resume_merges_option_flags_and_full_auto() {
with_temp_code_home(|code_home| {
+5
View File
@@ -93,6 +93,7 @@ pub mod project_doc;
pub mod project_features;
mod rollout;
pub(crate) mod safety;
mod session_catalog;
pub mod seatbelt;
pub mod shell;
pub mod spawn;
@@ -110,6 +111,10 @@ pub use rollout::find_conversation_path_by_id_str;
pub use rollout::list::ConversationItem;
pub use rollout::list::ConversationsPage;
pub use rollout::list::Cursor;
pub use rollout::catalog::SessionIndexEntry;
pub use session_catalog::entry_to_rollout_path;
pub use session_catalog::SessionCatalog;
pub use session_catalog::SessionQuery;
mod function_tool;
mod user_notification;
pub mod util;
+974
View File
@@ -0,0 +1,974 @@
//! Session catalog: unified index of all sessions for efficient querying and resume.
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{self, BufRead, BufReader};
use std::path::{Path, PathBuf};
use code_protocol::protocol::{RolloutItem, RolloutLine, SessionSource};
use serde::{Deserialize, Serialize};
use tracing::warn;
use uuid::Uuid;
use super::SESSIONS_SUBDIR;
const INDEX_SUBDIR: &str = "sessions/index";
const CATALOG_FILENAME: &str = "catalog.jsonl";
/// Canonical entry in the session catalog index.
/// Each session has exactly one entry in the catalog, written as a single JSON line.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionIndexEntry {
/// Unique session identifier
pub session_id: Uuid,
/// Path to the rollout JSONL file, relative to code_home
pub rollout_path: PathBuf,
/// Optional path to the snapshot JSON file, relative to code_home
#[serde(skip_serializing_if = "Option::is_none")]
pub snapshot_path: Option<PathBuf>,
/// Timestamp when session was created (from SessionMeta.timestamp)
pub created_at: String,
/// Timestamp of the newest RolloutLine in the session
pub last_event_at: String,
/// Canonical absolute working directory path
pub cwd_real: PathBuf,
/// Display-friendly working directory path (may contain ~)
pub cwd_display: String,
/// Git project root directory (canonicalized)
#[serde(skip_serializing_if = "Option::is_none")]
pub git_project_root: Option<PathBuf>,
/// Git branch name
#[serde(skip_serializing_if = "Option::is_none")]
pub git_branch: Option<String>,
/// Model provider/name used in this session
#[serde(skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
/// Session source (CLI, VSCode, Exec, MCP, etc.)
pub session_source: SessionSource,
/// Number of messages/turns in the session
pub message_count: usize,
/// Snippet from the last user message
#[serde(skip_serializing_if = "Option::is_none")]
pub last_user_snippet: Option<String>,
/// Device/machine where this session originated (for synced sessions)
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_origin_device: Option<String>,
/// Sync version/generation for conflict resolution
#[serde(default)]
pub sync_version: u64,
/// Whether session is archived
#[serde(default)]
pub archived: bool,
/// Whether session is marked as deleted
#[serde(default)]
pub deleted: bool,
}
impl SessionIndexEntry {
/// Global ordering key for sessions: (last_event_at DESC, created_at DESC, session_id DESC)
pub fn ordering_key(&self) -> (Reverse<String>, Reverse<String>, Reverse<Uuid>) {
(
Reverse(self.last_event_at.clone()),
Reverse(self.created_at.clone()),
Reverse(self.session_id),
)
}
}
/// In-memory session catalog with secondary indexes for efficient querying.
#[derive(Debug, Default)]
pub struct SessionCatalog {
/// All entries indexed by session_id
pub(crate) entries: HashMap<Uuid, SessionIndexEntry>,
/// Secondary index: cwd_real -> list of session_ids
by_cwd: HashMap<PathBuf, Vec<Uuid>>,
/// Secondary index: git_project_root -> list of session_ids
by_git_root: HashMap<PathBuf, Vec<Uuid>>,
/// Path to the catalog file
catalog_path: PathBuf,
}
impl SessionCatalog {
/// Load the catalog from disk, or create empty if it doesn't exist.
pub fn load(code_home: &Path) -> io::Result<Self> {
let catalog_path = code_home.join(INDEX_SUBDIR).join(CATALOG_FILENAME);
let mut catalog = Self {
entries: HashMap::new(),
by_cwd: HashMap::new(),
by_git_root: HashMap::new(),
catalog_path: catalog_path.clone(),
};
if !catalog_path.exists() {
return Ok(catalog);
}
let file = fs::File::open(&catalog_path)?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<SessionIndexEntry>(&line) {
Ok(entry) => {
catalog.index_entry(entry);
}
Err(e) => {
warn!("Failed to parse catalog entry: {}", e);
}
}
}
Ok(catalog)
}
/// Add or update an entry in the catalog.
fn index_entry(&mut self, entry: SessionIndexEntry) {
let session_id = entry.session_id;
// Add to cwd index
self.by_cwd
.entry(entry.cwd_real.clone())
.or_default()
.push(session_id);
// Add to git root index
if let Some(ref git_root) = entry.git_project_root {
self.by_git_root
.entry(git_root.clone())
.or_default()
.push(session_id);
}
// Add to main index
self.entries.insert(session_id, entry);
}
/// Save the entire catalog to disk, overwriting the existing file.
pub fn save(&self) -> io::Result<()> {
// Ensure the index directory exists
if let Some(parent) = self.catalog_path.parent() {
fs::create_dir_all(parent)?;
}
// Sort entries by ordering key for deterministic output
let mut entries: Vec<&SessionIndexEntry> = self.entries.values().collect();
entries.sort_by_key(|e| e.ordering_key());
// Write all entries
let mut lines = Vec::new();
for entry in entries {
match serde_json::to_string(entry) {
Ok(json) => lines.push(json),
Err(e) => warn!("Failed to serialize catalog entry: {}", e),
}
}
fs::write(&self.catalog_path, lines.join("\n") + "\n")?;
Ok(())
}
/// Get an entry by session ID.
#[allow(dead_code)]
pub fn get(&self, session_id: &Uuid) -> Option<&SessionIndexEntry> {
self.entries.get(session_id)
}
/// Get all entries sorted by global ordering.
pub fn all_ordered(&self) -> Vec<&SessionIndexEntry> {
let mut entries: Vec<&SessionIndexEntry> = self.entries.values().collect();
entries.sort_by_key(|e| e.ordering_key());
entries
}
/// Get sessions for a specific working directory, sorted by ordering.
#[allow(dead_code)]
pub fn by_cwd(&self, cwd: &Path) -> Vec<&SessionIndexEntry> {
let session_ids = match self.by_cwd.get(cwd) {
Some(ids) => ids,
None => return Vec::new(),
};
let mut entries: Vec<&SessionIndexEntry> = session_ids
.iter()
.filter_map(|id| self.entries.get(id))
.collect();
entries.sort_by_key(|e| e.ordering_key());
entries
}
/// Get sessions for a specific git project root, sorted by ordering.
#[allow(dead_code)]
pub fn by_git_root(&self, git_root: &Path) -> Vec<&SessionIndexEntry> {
let session_ids = match self.by_git_root.get(git_root) {
Some(ids) => ids,
None => return Vec::new(),
};
let mut entries: Vec<&SessionIndexEntry> = session_ids
.iter()
.filter_map(|id| self.entries.get(id))
.collect();
entries.sort_by_key(|e| e.ordering_key());
entries
}
/// Update or insert an entry, then save to disk.
pub fn upsert(&mut self, entry: SessionIndexEntry) -> io::Result<()> {
// Remove old secondary index entries if updating
let session_id = entry.session_id;
if let Some(old_entry) = self.entries.get(&session_id).cloned() {
self.remove_from_indexes(&session_id, &old_entry);
}
self.index_entry(entry);
self.save()
}
/// Remove an entry's session_id from secondary indexes.
fn remove_from_indexes(&mut self, session_id: &Uuid, entry: &SessionIndexEntry) {
// Remove from cwd index
if let Some(ids) = self.by_cwd.get_mut(&entry.cwd_real) {
ids.retain(|id| id != session_id);
}
// Remove from git root index
if let Some(ref git_root) = entry.git_project_root {
if let Some(ids) = self.by_git_root.get_mut(git_root) {
ids.retain(|id| id != session_id);
}
}
}
/// Remove an entry by session ID.
#[allow(dead_code)]
pub fn remove(&mut self, session_id: &Uuid) -> io::Result<()> {
if let Some(entry) = self.entries.remove(session_id) {
self.remove_from_indexes(session_id, &entry);
self.save()?;
}
Ok(())
}
/// Reconcile the catalog against actual rollout files on disk.
/// This scans the sessions directory and updates/adds entries for any files
/// that are newer or missing from the catalog.
#[allow(dead_code)]
pub async fn reconcile(&mut self, code_home: &Path) -> io::Result<ReconcileResult> {
let sessions_root = code_home.join(SESSIONS_SUBDIR);
if !sessions_root.exists() {
return Ok(ReconcileResult::default());
}
let mut result = ReconcileResult::default();
let discovered_entries = scan_rollout_files(&sessions_root).await?;
let discovered_ids: HashSet<Uuid> = discovered_entries.keys().copied().collect();
let mut changed = false;
// Remove entries that no longer exist on disk.
let existing_ids: Vec<Uuid> = self.entries.keys().copied().collect();
for session_id in existing_ids {
if !discovered_ids.contains(&session_id) {
if let Some(entry) = self.entries.remove(&session_id) {
self.remove_from_indexes(&session_id, &entry);
result.removed += 1;
changed = true;
}
}
}
// Upsert discovered entries.
for (session_id, entry) in discovered_entries {
if let Some(existing) = self.entries.get(&session_id).cloned() {
if should_replace(&existing, &entry) {
self.remove_from_indexes(&session_id, &existing);
self.index_entry(entry);
result.updated += 1;
changed = true;
}
} else {
self.index_entry(entry);
result.added += 1;
changed = true;
}
}
if changed {
self.save()?;
}
Ok(result)
}
/// Get the full absolute path to a session's rollout file.
#[allow(dead_code)]
pub fn resolve_rollout_path(&self, code_home: &Path, session_id: &Uuid) -> Option<PathBuf> {
self.entries
.get(session_id)
.map(|entry| code_home.join(&entry.rollout_path))
}
/// Get the full absolute path to a session's snapshot file.
#[allow(dead_code)]
pub fn resolve_snapshot_path(&self, code_home: &Path, session_id: &Uuid) -> Option<PathBuf> {
self.entries.get(session_id).and_then(|entry| {
entry
.snapshot_path
.as_ref()
.map(|p| code_home.join(p))
})
}
}
/// Result of reconciling the catalog against filesystem.
#[derive(Debug, Default)]
#[allow(dead_code)]
pub struct ReconcileResult {
pub added: usize,
pub updated: usize,
pub removed: usize,
}
/// Scan all rollout files under the sessions directory and build index entries.
#[allow(dead_code)]
async fn scan_rollout_files(
sessions_root: &Path,
) -> io::Result<HashMap<Uuid, SessionIndexEntry>> {
use tokio::fs;
let mut queue = vec![sessions_root.to_path_buf()];
let mut discovered = HashMap::new();
while let Some(dir) = queue.pop() {
let mut read_dir = fs::read_dir(&dir).await?;
while let Some(entry) = read_dir.next_entry().await? {
let path = entry.path();
let metadata = entry.metadata().await?;
if metadata.is_dir() {
queue.push(path);
} else if metadata.is_file() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.ends_with(".jsonl") && name.starts_with("rollout-") {
if let Some(index_entry) = parse_rollout_file(&path, sessions_root).await {
match discovered.get(&index_entry.session_id) {
Some(existing) => {
if should_replace(existing, &index_entry) {
discovered.insert(index_entry.session_id, index_entry);
}
}
None => {
discovered.insert(index_entry.session_id, index_entry);
}
}
}
}
}
}
}
}
Ok(discovered)
}
/// Parse a rollout file and extract catalog entry information.
async fn parse_rollout_file(
path: &Path,
sessions_root: &Path,
) -> Option<SessionIndexEntry> {
use tokio::io::{AsyncBufReadExt, BufReader};
// Read the file
let file = match tokio::fs::File::open(path).await {
Ok(f) => f,
Err(e) => {
warn!("Failed to open rollout file {:?}: {}", path, e);
return None;
}
};
let mut reader = BufReader::new(file).lines();
let mut session_id: Option<Uuid> = None;
let mut created_at: Option<String> = None;
let mut last_event_at: Option<String> = None;
let mut cwd_real: Option<PathBuf> = None;
let mut git_branch: Option<String> = None;
let mut git_project_root: Option<PathBuf> = None;
let mut session_source: Option<SessionSource> = None;
let mut message_count = 0usize;
let mut last_user_snippet: Option<String> = None;
// Parse the file line by line
while let Some(line) = reader.next_line().await.ok().flatten() {
if line.trim().is_empty() {
continue;
}
let rollout_line: RolloutLine = match serde_json::from_str(&line) {
Ok(rl) => rl,
Err(_) => continue,
};
// Update last_event_at with every line
last_event_at = Some(rollout_line.timestamp.clone());
match rollout_line.item {
RolloutItem::SessionMeta(meta_line) => {
session_id = Some(Uuid::from(meta_line.meta.id));
created_at = Some(rollout_line.timestamp.clone());
cwd_real = Some(meta_line.meta.cwd.clone());
session_source = Some(meta_line.meta.source);
if let Some(git_info) = meta_line.git {
git_branch = git_info.branch;
// Try to derive git project root from cwd and repository structure
// For now, just use cwd as approximation
git_project_root = Some(meta_line.meta.cwd.clone());
}
}
RolloutItem::ResponseItem(_) => {
message_count += 1;
}
RolloutItem::Event(event) => {
message_count += 1;
// Try to extract user message snippet
if let Some(msg) = crate::protocol::event_msg_from_protocol(&event.msg) {
if let crate::protocol::EventMsg::UserMessage(user_msg) = msg {
last_user_snippet = Some(
user_msg
.message
.chars()
.take(100)
.collect::<String>(),
);
}
}
}
RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) => {
message_count += 1;
}
}
}
// Build the entry
let session_id = session_id?;
let created_at = created_at?;
let last_event_at = last_event_at.unwrap_or_else(|| created_at.clone());
let cwd_real = cwd_real?;
let session_source = session_source?;
// Make rollout_path relative to sessions_root's parent (code_home)
let code_home = sessions_root.parent()?;
let rollout_path = path.strip_prefix(code_home).ok()?.to_path_buf();
// Check for snapshot file
let snapshot_path = {
let snapshot_file = path.with_extension("snapshot.json");
if snapshot_file.exists() {
snapshot_file
.strip_prefix(code_home)
.ok()
.map(|p| p.to_path_buf())
} else {
None
}
};
// Create display-friendly cwd (can be enhanced later with ~ substitution)
let cwd_display = cwd_real.to_string_lossy().to_string();
Some(SessionIndexEntry {
session_id,
rollout_path,
snapshot_path,
created_at,
last_event_at,
cwd_real,
cwd_display,
git_project_root,
git_branch,
model_provider: None, // TODO: extract from session if available
session_source,
message_count,
last_user_snippet,
sync_origin_device: None,
sync_version: 0,
archived: false,
deleted: false,
})
}
fn should_replace(existing: &SessionIndexEntry, candidate: &SessionIndexEntry) -> bool {
if existing.last_event_at != candidate.last_event_at {
return existing.last_event_at < candidate.last_event_at;
}
if existing.created_at != candidate.created_at {
return existing.created_at < candidate.created_at;
}
existing.rollout_path != candidate.rollout_path
|| existing.snapshot_path != candidate.snapshot_path
|| existing.cwd_real != candidate.cwd_real
|| existing.session_source != candidate.session_source
|| existing.git_branch != candidate.git_branch
}
/// Update catalog entry for a session after new events are written.
/// This is called by RolloutRecorder after flushing events.
pub async fn update_catalog_entry(
code_home: &Path,
rollout_path: &Path,
session_id: Uuid,
last_timestamp: &str,
) -> io::Result<()> {
let mut catalog = SessionCatalog::load(code_home)?;
// If entry exists, update last_event_at
if let Some(mut entry) = catalog.entries.remove(&session_id) {
entry.last_event_at = last_timestamp.to_string();
// Re-parse file to update message count and snippet
if let Some(updated) = parse_rollout_file(rollout_path, &code_home.join(SESSIONS_SUBDIR)).await {
entry.message_count = updated.message_count;
entry.last_user_snippet = updated.last_user_snippet;
}
catalog.upsert(entry)?;
} else {
// Entry doesn't exist, parse and add it
if let Some(entry) = parse_rollout_file(rollout_path, &code_home.join(SESSIONS_SUBDIR)).await {
catalog.upsert(entry)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_session_index_entry_ordering() {
let entry1 = SessionIndexEntry {
session_id: Uuid::new_v4(),
rollout_path: PathBuf::from("test1.jsonl"),
snapshot_path: None,
created_at: "2025-01-01T10:00:00.000Z".to_string(),
last_event_at: "2025-01-01T10:05:00.000Z".to_string(),
cwd_real: PathBuf::from("/test"),
cwd_display: "/test".to_string(),
git_project_root: None,
git_branch: None,
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
last_user_snippet: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
deleted: false,
};
let entry2 = SessionIndexEntry {
last_event_at: "2025-01-01T10:10:00.000Z".to_string(),
..entry1.clone()
};
// entry2 should come before entry1 (newer last_event_at)
assert!(entry2.ordering_key() < entry1.ordering_key());
}
#[tokio::test]
async fn test_catalog_load_save() -> io::Result<()> {
let temp = TempDir::new()?;
let code_home = temp.path();
let entry = SessionIndexEntry {
session_id: Uuid::new_v4(),
rollout_path: PathBuf::from("sessions/2025/01/01/rollout-test.jsonl"),
snapshot_path: None,
created_at: "2025-01-01T10:00:00.000Z".to_string(),
last_event_at: "2025-01-01T10:05:00.000Z".to_string(),
cwd_real: PathBuf::from("/test"),
cwd_display: "/test".to_string(),
git_project_root: None,
git_branch: None,
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
last_user_snippet: Some("test message".to_string()),
sync_origin_device: None,
sync_version: 0,
archived: false,
deleted: false,
};
// Create and save catalog
let mut catalog = SessionCatalog::load(code_home)?;
catalog.upsert(entry.clone())?;
// Load catalog and verify
let loaded = SessionCatalog::load(code_home)?;
assert_eq!(loaded.get(&entry.session_id), Some(&entry));
Ok(())
}
#[tokio::test]
async fn test_catalog_indexes() -> io::Result<()> {
let temp = TempDir::new()?;
let code_home = temp.path();
let cwd = PathBuf::from("/test/project");
let git_root = PathBuf::from("/test");
let entry1 = SessionIndexEntry {
session_id: Uuid::new_v4(),
rollout_path: PathBuf::from("sessions/test1.jsonl"),
snapshot_path: None,
created_at: "2025-01-01T10:00:00.000Z".to_string(),
last_event_at: "2025-01-01T10:05:00.000Z".to_string(),
cwd_real: cwd.clone(),
cwd_display: cwd.to_string_lossy().to_string(),
git_project_root: Some(git_root.clone()),
git_branch: Some("main".to_string()),
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
last_user_snippet: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
deleted: false,
};
let entry2 = SessionIndexEntry {
session_id: Uuid::new_v4(),
rollout_path: PathBuf::from("sessions/test2.jsonl"),
last_event_at: "2025-01-01T10:10:00.000Z".to_string(),
..entry1.clone()
};
let mut catalog = SessionCatalog::load(code_home)?;
catalog.upsert(entry1.clone())?;
catalog.upsert(entry2.clone())?;
// Test by_cwd index
let cwd_sessions = catalog.by_cwd(&cwd);
assert_eq!(cwd_sessions.len(), 2);
assert_eq!(cwd_sessions[0].session_id, entry2.session_id); // Newer first
// Test by_git_root index
let git_sessions = catalog.by_git_root(&git_root);
assert_eq!(git_sessions.len(), 2);
Ok(())
}
#[tokio::test]
async fn test_catalog_upsert_updates_existing() -> io::Result<()> {
let temp = TempDir::new()?;
let code_home = temp.path();
let session_id = Uuid::new_v4();
let entry1 = SessionIndexEntry {
session_id,
rollout_path: PathBuf::from("sessions/test.jsonl"),
snapshot_path: None,
created_at: "2025-01-01T10:00:00.000Z".to_string(),
last_event_at: "2025-01-01T10:05:00.000Z".to_string(),
cwd_real: PathBuf::from("/test"),
cwd_display: "/test".to_string(),
git_project_root: None,
git_branch: None,
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
last_user_snippet: Some("first message".to_string()),
sync_origin_device: None,
sync_version: 0,
archived: false,
deleted: false,
};
let mut catalog = SessionCatalog::load(code_home)?;
catalog.upsert(entry1.clone())?;
// Update the entry
let entry2 = SessionIndexEntry {
last_event_at: "2025-01-01T10:15:00.000Z".to_string(),
message_count: 10,
last_user_snippet: Some("updated message".to_string()),
..entry1.clone()
};
catalog.upsert(entry2.clone())?;
// Verify update
let loaded = SessionCatalog::load(code_home)?;
let retrieved = loaded.get(&session_id).unwrap();
assert_eq!(retrieved.last_event_at, "2025-01-01T10:15:00.000Z");
assert_eq!(retrieved.message_count, 10);
assert_eq!(retrieved.last_user_snippet, Some("updated message".to_string()));
Ok(())
}
#[tokio::test]
async fn test_catalog_remove() -> io::Result<()> {
let temp = TempDir::new()?;
let code_home = temp.path();
let entry = SessionIndexEntry {
session_id: Uuid::new_v4(),
rollout_path: PathBuf::from("sessions/test.jsonl"),
snapshot_path: None,
created_at: "2025-01-01T10:00:00.000Z".to_string(),
last_event_at: "2025-01-01T10:05:00.000Z".to_string(),
cwd_real: PathBuf::from("/test"),
cwd_display: "/test".to_string(),
git_project_root: None,
git_branch: None,
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
last_user_snippet: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
deleted: false,
};
let mut catalog = SessionCatalog::load(code_home)?;
catalog.upsert(entry.clone())?;
// Verify it exists
assert!(catalog.get(&entry.session_id).is_some());
// Remove it
catalog.remove(&entry.session_id)?;
// Verify it's gone
let loaded = SessionCatalog::load(code_home)?;
assert!(loaded.get(&entry.session_id).is_none());
Ok(())
}
#[tokio::test]
async fn test_catalog_ordering_multi_criteria() -> io::Result<()> {
let temp = TempDir::new()?;
let code_home = temp.path();
// Create entries with different timestamps
let entry1 = SessionIndexEntry {
session_id: Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
rollout_path: PathBuf::from("sessions/test1.jsonl"),
snapshot_path: None,
created_at: "2025-01-01T10:00:00.000Z".to_string(),
last_event_at: "2025-01-01T10:05:00.000Z".to_string(),
cwd_real: PathBuf::from("/test"),
cwd_display: "/test".to_string(),
git_project_root: None,
git_branch: None,
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
last_user_snippet: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
deleted: false,
};
let entry2 = SessionIndexEntry {
session_id: Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(),
rollout_path: PathBuf::from("sessions/test2.jsonl"),
last_event_at: "2025-01-01T10:10:00.000Z".to_string(), // Newer
..entry1.clone()
};
let entry3 = SessionIndexEntry {
session_id: Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(),
rollout_path: PathBuf::from("sessions/test3.jsonl"),
last_event_at: "2025-01-01T10:10:00.000Z".to_string(), // Same as entry2
created_at: "2025-01-01T10:01:00.000Z".to_string(), // Later created_at
..entry1.clone()
};
let mut catalog = SessionCatalog::load(code_home)?;
catalog.upsert(entry1.clone())?;
catalog.upsert(entry2.clone())?;
catalog.upsert(entry3.clone())?;
// Get all ordered
let all = catalog.all_ordered();
assert_eq!(all.len(), 3);
// entry3 should be first (latest last_event_at, and if tied, latest created_at)
// entry2 should be second (same last_event_at as entry3 but earlier created_at)
// entry1 should be last (earliest last_event_at)
assert_eq!(all[0].session_id, entry3.session_id);
assert_eq!(all[1].session_id, entry2.session_id);
assert_eq!(all[2].session_id, entry1.session_id);
Ok(())
}
#[tokio::test]
async fn test_catalog_resolve_paths() -> io::Result<()> {
let temp = TempDir::new()?;
let code_home = temp.path();
let entry = SessionIndexEntry {
session_id: Uuid::new_v4(),
rollout_path: PathBuf::from("sessions/2025/01/01/rollout-test.jsonl"),
snapshot_path: Some(PathBuf::from("sessions/2025/01/01/rollout-test.snapshot.json")),
created_at: "2025-01-01T10:00:00.000Z".to_string(),
last_event_at: "2025-01-01T10:05:00.000Z".to_string(),
cwd_real: PathBuf::from("/test"),
cwd_display: "/test".to_string(),
git_project_root: None,
git_branch: None,
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
last_user_snippet: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
deleted: false,
};
let mut catalog = SessionCatalog::load(code_home)?;
catalog.upsert(entry.clone())?;
// Test resolve paths
let rollout_path = catalog.resolve_rollout_path(code_home, &entry.session_id);
assert_eq!(
rollout_path,
Some(code_home.join("sessions/2025/01/01/rollout-test.jsonl"))
);
let snapshot_path = catalog.resolve_snapshot_path(code_home, &entry.session_id);
assert_eq!(
snapshot_path,
Some(code_home.join("sessions/2025/01/01/rollout-test.snapshot.json"))
);
Ok(())
}
#[tokio::test]
async fn test_catalog_reconcile_empty_directory() -> io::Result<()> {
let temp = TempDir::new()?;
let code_home = temp.path();
let mut catalog = SessionCatalog::load(code_home)?;
let result = catalog.reconcile(code_home).await?;
assert_eq!(result.added, 0);
assert_eq!(result.updated, 0);
Ok(())
}
#[tokio::test]
async fn test_catalog_by_cwd_empty() -> io::Result<()> {
let temp = TempDir::new()?;
let code_home = temp.path();
let catalog = SessionCatalog::load(code_home)?;
let sessions = catalog.by_cwd(&PathBuf::from("/nonexistent"));
assert_eq!(sessions.len(), 0);
Ok(())
}
#[tokio::test]
async fn test_catalog_secondary_index_updates() -> io::Result<()> {
let temp = TempDir::new()?;
let code_home = temp.path();
let session_id = Uuid::new_v4();
let cwd1 = PathBuf::from("/test1");
let cwd2 = PathBuf::from("/test2");
// Create entry with cwd1
let entry1 = SessionIndexEntry {
session_id,
rollout_path: PathBuf::from("sessions/test.jsonl"),
snapshot_path: None,
created_at: "2025-01-01T10:00:00.000Z".to_string(),
last_event_at: "2025-01-01T10:05:00.000Z".to_string(),
cwd_real: cwd1.clone(),
cwd_display: cwd1.to_string_lossy().to_string(),
git_project_root: None,
git_branch: None,
model_provider: None,
session_source: SessionSource::Cli,
message_count: 5,
last_user_snippet: None,
sync_origin_device: None,
sync_version: 0,
archived: false,
deleted: false,
};
let mut catalog = SessionCatalog::load(code_home)?;
catalog.upsert(entry1.clone())?;
// Verify it's in cwd1 index
assert_eq!(catalog.by_cwd(&cwd1).len(), 1);
assert_eq!(catalog.by_cwd(&cwd2).len(), 0);
// Update to cwd2
let entry2 = SessionIndexEntry {
cwd_real: cwd2.clone(),
cwd_display: cwd2.to_string_lossy().to_string(),
..entry1.clone()
};
catalog.upsert(entry2)?;
// Verify indexes updated
assert_eq!(catalog.by_cwd(&cwd1).len(), 0);
assert_eq!(catalog.by_cwd(&cwd2).len(), 1);
Ok(())
}
}
+1 -1
View File
@@ -8,11 +8,11 @@ pub const ARCHIVED_SESSIONS_SUBDIR: &str = "archived_sessions";
pub const INTERACTIVE_SESSION_SOURCES: &[SessionSource] =
&[SessionSource::Cli, SessionSource::VSCode];
pub mod catalog;
pub mod list;
pub(crate) mod policy;
pub mod recorder;
#[allow(unused_imports)]
pub use code_protocol::protocol::SessionMeta;
#[allow(unused_imports)]
pub use list::find_conversation_path_by_id_str;
+63 -6
View File
@@ -87,6 +87,14 @@ enum RolloutCmd {
Shutdown { ack: oneshot::Sender<()> },
}
/// Tracks context needed to update the session catalog after writes.
struct CatalogUpdateState {
code_home: PathBuf,
session_id: ConversationId,
rollout_path: PathBuf,
last_timestamp: String,
}
impl RolloutRecorderParams {
pub fn new(
conversation_id: ConversationId,
@@ -167,6 +175,13 @@ impl RolloutRecorder {
let cwd = config.cwd.clone();
let snapshot_path = rollout_path.with_extension("snapshot.json");
let catalog_state = meta.as_ref().map(|meta| CatalogUpdateState {
code_home: config.code_home.clone(),
session_id: meta.id,
rollout_path: rollout_path.clone(),
last_timestamp: meta.timestamp.clone(),
});
// A reasonably-sized bounded channel. If the buffer fills up the send
// future will yield, which is fine – we only need to ensure we do not
// perform *blocking* I/O on the caller's thread.
@@ -175,7 +190,14 @@ impl RolloutRecorder {
// Spawn a Tokio task that owns the file handle and performs async
// writes. Using `tokio::fs::File` keeps everything on the async I/O
// driver instead of blocking the runtime.
tokio::task::spawn(rollout_writer(file, rx, meta, cwd, snapshot_path));
tokio::task::spawn(rollout_writer(
file,
rx,
meta,
cwd,
snapshot_path,
catalog_state,
));
Ok(Self { tx, rollout_path })
}
@@ -458,6 +480,7 @@ async fn rollout_writer(
mut meta: Option<SessionMeta>,
cwd: std::path::PathBuf,
snapshot_path: PathBuf,
mut catalog_state: Option<CatalogUpdateState>,
) -> std::io::Result<()> {
let mut writer = JsonlWriter { file };
@@ -470,9 +493,23 @@ async fn rollout_writer(
};
// Write the SessionMeta as the first item in the file, wrapped in a rollout line
writer
let (timestamp, _) = writer
.write_rollout_item(RolloutItem::SessionMeta(session_meta_line))
.await?;
if let Some(ref mut state) = catalog_state {
state.last_timestamp = timestamp;
if let Err(err) = super::catalog::update_catalog_entry(
&state.code_home,
&state.rollout_path,
state.session_id.into(),
&state.last_timestamp,
)
.await
{
warn!("failed to update session catalog after meta write: {err}");
}
}
}
// Process rollout commands
@@ -481,7 +518,23 @@ async fn rollout_writer(
RolloutCmd::AddItems(items) => {
for item in items {
if should_persist_rollout_item(&item) {
writer.write_rollout_item(item).await?;
let (timestamp, _) = writer.write_rollout_item(item).await?;
if let Some(ref mut state) = catalog_state {
state.last_timestamp = timestamp;
}
}
}
if let Some(ref state) = catalog_state {
if let Err(err) = super::catalog::update_catalog_entry(
&state.code_home,
&state.rollout_path,
state.session_id.into(),
&state.last_timestamp,
)
.await
{
warn!("failed to update session catalog after AddItems: {err}");
}
}
}
@@ -510,7 +563,10 @@ struct JsonlWriter {
}
impl JsonlWriter {
async fn write_rollout_item(&mut self, rollout_item: RolloutItem) -> std::io::Result<()> {
async fn write_rollout_item(
&mut self,
rollout_item: RolloutItem,
) -> std::io::Result<(String, ())> {
let timestamp_format: &[FormatItem] = format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
);
@@ -519,10 +575,11 @@ impl JsonlWriter {
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
let line = RolloutLine {
timestamp,
timestamp: timestamp.clone(),
item: rollout_item,
};
self.write_line(&line).await
self.write_line(&line).await?;
Ok((timestamp, ()))
}
async fn write_line(&mut self, item: &impl serde::Serialize) -> std::io::Result<()> {
let mut json = serde_json::to_string(item)?;
+129
View File
@@ -0,0 +1,129 @@
//! Async-friendly wrapper around the rollout session catalog.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use code_protocol::protocol::SessionSource;
use tokio::task;
use crate::rollout::catalog::{self as rollout_catalog, SessionIndexEntry};
/// Query parameters for catalog lookups.
#[derive(Debug, Clone, Default)]
pub struct SessionQuery {
/// Filter by canonical working directory (exact match).
pub cwd: Option<PathBuf>,
/// Filter by git project root.
pub git_root: Option<PathBuf>,
/// Restrict to these sources; empty = all sources.
pub sources: Vec<SessionSource>,
/// Include archived sessions.
pub include_archived: bool,
/// Include deleted sessions.
pub include_deleted: bool,
/// Maximum number of rows to return.
pub limit: Option<usize>,
}
/// Public catalog facade used by TUI/CLI/Exec entrypoints.
pub struct SessionCatalog {
code_home: PathBuf,
}
impl SessionCatalog {
/// Create a catalog facade for the provided code home directory.
pub fn new(code_home: PathBuf) -> Self {
Self { code_home }
}
/// Query the catalog with the provided filters, returning ordered entries.
pub async fn query(&self, query: &SessionQuery) -> Result<Vec<SessionIndexEntry>> {
let catalog = self.load_inner().await?;
let mut rows = Vec::new();
for entry in catalog.all_ordered() {
if !query.include_archived && entry.archived {
continue;
}
if !query.include_deleted && entry.deleted {
continue;
}
if let Some(cwd) = &query.cwd {
if &entry.cwd_real != cwd {
continue;
}
}
if let Some(git_root) = &query.git_root {
if entry.git_project_root.as_ref() != Some(git_root) {
continue;
}
}
if !query.sources.is_empty() && !query.sources.contains(&entry.session_source) {
continue;
}
rows.push(entry.clone());
if let Some(limit) = query.limit {
if rows.len() >= limit {
break;
}
}
}
Ok(rows)
}
/// Find a session by UUID (prefix matches allowed, case-insensitive).
pub async fn find_by_id(&self, id_prefix: &str) -> Result<Option<SessionIndexEntry>> {
let catalog = self.load_inner().await?;
let needle = id_prefix.to_ascii_lowercase();
let entry = catalog
.all_ordered()
.into_iter()
.find(|entry| {
entry
.session_id
.to_string()
.to_ascii_lowercase()
.starts_with(&needle)
})
.cloned();
Ok(entry)
}
/// Return the newest session matching the query.
pub async fn get_latest(&self, query: &SessionQuery) -> Result<Option<SessionIndexEntry>> {
let mut limited = query.clone();
limited.limit = Some(1);
let mut rows = self.query(&limited).await?;
Ok(rows.pop())
}
/// Convert a catalog entry to an absolute rollout path.
pub fn entry_rollout_path(&self, entry: &SessionIndexEntry) -> PathBuf {
entry_to_rollout_path(&self.code_home, entry)
}
async fn load_inner(&self) -> Result<rollout_catalog::SessionCatalog> {
let code_home = self.code_home.clone();
let mut catalog = task::spawn_blocking(move || rollout_catalog::SessionCatalog::load(&code_home))
.await
.context("catalog task panicked")?
.context("failed to load session catalog")?;
catalog
.reconcile(&self.code_home)
.await
.context("failed to reconcile session catalog")?;
Ok(catalog)
}
}
/// Helper to convert an entry to an absolute rollout path.
pub fn entry_to_rollout_path(code_home: &Path, entry: &SessionIndexEntry) -> PathBuf {
code_home.join(&entry.rollout_path)
}
@@ -0,0 +1,317 @@
use std::fs;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use code_core::{SessionCatalog, SessionIndexEntry, SessionQuery};
use code_protocol::models::{ContentItem, ResponseItem};
use code_protocol::protocol::{
EventMsg as ProtoEventMsg, RecordedEvent, RolloutItem, RolloutLine, SessionMeta,
SessionMetaLine, SessionSource, UserMessageEvent,
};
use code_protocol::ConversationId;
use filetime::{set_file_mtime, FileTime};
use tempfile::TempDir;
use uuid::Uuid;
fn write_catalog(code_home: &Path, entries: &[SessionIndexEntry]) {
let catalog_path = code_home.join("sessions/index/catalog.jsonl");
if let Some(parent) = catalog_path.parent() {
fs::create_dir_all(parent).unwrap();
}
let data = entries
.iter()
.map(|entry| serde_json::to_string(entry).unwrap())
.collect::<Vec<_>>()
.join("\n");
fs::write(catalog_path, format!("{}\n", data)).unwrap();
}
fn entry(id: &str, source: SessionSource, last: &str, created: &str, cwd: &Path) -> SessionIndexEntry {
SessionIndexEntry {
session_id: Uuid::parse_str(id).unwrap(),
rollout_path: PathBuf::from(format!("sessions/2025/11/16/rollout-{id}.jsonl")),
snapshot_path: None,
created_at: created.to_string(),
last_event_at: last.to_string(),
cwd_real: cwd.to_path_buf(),
cwd_display: cwd.to_string_lossy().to_string(),
git_project_root: None,
git_branch: None,
model_provider: Some("test-provider".to_string()),
session_source: source,
message_count: 3,
last_user_snippet: Some("last user input".to_string()),
sync_origin_device: None,
sync_version: 0,
archived: false,
deleted: false,
}
}
fn write_rollout_transcript(
code_home: &Path,
session_id: Uuid,
created_at: &str,
last_event_at: &str,
cwd: &Path,
source: SessionSource,
user_message: &str,
) -> PathBuf {
let sessions_dir = code_home.join("sessions").join("2025").join("11").join("16");
fs::create_dir_all(&sessions_dir).unwrap();
let filename = format!(
"rollout-{}-{}.jsonl",
created_at.replace(':', "-"),
session_id
);
let path = sessions_dir.join(filename);
let session_meta = SessionMeta {
id: ConversationId::from(session_id),
timestamp: created_at.to_string(),
cwd: cwd.to_path_buf(),
originator: "test".to_string(),
cli_version: "0.0.0-test".to_string(),
instructions: None,
source,
};
let session_line = RolloutLine {
timestamp: created_at.to_string(),
item: RolloutItem::SessionMeta(SessionMetaLine {
meta: session_meta,
git: None,
}),
};
let user_event = RolloutLine {
timestamp: last_event_at.to_string(),
item: RolloutItem::Event(RecordedEvent {
id: "event-0".to_string(),
event_seq: 0,
order: None,
msg: ProtoEventMsg::UserMessage(UserMessageEvent {
message: user_message.to_string(),
kind: None,
images: None,
}),
}),
};
let response_line = RolloutLine {
timestamp: last_event_at.to_string(),
item: RolloutItem::ResponseItem(ResponseItem::Message {
id: Some(format!("msg-{}", session_id)),
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "Ack".to_string(),
}],
}),
};
let mut writer = BufWriter::new(std::fs::File::create(&path).unwrap());
serde_json::to_writer(&mut writer, &session_line).unwrap();
writer.write_all(b"\n").unwrap();
serde_json::to_writer(&mut writer, &user_event).unwrap();
writer.write_all(b"\n").unwrap();
serde_json::to_writer(&mut writer, &response_line).unwrap();
writer.write_all(b"\n").unwrap();
writer.flush().unwrap();
path
}
#[tokio::test]
async fn query_includes_exec_sessions() {
let temp = TempDir::new().unwrap();
let cwd = PathBuf::from("/workspace/project");
let cli_entry = entry(
"11111111-1111-4111-8111-111111111111",
SessionSource::Cli,
"2025-11-15T12:00:00Z",
"2025-11-15T11:59:00Z",
&cwd,
);
let exec_entry = entry(
"22222222-2222-4222-8222-222222222222",
SessionSource::Exec,
"2025-11-15T13:00:00Z",
"2025-11-15T12:59:00Z",
&cwd,
);
write_catalog(temp.path(), &[cli_entry.clone(), exec_entry.clone()]);
let catalog = SessionCatalog::new(temp.path().to_path_buf());
let query = SessionQuery {
cwd: Some(cwd.clone()),
..SessionQuery::default()
};
let results = catalog.query(&query).await.unwrap();
assert_eq!(results.len(), 2);
let sources: Vec<_> = results.iter().map(|e| e.session_source).collect();
assert!(sources.contains(&SessionSource::Cli));
assert!(sources.contains(&SessionSource::Exec));
}
#[tokio::test]
async fn latest_prefers_newer_timestamp() {
let temp = TempDir::new().unwrap();
let cwd = PathBuf::from("/workspace/project");
let older = entry(
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
SessionSource::Cli,
"2025-11-15T10:00:00Z",
"2025-11-15T09:00:00Z",
&cwd,
);
let newer = entry(
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
SessionSource::Cli,
"2025-11-16T10:00:00Z",
"2025-11-16T09:00:00Z",
&cwd,
);
write_catalog(temp.path(), &[older, newer.clone()]);
let catalog = SessionCatalog::new(temp.path().to_path_buf());
let query = SessionQuery { limit: Some(1), ..SessionQuery::default() };
let result = catalog.get_latest(&query).await.unwrap().unwrap();
assert_eq!(result.session_id, newer.session_id);
}
#[tokio::test]
async fn find_by_prefix_matches_entry() {
let temp = TempDir::new().unwrap();
let cwd = PathBuf::from("/workspace/project");
let target = entry(
"12345678-9abc-4def-8123-456789abcdef",
SessionSource::Cli,
"2025-11-16T12:34:56Z",
"2025-11-16T12:00:00Z",
&cwd,
);
write_catalog(temp.path(), &[target.clone()]);
let catalog = SessionCatalog::new(temp.path().to_path_buf());
let result = catalog
.find_by_id("12345678")
.await
.unwrap()
.expect("entry should exist");
assert_eq!(result.session_id, target.session_id);
}
#[tokio::test]
async fn bootstrap_catalog_from_rollouts() {
let temp = TempDir::new().unwrap();
let cwd = PathBuf::from("/workspace/project");
let cli_id = Uuid::parse_str("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa").unwrap();
let exec_id = Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap();
write_rollout_transcript(
temp.path(),
cli_id,
"2025-11-15T10:00:00Z",
"2025-11-15T10:00:10Z",
&cwd,
SessionSource::Cli,
"cli",
);
write_rollout_transcript(
temp.path(),
exec_id,
"2025-11-16T09:00:00Z",
"2025-11-16T09:10:00Z",
&cwd,
SessionSource::Exec,
"exec",
);
let catalog = SessionCatalog::new(temp.path().to_path_buf());
let query = SessionQuery {
cwd: Some(cwd.clone()),
..SessionQuery::default()
};
let results = catalog.query(&query).await.unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0].session_id, exec_id);
assert_eq!(results[1].session_id, cli_id);
}
#[tokio::test]
async fn reconcile_removes_deleted_sessions() {
let temp = TempDir::new().unwrap();
let cwd = PathBuf::from("/workspace/project");
let session_id = Uuid::parse_str("cccccccc-cccc-4ccc-8ccc-cccccccccccc").unwrap();
let rollout_path = write_rollout_transcript(
temp.path(),
session_id,
"2025-11-15T12:00:00Z",
"2025-11-15T12:05:00Z",
&cwd,
SessionSource::Cli,
"to delete",
);
let catalog = SessionCatalog::new(temp.path().to_path_buf());
let query = SessionQuery {
cwd: Some(cwd.clone()),
..SessionQuery::default()
};
let results = catalog.query(&query).await.unwrap();
assert_eq!(results.len(), 1);
fs::remove_file(rollout_path).unwrap();
let results_after = catalog.query(&query).await.unwrap();
assert!(results_after.is_empty());
}
#[tokio::test]
async fn reconcile_prefers_last_event_over_mtime() {
let temp = TempDir::new().unwrap();
let cwd = PathBuf::from("/workspace/project");
let older_id = Uuid::parse_str("dddddddd-dddd-4ddd-8ddd-dddddddddddd").unwrap();
let newer_id = Uuid::parse_str("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee").unwrap();
let older_path = write_rollout_transcript(
temp.path(),
older_id,
"2025-11-10T09:00:00Z",
"2025-11-10T09:05:00Z",
&cwd,
SessionSource::Cli,
"old",
);
let newer_path = write_rollout_transcript(
temp.path(),
newer_id,
"2025-11-16T09:00:00Z",
"2025-11-16T09:15:00Z",
&cwd,
SessionSource::Exec,
"new",
);
let base = SystemTime::now();
set_file_mtime(&older_path, FileTime::from_system_time(base + Duration::from_secs(300))).unwrap();
set_file_mtime(&newer_path, FileTime::from_system_time(base + Duration::from_secs(60))).unwrap();
let catalog = SessionCatalog::new(temp.path().to_path_buf());
let latest = catalog
.get_latest(&SessionQuery::default())
.await
.unwrap()
.expect("latest entry");
assert_eq!(latest.session_id, newer_id);
}
+2
View File
@@ -49,3 +49,5 @@ libc = "0.2"
[dev-dependencies]
filetime = { workspace = true }
tempfile = { workspace = true }
+232 -12
View File
@@ -33,10 +33,11 @@ use tracing::error;
use tracing::info;
use tracing_subscriber::EnvFilter;
use anyhow::Context;
use crate::cli::Command as ExecCommand;
use crate::event_processor::CodexStatus;
use crate::event_processor::EventProcessor;
use code_core::find_conversation_path_by_id_str;
use code_core::{entry_to_rollout_path, SessionCatalog, SessionQuery};
pub async fn run_main(cli: Cli, code_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
if let Err(err) = set_default_originator("code_exec") {
@@ -438,17 +439,32 @@ async fn resolve_resume_path(
config: &Config,
args: &crate::cli::ResumeArgs,
) -> anyhow::Result<Option<PathBuf>> {
if args.last {
match code_core::RolloutRecorder::list_conversations(&config.code_home, 1, None, &[]).await {
Ok(page) => Ok(page.items.first().map(|it| it.path.clone())),
Err(e) => {
error!("Error listing conversations: {e}");
Ok(None)
}
}
} else if let Some(id_str) = args.session_id.as_deref() {
let path = find_conversation_path_by_id_str(&config.code_home, id_str).await?;
Ok(path)
if !args.last && args.session_id.is_none() {
return Ok(None);
}
let catalog = SessionCatalog::new(config.code_home.clone());
if let Some(id_str) = args.session_id.as_deref() {
let entry = catalog
.find_by_id(id_str)
.await
.context("failed to look up session by id")?;
Ok(entry.map(|entry| entry_to_rollout_path(&config.code_home, &entry)))
} else if args.last {
let query = SessionQuery {
cwd: None,
git_root: None,
sources: Vec::new(),
include_archived: false,
include_deleted: false,
limit: Some(1),
};
let entry = catalog
.get_latest(&query)
.await
.context("failed to get latest session from catalog")?;
Ok(entry.map(|entry| entry_to_rollout_path(&config.code_home, &entry)))
} else {
Ok(None)
}
@@ -479,3 +495,207 @@ fn load_output_schema(path: Option<PathBuf>) -> Option<Value> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use code_core::config::{ConfigOverrides, ConfigToml};
use code_protocol::models::{ContentItem, ResponseItem};
use code_protocol::mcp_protocol::ConversationId;
use code_protocol::protocol::{
EventMsg as ProtoEventMsg, RecordedEvent, RolloutItem, RolloutLine, SessionMeta,
SessionMetaLine, SessionSource, UserMessageEvent,
};
use filetime::{set_file_mtime, FileTime};
use tempfile::TempDir;
use uuid::Uuid;
fn test_config(code_home: &Path) -> Config {
let mut overrides = ConfigOverrides::default();
let workspace = code_home.join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
overrides.cwd = Some(workspace);
Config::load_from_base_config_with_overrides(
ConfigToml::default(),
overrides,
code_home.to_path_buf(),
)
.unwrap()
}
fn write_rollout(
code_home: &Path,
session_id: Uuid,
created_at: &str,
last_event_at: &str,
source: SessionSource,
message: &str,
) -> PathBuf {
let sessions_dir = code_home.join("sessions").join("2025").join("11").join("16");
std::fs::create_dir_all(&sessions_dir).unwrap();
let filename = format!(
"rollout-{}-{}.jsonl",
created_at.replace(':', "-"),
session_id
);
let path = sessions_dir.join(filename);
let session_meta = SessionMeta {
id: ConversationId::from(session_id),
timestamp: created_at.to_string(),
cwd: Path::new("/workspace/project").to_path_buf(),
originator: "test".to_string(),
cli_version: "0.0.0-test".to_string(),
instructions: None,
source,
};
let session_line = RolloutLine {
timestamp: created_at.to_string(),
item: RolloutItem::SessionMeta(SessionMetaLine {
meta: session_meta,
git: None,
}),
};
let event_line = RolloutLine {
timestamp: last_event_at.to_string(),
item: RolloutItem::Event(RecordedEvent {
id: "event-0".to_string(),
event_seq: 0,
order: None,
msg: ProtoEventMsg::UserMessage(UserMessageEvent {
message: message.to_string(),
kind: None,
images: None,
}),
}),
};
let assistant_line = RolloutLine {
timestamp: last_event_at.to_string(),
item: RolloutItem::ResponseItem(ResponseItem::Message {
id: Some(format!("msg-{}", session_id)),
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: format!("Ack: {}", message),
}],
}),
};
let mut writer = std::io::BufWriter::new(std::fs::File::create(&path).unwrap());
serde_json::to_writer(&mut writer, &session_line).unwrap();
writer.write_all(b"\n").unwrap();
serde_json::to_writer(&mut writer, &event_line).unwrap();
writer.write_all(b"\n").unwrap();
serde_json::to_writer(&mut writer, &assistant_line).unwrap();
writer.write_all(b"\n").unwrap();
writer.flush().unwrap();
path
}
#[tokio::test]
async fn exec_resolve_last_prefers_latest_timestamp() {
let temp = TempDir::new().unwrap();
let config = test_config(temp.path());
let older = Uuid::parse_str("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa").unwrap();
let newer = Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap();
write_rollout(
temp.path(),
older,
"2025-11-10T09:00:00Z",
"2025-11-10T09:05:00Z",
SessionSource::Cli,
"older",
);
write_rollout(
temp.path(),
newer,
"2025-11-16T09:00:00Z",
"2025-11-16T09:10:00Z",
SessionSource::Exec,
"newer",
);
let args = crate::cli::ResumeArgs {
session_id: None,
last: true,
prompt: None,
};
let path = resolve_resume_path(&config, &args)
.await
.unwrap()
.expect("path");
assert!(path.ends_with("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb.jsonl"));
}
#[tokio::test]
async fn exec_resolve_by_id_uses_catalog_bootstrap() {
let temp = TempDir::new().unwrap();
let config = test_config(temp.path());
let session_id = Uuid::parse_str("cccccccc-cccc-4ccc-8ccc-cccccccccccc").unwrap();
write_rollout(
temp.path(),
session_id,
"2025-11-12T09:00:00Z",
"2025-11-12T09:05:00Z",
SessionSource::Cli,
"resume",
);
let args = crate::cli::ResumeArgs {
session_id: Some("cccccccc".to_string()),
last: false,
prompt: None,
};
let path = resolve_resume_path(&config, &args)
.await
.unwrap()
.expect("path");
assert!(path.ends_with("cccccccc-cccc-4ccc-8ccc-cccccccccccc.jsonl"));
}
#[tokio::test]
async fn exec_resolve_last_ignores_mtime_drift() {
let temp = TempDir::new().unwrap();
let config = test_config(temp.path());
let older = Uuid::parse_str("dddddddd-dddd-4ddd-8ddd-dddddddddddd").unwrap();
let newer = Uuid::parse_str("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee").unwrap();
let older_path = write_rollout(
temp.path(),
older,
"2025-11-01T09:00:00Z",
"2025-11-01T09:05:00Z",
SessionSource::Cli,
"old",
);
let newer_path = write_rollout(
temp.path(),
newer,
"2025-11-20T09:00:00Z",
"2025-11-20T09:05:00Z",
SessionSource::Exec,
"new",
);
let base = SystemTime::now();
set_file_mtime(&older_path, FileTime::from_system_time(base + Duration::from_secs(500))).unwrap();
set_file_mtime(&newer_path, FileTime::from_system_time(base + Duration::from_secs(10))).unwrap();
let args = crate::cli::ResumeArgs {
session_id: None,
last: true,
prompt: None,
};
let path = resolve_resume_path(&config, &args)
.await
.unwrap()
.expect("path");
assert!(path.ends_with("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee.jsonl"));
}
}
+1
View File
@@ -113,6 +113,7 @@ signal-hook = "0.3"
[dev-dependencies]
chrono = { version = "0.4", features = ["serde"] }
filetime = { workspace = true }
indoc = "2.0"
insta = "1.43.1"
pretty_assertions = "1"
+1 -1
View File
@@ -73,7 +73,7 @@ mod session_log;
mod shimmer;
mod slash_command;
mod rate_limits_view;
mod resume;
pub mod resume;
mod streaming;
mod sanitize;
mod layout_consts;
+34 -329
View File
@@ -1,12 +1,4 @@
use code_core::protocol::EventMsg;
use code_protocol::models::{ContentItem, ResponseItem};
use code_protocol::protocol::{RolloutItem, RolloutLine};
use code_core::Cursor;
use code_core::RolloutRecorder;
use code_core::INTERACTIVE_SESSION_SOURCES;
use serde::Deserialize;
use std::fs;
use std::io::{BufRead, BufReader};
use code_core::{entry_to_rollout_path, SessionCatalog, SessionIndexEntry, SessionQuery};
use std::path::{Path, PathBuf};
use std::thread;
use tokio::runtime::{Builder, Handle};
@@ -15,7 +7,6 @@ use tokio::runtime::{Builder, Handle};
pub struct ResumeCandidate {
pub path: PathBuf,
pub subtitle: Option<String>,
pub sort_key: String,
pub created_ts: Option<String>,
pub modified_ts: Option<String>,
pub message_count: usize,
@@ -23,170 +14,45 @@ pub struct ResumeCandidate {
pub snippet: Option<String>,
}
// No fallback scan: meta parsing for rollout headers no longer needed here.
/// Return rollout files under ~/.code/sessions matching the provided cwd (Code
/// still reads legacy ~/.codex/sessions directories).
/// Reads only the first line of each file to avoid heavy IO.
/// Return sessions matching the provided cwd using the SessionCatalog.
/// Includes CLI, VSCode, Exec/model sessions, etc.
pub fn list_sessions_for_cwd(cwd: &Path, code_home: &Path) -> Vec<ResumeCandidate> {
use std::collections::HashMap;
const MAX_RESULTS: usize = 200;
let mut by_path: HashMap<PathBuf, ResumeCandidate> = HashMap::new();
if let Some(index_rows) = read_dir_index(code_home, cwd) {
for row in index_rows {
by_path.entry(row.path.clone()).or_insert(row);
}
}
for row in fallback_scan_sessions_for_cwd(cwd, code_home) {
by_path.entry(row.path.clone()).or_insert(row);
}
let mut results: Vec<ResumeCandidate> = by_path.into_values().collect();
results.sort_by(|a, b| b.sort_key.cmp(&a.sort_key));
if results.len() > MAX_RESULTS {
results.truncate(MAX_RESULTS);
}
results
}
#[derive(Deserialize)]
struct DirIndexLine {
record_type: String,
cwd: String,
session_file: String,
created_ts: Option<String>,
modified_ts: Option<String>,
message_count_delta: Option<usize>,
model: Option<String>,
branch: Option<String>,
last_user_snippet: Option<String>,
}
fn read_dir_index(code_home: &Path, cwd: &Path) -> Option<Vec<ResumeCandidate>> {
let index_path = super_sanitize_dir_index_path(code_home, cwd);
let f = fs::File::open(index_path).ok()?;
let reader = BufReader::new(f);
use std::collections::HashMap;
struct Accum {
created: Option<String>,
modified: Option<String>,
count: usize,
model: Option<String>,
branch: Option<String>,
snippet: Option<String>,
}
let mut map: HashMap<String, Accum> = HashMap::new();
for line in reader.lines() {
let Ok(l) = line else { continue };
if l.trim().is_empty() { continue; }
let Ok(v) = serde_json::from_str::<DirIndexLine>(&l) else { continue };
if v.record_type != "dir_index" { continue; }
if v.cwd.is_empty() { continue; }
let e = map.entry(v.session_file.clone()).or_insert(Accum {
created: v.created_ts.clone(),
modified: v.modified_ts.clone(),
count: 0,
model: v.model.clone(),
branch: v.branch.clone(),
snippet: None,
});
if e.created.is_none() { e.created = v.created_ts.clone(); }
e.modified = v.modified_ts.clone().or(e.modified.take());
e.count = e.count.saturating_add(v.message_count_delta.unwrap_or(0));
if let Some(s) = v.last_user_snippet { if !s.is_empty() { e.snippet = Some(s); } }
if e.model.is_none() { e.model = v.model.clone(); }
if e.branch.is_none() { e.branch = v.branch.clone(); }
}
let mut out = Vec::new();
for (path, a) in map.into_iter() {
if a.count == 0 { continue; }
let subtitle = a.snippet.clone();
out.push(ResumeCandidate {
path: PathBuf::from(path),
subtitle: subtitle.clone(),
sort_key: a.modified.clone().unwrap_or_default(),
created_ts: a.created,
modified_ts: a.modified,
message_count: a.count,
branch: a.branch,
snippet: subtitle,
});
}
Some(out)
}
fn super_sanitize_dir_index_path(code_home: &Path, cwd: &Path) -> PathBuf {
let mut name = cwd.to_string_lossy().to_string();
name = name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect();
if name.len() > 160 { name.truncate(160); }
let mut p = code_home.to_path_buf();
p.push("sessions");
p.push("index");
p.push("by-dir");
p.push(format!("{}.jsonl", name));
p
}
// Removed fallback slow scan; the fast per-directory index is authoritative.
fn fallback_scan_sessions_for_cwd(cwd: &Path, code_home: &Path) -> Vec<ResumeCandidate> {
const MAX_RESULTS: usize = 200;
let code_home = code_home.to_path_buf();
let cwd = cwd.to_path_buf();
let fetch = async move {
let mut collected: Vec<ResumeCandidate> = Vec::new();
let mut cursor: Option<Cursor> = None;
while collected.len() < MAX_RESULTS {
let page = match RolloutRecorder::list_conversations(
code_home.as_path(),
256,
cursor.as_ref(),
INTERACTIVE_SESSION_SOURCES,
)
.await {
Ok(page) => page,
Err(err) => {
tracing::warn!("failed to list conversations for resume fallback: {err}");
break;
}
};
let catalog = SessionCatalog::new(code_home.clone());
let query = SessionQuery {
cwd: Some(cwd),
git_root: None,
sources: Vec::new(),
include_archived: false,
include_deleted: false,
limit: Some(MAX_RESULTS),
};
if page.items.is_empty() {
break;
match catalog.query(&query).await {
Ok(entries) => entries
.into_iter()
.map(|entry| entry_to_candidate(&code_home, entry))
.collect(),
Err(err) => {
tracing::warn!("failed to query session catalog: {err}");
Vec::new()
}
for item in page.items {
if let Some(candidate) = parse_rollout_candidate(&item.path, &cwd) {
collected.push(candidate);
if collected.len() >= MAX_RESULTS {
break;
}
}
}
if collected.len() >= MAX_RESULTS || page.next_cursor.is_none() {
break;
}
cursor = page.next_cursor;
}
collected
};
// Execute the async fetch, reusing an existing runtime when available.
let mut sessions = match Handle::try_current() {
match Handle::try_current() {
Ok(handle) => {
let handle = handle.clone();
match thread::spawn(move || handle.block_on(fetch)).join() {
Ok(result) => result,
Err(_) => {
tracing::warn!("resume fallback thread panicked while listing conversations");
tracing::warn!("resume picker thread panicked while querying catalog");
Vec::new()
}
}
@@ -194,184 +60,23 @@ fn fallback_scan_sessions_for_cwd(cwd: &Path, code_home: &Path) -> Vec<ResumeCan
Err(_) => match Builder::new_current_thread().enable_all().build() {
Ok(rt) => rt.block_on(fetch),
Err(err) => {
tracing::warn!("failed to build tokio runtime for resume fallback: {err}");
tracing::warn!("failed to build tokio runtime for resume picker: {err}");
Vec::new()
}
},
};
sessions.sort_by(|a, b| b.sort_key.cmp(&a.sort_key));
if sessions.len() > MAX_RESULTS {
sessions.truncate(MAX_RESULTS);
}
sessions
}
fn parse_rollout_candidate(path: &Path, target_cwd: &Path) -> Option<ResumeCandidate> {
let file = fs::File::open(path).ok()?;
let reader = BufReader::new(file);
let mut created_ts: Option<String> = None;
let mut modified_ts: Option<String> = None;
let mut message_count: usize = 0;
let mut branch: Option<String> = None;
let mut last_user_snippet: Option<String> = None;
let mut instructions: Option<String> = None;
let mut path_checked = false;
for line in reader.lines() {
let Ok(line) = line else { continue };
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let parsed: RolloutLine = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(_) => continue,
};
modified_ts = Some(parsed.timestamp.clone());
match parsed.item {
RolloutItem::SessionMeta(meta_line) => {
if !path_checked {
path_checked = true;
if !paths_match(&meta_line.meta.cwd, target_cwd) {
return None;
}
}
if created_ts.is_none() {
created_ts = Some(meta_line.meta.timestamp.clone());
}
instructions = instructions.or_else(|| meta_line.meta.instructions.clone());
if branch.is_none() {
if let Some(git) = &meta_line.git {
if let Some(b) = &git.branch {
if !b.is_empty() {
branch = Some(b.clone());
}
}
}
}
}
RolloutItem::ResponseItem(response_item) => {
message_count = message_count.saturating_add(1);
if let Some(snippet) = extract_user_snippet_from_response(&response_item) {
last_user_snippet = Some(snippet);
}
}
RolloutItem::Event(recorded) => {
if let Some(event) = code_core::protocol::event_msg_from_protocol(&recorded.msg) {
if let Some(snippet) = extract_user_snippet_from_event(&event) {
last_user_snippet = Some(snippet);
}
if matches!(event, EventMsg::UserMessage(_)) {
message_count = message_count.saturating_add(1);
}
}
}
RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) => {}
}
}
if !path_checked {
return None;
}
if message_count == 0 {
return None;
}
let modified_ts = modified_ts.or_else(|| created_ts.clone());
let snippet = last_user_snippet.or_else(|| instructions.clone());
Some(ResumeCandidate {
path: path.to_path_buf(),
subtitle: snippet.clone(),
sort_key: modified_ts.clone().unwrap_or_default(),
created_ts,
modified_ts,
message_count,
branch,
snippet,
})
}
fn extract_user_snippet_from_response(item: &ResponseItem) -> Option<String> {
match item {
ResponseItem::Message { role, content, .. } if role == "user" => content
.iter()
.find_map(|c| match c {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
let trimmed = text.trim();
if trimmed.is_empty() || trimmed.contains("== System Status ==") {
None
} else {
Some(trimmed.to_string())
}
}
_ => None,
}),
_ => None,
}
}
fn extract_user_snippet_from_event(event: &EventMsg) -> Option<String> {
match event {
EventMsg::UserMessage(ev) => {
let trimmed = ev.message.trim();
if trimmed.is_empty() || trimmed.contains("== System Status ==") {
None
} else {
Some(trimmed.to_string())
}
}
_ => None,
fn entry_to_candidate(code_home: &Path, entry: SessionIndexEntry) -> ResumeCandidate {
let path = entry_to_rollout_path(code_home, &entry);
ResumeCandidate {
path,
subtitle: entry.last_user_snippet.clone(),
created_ts: Some(entry.created_at.clone()),
modified_ts: Some(entry.last_event_at.clone()),
message_count: entry.message_count,
branch: entry.git_branch.clone(),
snippet: entry.last_user_snippet.clone(),
}
}
fn paths_match(meta: &Path, target: &Path) -> bool {
if meta == target {
return true;
}
let meta_norm = normalize_path(meta);
let target_norm = normalize_path(target);
if meta_norm == target_norm || meta_norm.eq_ignore_ascii_case(&target_norm) {
return true;
}
if let (Ok(meta_real), Ok(target_real)) = (fs::canonicalize(meta), fs::canonicalize(target)) {
let meta_real_norm = normalize_path(&meta_real);
let target_real_norm = normalize_path(&target_real);
if meta_real_norm == target_real_norm
|| meta_real_norm.eq_ignore_ascii_case(&target_real_norm)
{
return true;
}
// If both paths belong to the same Git project (including worktrees) consider them equivalent.
if let (Some(meta_root), Some(target_root)) = (
code_core::git_info::resolve_root_git_project_for_trust(&meta_real)
.and_then(|p| fs::canonicalize(p).ok()),
code_core::git_info::resolve_root_git_project_for_trust(&target_real)
.and_then(|p| fs::canonicalize(p).ok()),
) {
let meta_root_norm = normalize_path(&meta_root);
let target_root_norm = normalize_path(&target_root);
if meta_root_norm == target_root_norm
|| meta_root_norm.eq_ignore_ascii_case(&target_root_norm)
{
return true;
}
}
}
false
}
fn normalize_path(path: &Path) -> String {
let mut s = path.to_string_lossy().replace('\\', "/");
while s.ends_with('/') && s.len() > 1 {
s.pop();
}
s
}
@@ -0,0 +1,156 @@
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
use code_protocol::models::{ContentItem, ResponseItem};
use code_protocol::protocol::{
EventMsg as ProtoEventMsg, RecordedEvent, RolloutItem, RolloutLine, SessionMeta,
SessionMetaLine, SessionSource, UserMessageEvent,
};
use code_protocol::ConversationId;
use filetime::{set_file_mtime, FileTime};
use tempfile::TempDir;
use uuid::Uuid;
use code_tui::resume::discovery::list_sessions_for_cwd;
fn write_rollout(
code_home: &std::path::Path,
session_id: Uuid,
created_at: &str,
last_event_at: &str,
cwd: &std::path::Path,
source: SessionSource,
user_text: &str,
) -> PathBuf {
let sessions_dir = code_home.join("sessions").join("2025").join("11").join("16");
std::fs::create_dir_all(&sessions_dir).unwrap();
let filename = format!(
"rollout-{}-{}.jsonl",
created_at.replace(':', "-"),
session_id
);
let path = sessions_dir.join(filename);
let session_meta = SessionMeta {
id: ConversationId::from(session_id),
timestamp: created_at.to_string(),
cwd: cwd.to_path_buf(),
originator: "test".to_string(),
cli_version: "0.0.0-test".to_string(),
instructions: None,
source,
};
let session_line = RolloutLine {
timestamp: created_at.to_string(),
item: RolloutItem::SessionMeta(SessionMetaLine {
meta: session_meta,
git: None,
}),
};
let event_line = RolloutLine {
timestamp: last_event_at.to_string(),
item: RolloutItem::Event(RecordedEvent {
id: "event-0".to_string(),
event_seq: 0,
order: None,
msg: ProtoEventMsg::UserMessage(UserMessageEvent {
message: user_text.to_string(),
kind: None,
images: None,
}),
}),
};
let assistant_line = RolloutLine {
timestamp: last_event_at.to_string(),
item: RolloutItem::ResponseItem(ResponseItem::Message {
id: Some(format!("msg-{}", session_id)),
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: format!("Ack: {}", user_text),
}],
}),
};
let mut writer = BufWriter::new(std::fs::File::create(&path).unwrap());
serde_json::to_writer(&mut writer, &session_line).unwrap();
writer.write_all(b"\n").unwrap();
serde_json::to_writer(&mut writer, &event_line).unwrap();
writer.write_all(b"\n").unwrap();
serde_json::to_writer(&mut writer, &assistant_line).unwrap();
writer.write_all(b"\n").unwrap();
writer.flush().unwrap();
path
}
#[test]
fn resume_picker_lists_exec_sessions() {
let temp = TempDir::new().unwrap();
let cwd = std::path::PathBuf::from("/project");
write_rollout(
temp.path(),
Uuid::parse_str("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa").unwrap(),
"2025-11-15T10:00:00Z",
"2025-11-15T10:00:10Z",
&cwd,
SessionSource::Cli,
"cli",
);
write_rollout(
temp.path(),
Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap(),
"2025-11-15T11:00:00Z",
"2025-11-15T11:00:10Z",
&cwd,
SessionSource::Exec,
"exec",
);
let results = list_sessions_for_cwd(&cwd, temp.path());
assert_eq!(results.len(), 2);
let sources: Vec<_> = results.iter().map(|cand| cand.subtitle.clone()).collect();
assert!(sources.iter().any(|s| s.as_deref() == Some("cli")));
assert!(sources.iter().any(|s| s.as_deref() == Some("exec")));
}
#[test]
fn resume_picker_orders_by_last_event_even_with_mtime_drift() {
let temp = TempDir::new().unwrap();
let cwd = std::path::PathBuf::from("/project");
let old_path = write_rollout(
temp.path(),
Uuid::parse_str("cccccccc-cccc-4ccc-8ccc-cccccccccccc").unwrap(),
"2025-11-01T10:00:00Z",
"2025-11-01T10:00:05Z",
&cwd,
SessionSource::Cli,
"old",
);
let new_path = write_rollout(
temp.path(),
Uuid::parse_str("dddddddd-dddd-4ddd-8ddd-dddddddddddd").unwrap(),
"2025-11-16T10:00:00Z",
"2025-11-16T10:15:00Z",
&cwd,
SessionSource::Exec,
"new",
);
// Simulate sync where the older file has the newest mtime.
let base = SystemTime::now();
set_file_mtime(&old_path, FileTime::from_system_time(base + Duration::from_secs(300))).unwrap();
set_file_mtime(&new_path, FileTime::from_system_time(base + Duration::from_secs(60))).unwrap();
let results = list_sessions_for_cwd(&cwd, temp.path());
assert_eq!(results.len(), 2);
assert_eq!(results[0].path, new_path);
}