#[cfg(test)]
use std::{fs, io::Write};
use std::{
fs::{File, OpenOptions},
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
#[cfg(test)]
use uuid::Uuid;
#[cfg(test)]
use rho_providers::model::ContentBlock;
use rho_providers::model::{Message, ModelIdentity};
#[cfg(test)]
use rho_sdk::{CompactionState, Revision, SessionId, SessionSnapshot};
mod delete;
mod index;
mod layout;
#[cfg(test)]
mod performance_benchmarks;
mod persistence;
mod snapshot_delta;
mod snapshot_store;
#[cfg(test)]
#[path = "session_summary_tests.rs"]
mod summary_tests;
#[cfg(test)]
#[path = "session_tests.rs"]
mod tests;
pub(crate) mod tree;
#[cfg(test)]
#[path = "session_tree_tests.rs"]
mod tree_tests;
#[cfg(test)]
#[path = "session_version_tests.rs"]
mod version_tests;
pub(crate) mod workspace_checkpoint;
#[cfg(test)]
use layout::encode_cwd;
use persistence::{
parse_timestamp, session_dir_in_root, session_root, session_web_dir, unix_timestamp_secs,
AppendCursor, SessionStore,
};
#[cfg(test)]
use persistence::{read_entries, summarize_session_file, SessionEntry, SESSION_VERSION};
pub use delete::{is_cross_project, DeleteOptions, DeleteOutcome};
pub(crate) use delete::{CleanupOutcome, WorkspaceDeleteOutcome};
#[derive(Clone, Debug)]
pub struct Session {
id: String,
path: PathBuf,
session_root: PathBuf,
cwd: PathBuf,
workspace_key: String,
write_lock: Arc<Mutex<AppendCursor>>,
_active_lease: Arc<File>,
}
#[derive(Clone, Debug)]
pub struct SessionHistories {
pub model: Vec<Message>,
pub display: Vec<Message>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionSummary {
pub id: String,
pub path: PathBuf,
pub cwd: PathBuf,
pub created_at: u64,
pub updated_at: u64,
pub message_count: u64,
pub title: Option<String>,
pub first_user_message: Option<String>,
pub last_user_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SessionTarget {
pub id: String,
pub cwd: PathBuf,
}
impl SessionTarget {
pub fn new(id: impl Into<String>, cwd: impl Into<PathBuf>) -> Self {
Self {
id: id.into(),
cwd: cwd.into(),
}
}
}
pub(crate) fn workspace_key(cwd: &Path) -> String {
layout::workspace_key(cwd)
}
impl SessionSummary {
pub fn target(&self) -> SessionTarget {
SessionTarget::new(self.id.clone(), self.cwd.clone())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TitleUpdate {
pub id: String,
pub cwd: PathBuf,
pub title: String,
}
#[derive(Clone, Debug)]
pub struct ExportedMessage {
pub timestamp: Option<u64>,
pub message: Message,
}
#[derive(Clone, Debug)]
pub struct SessionExport {
pub id: String,
pub cwd: PathBuf,
pub created_at: u64,
pub updated_at: u64,
pub title: Option<String>,
pub messages: Vec<ExportedMessage>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct SessionIndexRecord {
pub(super) summary: SessionSummary,
pub(super) file_size: Option<i64>,
pub(super) file_mtime: Option<i64>,
pub(super) node_count: u64,
pub(super) branch_count: u64,
pub(super) active_leaf_id: Option<String>,
pub(super) effective_format_version: u32,
}
#[derive(Clone, Copy)]
enum LeaseMode {
Active,
Delete,
}
fn acquire_session_lease(
session_root: &Path,
cwd: &Path,
id: &str,
mode: LeaseMode,
) -> anyhow::Result<File> {
let dir = session_dir_in_root(session_root, cwd);
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!(".{id}.active.lock"));
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&path)?;
layout::set_private_file_permissions(&file)?;
let lock = match mode {
LeaseMode::Active => fs2::FileExt::try_lock_shared(&file),
LeaseMode::Delete => fs2::FileExt::try_lock_exclusive(&file),
};
match lock {
Ok(()) => Ok(file),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => match mode {
LeaseMode::Active => anyhow::bail!(
"session '{}' is being deleted by another Rho process; refresh the session list",
delete::short_id(id)
),
LeaseMode::Delete => anyhow::bail!(
"refusing to delete active session '{}'; close it in the other Rho process first",
delete::short_id(id)
),
},
Err(error) => Err(error.into()),
}
}
fn acquire_delete_session_lease(session_root: &Path, cwd: &Path, id: &str) -> anyhow::Result<File> {
acquire_session_lease(session_root, cwd, id, LeaseMode::Delete)
}
impl Session {
pub fn open_by_id_with_histories(
cwd: &Path,
id_prefix: &str,
) -> anyhow::Result<(Self, SessionHistories)> {
Self::open_by_id_with_histories_in_root(&session_root()?, cwd, id_prefix)
}
pub fn open_target_with_histories(
target: &SessionTarget,
) -> anyhow::Result<(Self, SessionHistories)> {
Self::open_target_with_histories_in_root(&session_root()?, target)
}
#[cfg(test)]
fn open_by_id_in_root(
session_root: &Path,
cwd: &Path,
id_prefix: &str,
) -> anyhow::Result<(Self, Vec<Message>)> {
let (session, histories) =
Self::open_by_id_with_histories_in_root(session_root, cwd, id_prefix)?;
Ok((session, histories.model))
}
pub(crate) fn open_by_id_with_histories_in_root(
session_root: &Path,
cwd: &Path,
id_prefix: &str,
) -> anyhow::Result<(Self, SessionHistories)> {
let resolved = SessionStore::new(session_root, cwd).resolve(id_prefix)?;
Self::open_resolved_with_histories(session_root, resolved)
}
pub(crate) fn open_target_with_histories_in_root(
session_root: &Path,
target: &SessionTarget,
) -> anyhow::Result<(Self, SessionHistories)> {
let resolved =
SessionStore::new(session_root, &target.cwd).resolve_in_workspace(&target.id)?;
Self::open_resolved_with_histories(session_root, resolved)
}
fn open_resolved_with_histories(
session_root: &Path,
resolved: persistence::ResolvedSession,
) -> anyhow::Result<(Self, SessionHistories)> {
anyhow::ensure!(
resolved.cwd.is_dir(),
"session '{}' belongs to workspace {}, which is no longer an accessible directory. \
Restore or recreate that directory and resume from there; its transcript \
is preserved under ~/.rho/sessions.",
resolved.id,
resolved.cwd.display(),
);
let active_lease =
acquire_session_lease(session_root, &resolved.cwd, &resolved.id, LeaseMode::Active)?;
let tree = tree::SessionTree::load(&resolved.path)?;
let histories = persistence::histories_from_tree(&tree)?;
let session = Self::from_parts_with_lease(
session_root,
resolved.cwd,
resolved.id,
resolved.path,
active_lease,
);
session.cache_loaded_tree(tree);
Ok((session, histories))
}
pub(crate) fn tree_facts_by_id(
cwd: &Path,
id_prefix: &str,
) -> anyhow::Result<tree::SessionTreeFacts> {
let store = SessionStore::new(&session_root()?, cwd);
let resolved = store.resolve(id_prefix)?;
Ok(resolved.tree()?.facts())
}
pub fn export_by_id(cwd: &Path, id_prefix: &str) -> anyhow::Result<SessionExport> {
Self::export_by_id_in_root(&session_root()?, cwd, id_prefix)
}
pub(crate) fn export_by_id_in_root(
session_root: &Path,
cwd: &Path,
id_prefix: &str,
) -> anyhow::Result<SessionExport> {
let store = SessionStore::new(session_root, cwd);
let resolved = store.resolve(id_prefix)?;
let (record, tree) = resolved.summary_with_tree(cwd)?;
let title = Self::list_in_root(session_root, cwd)
.ok()
.and_then(|summaries| {
summaries
.into_iter()
.find(|summary| summary.id == resolved.id)
.and_then(|summary| summary.title)
});
let mut messages = match tree.active_leaf_id() {
Some(active_leaf_id) => tree.projected_display(active_leaf_id)?,
None => Vec::new(),
};
let complete_len = persistence::complete_turn_tail_len(&messages, |entry| &entry.message);
messages.truncate(complete_len);
Ok(SessionExport {
id: record.summary.id,
cwd: record.summary.cwd,
created_at: record.summary.created_at,
updated_at: record.summary.updated_at,
title,
messages: messages
.into_iter()
.map(|message| ExportedMessage {
timestamp: parse_timestamp(&message.timestamp),
message: message.message,
})
.collect(),
})
}
pub(crate) fn stored_agent_identity(&self) -> anyhow::Result<Option<(String, String)>> {
persistence::read_agent_identity(&self.path)
}
pub(crate) fn stored_provider_identity(&self) -> anyhow::Result<Option<ModelIdentity>> {
Ok(persistence::read_session_state(&self.path)?
.snapshot
.as_ref()
.map(|snapshot| snapshot.provider().clone()))
}
pub(crate) fn validate_agent_definition_identity(
&self,
definition: &crate::agent::AgentDefinition,
) -> anyhow::Result<()> {
self.validate_agent_identity_with(definition.id.as_str(), |stored| {
definition.accepts_stored_fingerprint(stored)
})
}
fn validate_agent_identity_with(
&self,
selected_id: &str,
accepts_fingerprint: impl FnOnce(&str) -> bool,
) -> anyhow::Result<()> {
let Some((stored_id, stored_fingerprint)) = self.stored_agent_identity()? else {
anyhow::bail!(
"cannot resume this session as agent '{selected_id}': the session has no stored agent definition identity"
);
};
if stored_id != selected_id {
anyhow::bail!(
"cannot resume session created by agent '{stored_id}' as selected agent '{selected_id}'"
);
}
if !accepts_fingerprint(&stored_fingerprint) {
anyhow::bail!(
"cannot resume agent '{selected_id}': its definition changed since the session was created"
);
}
Ok(())
}
pub fn list(cwd: &Path) -> anyhow::Result<Vec<SessionSummary>> {
Self::list_in_root(&session_root()?, cwd)
}
pub fn list_all() -> anyhow::Result<Vec<SessionSummary>> {
Self::list_all_in_root(&session_root()?)
}
pub(crate) fn list_missing_workspaces() -> anyhow::Result<Vec<SessionSummary>> {
delete::list_missing_workspaces_in_root(&session_root()?)
}
pub(crate) fn workspace_directory_is_missing(cwd: &Path) -> anyhow::Result<bool> {
delete::workspace_directory_is_missing(cwd)
}
pub(crate) fn cleanup_missing_targets(
targets: &[SessionTarget],
options: DeleteOptions,
) -> anyhow::Result<CleanupOutcome> {
delete::cleanup_missing_targets_in_roots(
&session_root()?,
&crate::paths::rho_dir()?.join("subagents"),
targets,
&options,
)
}
pub fn find_by_id_prefix(cwd: &Path, id_prefix: &str) -> anyhow::Result<Vec<SessionSummary>> {
Self::find_by_id_prefix_in_root(&session_root()?, cwd, id_prefix)
}
pub fn set_title(cwd: &Path, id_prefix: &str, title: &str) -> anyhow::Result<TitleUpdate> {
Self::set_title_in_root(&session_root()?, cwd, id_prefix, title)
}
pub(crate) fn set_generated_title(
cwd: &Path,
id_prefix: &str,
title: &str,
) -> anyhow::Result<Option<TitleUpdate>> {
Self::set_generated_title_in_root(&session_root()?, cwd, id_prefix, title)
}
pub(crate) fn title_is_set(cwd: &Path, id_prefix: &str) -> anyhow::Result<bool> {
Self::title_is_set_in_root(&session_root()?, cwd, id_prefix)
}
pub fn delete_target(
target: &SessionTarget,
options: DeleteOptions,
) -> anyhow::Result<DeleteOutcome> {
delete::delete_target_in_roots(
&session_root()?,
&crate::paths::rho_dir()?.join("subagents"),
target,
&options,
)
}
pub(crate) fn delete_targets(
targets: &[SessionTarget],
options: DeleteOptions,
) -> anyhow::Result<WorkspaceDeleteOutcome> {
delete::delete_targets_in_roots(
&session_root()?,
&crate::paths::rho_dir()?.join("subagents"),
targets,
&options,
)
}
fn set_title_in_root(
session_root: &Path,
cwd: &Path,
id_prefix: &str,
title: &str,
) -> anyhow::Result<TitleUpdate> {
let title = title.trim();
if title.is_empty() {
anyhow::bail!("title must not be empty");
}
let resolved = SessionStore::new(session_root, cwd).resolve(id_prefix)?;
SessionStore::new(session_root, &resolved.cwd).set_title(&resolved.id, title)?;
Ok(TitleUpdate {
id: resolved.id,
cwd: resolved.cwd,
title: title.to_string(),
})
}
fn set_generated_title_in_root(
session_root: &Path,
cwd: &Path,
id_prefix: &str,
title: &str,
) -> anyhow::Result<Option<TitleUpdate>> {
let title = title.trim();
if title.is_empty() {
anyhow::bail!("title must not be empty");
}
let resolved = SessionStore::new(session_root, cwd).resolve(id_prefix)?;
let store = SessionStore::new(session_root, &resolved.cwd);
if !store.set_title_if_absent(&resolved.id, title)? {
return Ok(None);
}
Ok(Some(TitleUpdate {
id: resolved.id,
cwd: resolved.cwd,
title: title.to_string(),
}))
}
fn title_is_set_in_root(
session_root: &Path,
cwd: &Path,
id_prefix: &str,
) -> anyhow::Result<bool> {
let resolved = SessionStore::new(session_root, cwd).resolve(id_prefix)?;
Ok(SessionStore::new(session_root, &resolved.cwd)
.title(&resolved.id)?
.is_some())
}
fn list_in_root(session_root: &Path, cwd: &Path) -> anyhow::Result<Vec<SessionSummary>> {
SessionStore::new(session_root, cwd).list()
}
#[cfg(test)]
pub(crate) fn list_in_root_for_test(
session_root: &Path,
cwd: &Path,
) -> anyhow::Result<Vec<SessionSummary>> {
Self::list_in_root(session_root, cwd)
}
#[cfg(test)]
pub(crate) fn set_title_in_root_for_test(
session_root: &Path,
cwd: &Path,
id_prefix: &str,
title: &str,
) -> anyhow::Result<TitleUpdate> {
Self::set_title_in_root(session_root, cwd, id_prefix, title)
}
#[cfg(test)]
pub(crate) fn set_generated_title_in_root_for_test(
session_root: &Path,
cwd: &Path,
id_prefix: &str,
title: &str,
) -> anyhow::Result<Option<TitleUpdate>> {
Self::set_generated_title_in_root(session_root, cwd, id_prefix, title)
}
pub(crate) fn list_all_in_root(session_root: &Path) -> anyhow::Result<Vec<SessionSummary>> {
index::list_all_sessions(session_root)
}
#[cfg(test)]
pub(crate) fn list_missing_workspaces_in_root_for_test(
session_root: &Path,
) -> anyhow::Result<Vec<SessionSummary>> {
delete::list_missing_workspaces_in_root(session_root)
}
#[cfg(test)]
pub(crate) fn cleanup_missing_workspaces_in_roots_for_test(
session_root: &Path,
subagents_root: &Path,
options: DeleteOptions,
) -> anyhow::Result<CleanupOutcome> {
delete::cleanup_missing_workspaces_in_roots(session_root, subagents_root, &options)
}
fn find_by_id_prefix_in_root(
session_root: &Path,
cwd: &Path,
id_prefix: &str,
) -> anyhow::Result<Vec<SessionSummary>> {
let local = SessionStore::new(session_root, cwd)
.list()?
.into_iter()
.filter(|session| session.id.starts_with(id_prefix))
.collect::<Vec<_>>();
if !local.is_empty() {
return Ok(local);
}
index::reconcile_all_workspaces(session_root)?;
index::summaries_matching_id_prefix(session_root, id_prefix)
}
#[cfg(test)]
pub(crate) fn delete_by_id_in_roots(
session_root: &Path,
subagents_root: &Path,
cwd: &Path,
id_prefix: &str,
options: DeleteOptions,
) -> anyhow::Result<DeleteOutcome> {
delete::delete_in_roots(session_root, subagents_root, cwd, id_prefix, &options)
}
pub(crate) fn create_with_id(
cwd: &Path,
id: &str,
agent_id: &str,
agent_fingerprint: &str,
) -> anyhow::Result<Self> {
Self::create_with_id_in_root(
&session_root()?,
cwd,
id,
Some((agent_id, agent_fingerprint)),
)
}
#[cfg(test)]
pub(crate) fn create_in_root(session_root: &Path, cwd: &Path) -> anyhow::Result<Self> {
Self::create_with_id_in_root(session_root, cwd, &Uuid::new_v4().to_string(), None)
}
#[cfg(test)]
pub(crate) fn create_in_root_with_agent(
session_root: &Path,
cwd: &Path,
agent_id: &str,
agent_fingerprint: &str,
) -> anyhow::Result<Self> {
Self::create_with_id_in_root(
session_root,
cwd,
&Uuid::new_v4().to_string(),
Some((agent_id, agent_fingerprint)),
)
}
fn create_with_id_in_root(
session_root: &Path,
cwd: &Path,
id: &str,
agent: Option<(&str, &str)>,
) -> anyhow::Result<Self> {
let store = SessionStore::new(session_root, cwd);
let id = id.to_string();
let created_at = unix_timestamp_secs();
let path = store.create_path(&id, created_at)?;
let session = Self::from_parts(session_root, cwd, id.clone(), path)?;
session.append_session_metadata(id, created_at, agent)?;
Ok(session)
}
#[cfg(test)]
pub fn append_message(&self, message: &Message) -> anyhow::Result<()> {
self.append_stored_message(message, None)
}
#[cfg(test)]
pub fn append_message_with_display(
&self,
message: &Message,
display_message: &Message,
) -> anyhow::Result<()> {
self.append_stored_message(message, Some(display_message))
}
#[cfg(test)]
pub fn replace_history(&self, messages: &[Message]) -> anyhow::Result<()> {
self.append_replaced_history(messages)
}
fn from_parts(
session_root: &Path,
cwd: &Path,
id: String,
path: PathBuf,
) -> anyhow::Result<Self> {
let active_lease = acquire_session_lease(session_root, cwd, &id, LeaseMode::Active)?;
Ok(Self::from_parts_with_lease(
session_root,
cwd.to_path_buf(),
id,
path,
active_lease,
))
}
fn from_parts_with_lease(
session_root: &Path,
cwd: PathBuf,
id: String,
path: PathBuf,
active_lease: File,
) -> Self {
Self {
workspace_key: workspace_key(&cwd),
id,
path,
session_root: session_root.to_path_buf(),
cwd,
write_lock: Arc::new(Mutex::new(AppendCursor::default())),
_active_lease: Arc::new(active_lease),
}
}
#[cfg(test)]
pub(crate) fn path(&self) -> &Path {
&self.path
}
pub(crate) fn web_dir(&self) -> Option<PathBuf> {
session_web_dir(&self.path)
}
pub(crate) fn subagents_dir(&self) -> Option<PathBuf> {
persistence::SessionUnit::from_path(&self.path)?.subagents_dir()
}
pub fn id(&self) -> &str {
&self.id
}
pub(crate) fn cwd(&self) -> &Path {
&self.cwd
}
}