mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(resume): unify session catalog and add e2e tests
This commit is contained in:
Generated
+4
@@ -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",
|
||||
|
||||
@@ -59,3 +59,4 @@ tracing = { workspace = true, features = ["log"] }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
|
||||
|
||||
[dev-dependencies]
|
||||
filetime = { workspace = true }
|
||||
|
||||
+152
-27
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -49,3 +49,5 @@ libc = "0.2"
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
filetime = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
+232
-12
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user