session_control.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  1. use std::env;
  2. use std::fmt::{Display, Formatter};
  3. use std::fs;
  4. use std::path::{Path, PathBuf};
  5. use std::time::UNIX_EPOCH;
  6. use serde::{Deserialize, Serialize};
  7. use crate::session::{Session, SessionError};
  8. use crate::worker_boot::{Worker, WorkerReadySnapshot, WorkerRegistry, WorkerStatus};
  9. pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
  10. pub const LEGACY_SESSION_EXTENSION: &str = "json";
  11. pub const LATEST_SESSION_REFERENCE: &str = "latest";
  12. const SESSION_REFERENCE_ALIASES: &[&str] = &[LATEST_SESSION_REFERENCE, "last", "recent"];
  13. #[derive(Debug, Clone, PartialEq, Eq)]
  14. pub struct SessionHandle {
  15. pub id: String,
  16. pub path: PathBuf,
  17. }
  18. #[derive(Debug, Clone, PartialEq, Eq)]
  19. pub struct ManagedSessionSummary {
  20. pub id: String,
  21. pub path: PathBuf,
  22. pub modified_epoch_millis: u128,
  23. pub message_count: usize,
  24. pub parent_session_id: Option<String>,
  25. pub branch_name: Option<String>,
  26. }
  27. #[derive(Debug, Clone, PartialEq, Eq)]
  28. pub struct LoadedManagedSession {
  29. pub handle: SessionHandle,
  30. pub session: Session,
  31. }
  32. #[derive(Debug, Clone, PartialEq, Eq)]
  33. pub struct ForkedManagedSession {
  34. pub parent_session_id: String,
  35. pub handle: SessionHandle,
  36. pub session: Session,
  37. pub branch_name: Option<String>,
  38. }
  39. #[derive(Debug)]
  40. pub enum SessionControlError {
  41. Io(std::io::Error),
  42. Session(SessionError),
  43. Format(String),
  44. }
  45. impl Display for SessionControlError {
  46. fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
  47. match self {
  48. Self::Io(error) => write!(f, "{error}"),
  49. Self::Session(error) => write!(f, "{error}"),
  50. Self::Format(error) => write!(f, "{error}"),
  51. }
  52. }
  53. }
  54. impl std::error::Error for SessionControlError {}
  55. impl From<std::io::Error> for SessionControlError {
  56. fn from(value: std::io::Error) -> Self {
  57. Self::Io(value)
  58. }
  59. }
  60. impl From<SessionError> for SessionControlError {
  61. fn from(value: SessionError) -> Self {
  62. Self::Session(value)
  63. }
  64. }
  65. pub fn sessions_dir() -> Result<PathBuf, SessionControlError> {
  66. managed_sessions_dir_for(env::current_dir()?)
  67. }
  68. pub fn managed_sessions_dir_for(
  69. base_dir: impl AsRef<Path>,
  70. ) -> Result<PathBuf, SessionControlError> {
  71. let path = base_dir.as_ref().join(".claw").join("sessions");
  72. fs::create_dir_all(&path)?;
  73. Ok(path)
  74. }
  75. pub fn create_managed_session_handle(
  76. session_id: &str,
  77. ) -> Result<SessionHandle, SessionControlError> {
  78. create_managed_session_handle_for(env::current_dir()?, session_id)
  79. }
  80. pub fn create_managed_session_handle_for(
  81. base_dir: impl AsRef<Path>,
  82. session_id: &str,
  83. ) -> Result<SessionHandle, SessionControlError> {
  84. let id = session_id.to_string();
  85. let path =
  86. managed_sessions_dir_for(base_dir)?.join(format!("{id}.{PRIMARY_SESSION_EXTENSION}"));
  87. Ok(SessionHandle { id, path })
  88. }
  89. pub fn resolve_session_reference(reference: &str) -> Result<SessionHandle, SessionControlError> {
  90. resolve_session_reference_for(env::current_dir()?, reference)
  91. }
  92. pub fn resolve_session_reference_for(
  93. base_dir: impl AsRef<Path>,
  94. reference: &str,
  95. ) -> Result<SessionHandle, SessionControlError> {
  96. let base_dir = base_dir.as_ref();
  97. if is_session_reference_alias(reference) {
  98. let latest = latest_managed_session_for(base_dir)?;
  99. return Ok(SessionHandle {
  100. id: latest.id,
  101. path: latest.path,
  102. });
  103. }
  104. let direct = PathBuf::from(reference);
  105. let candidate = if direct.is_absolute() {
  106. direct.clone()
  107. } else {
  108. base_dir.join(&direct)
  109. };
  110. let looks_like_path = direct.extension().is_some() || direct.components().count() > 1;
  111. let path = if candidate.exists() {
  112. candidate
  113. } else if looks_like_path {
  114. return Err(SessionControlError::Format(
  115. format_missing_session_reference(reference),
  116. ));
  117. } else {
  118. resolve_managed_session_path_for(base_dir, reference)?
  119. };
  120. Ok(SessionHandle {
  121. id: session_id_from_path(&path).unwrap_or_else(|| reference.to_string()),
  122. path,
  123. })
  124. }
  125. pub fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, SessionControlError> {
  126. resolve_managed_session_path_for(env::current_dir()?, session_id)
  127. }
  128. pub fn resolve_managed_session_path_for(
  129. base_dir: impl AsRef<Path>,
  130. session_id: &str,
  131. ) -> Result<PathBuf, SessionControlError> {
  132. let directory = managed_sessions_dir_for(base_dir)?;
  133. for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] {
  134. let path = directory.join(format!("{session_id}.{extension}"));
  135. if path.exists() {
  136. return Ok(path);
  137. }
  138. }
  139. Err(SessionControlError::Format(
  140. format_missing_session_reference(session_id),
  141. ))
  142. }
  143. #[must_use]
  144. pub fn is_managed_session_file(path: &Path) -> bool {
  145. path.extension()
  146. .and_then(|ext| ext.to_str())
  147. .is_some_and(|extension| {
  148. extension == PRIMARY_SESSION_EXTENSION || extension == LEGACY_SESSION_EXTENSION
  149. })
  150. }
  151. pub fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
  152. list_managed_sessions_for(env::current_dir()?)
  153. }
  154. pub fn list_managed_sessions_for(
  155. base_dir: impl AsRef<Path>,
  156. ) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
  157. let mut sessions = Vec::new();
  158. for entry in fs::read_dir(managed_sessions_dir_for(base_dir)?)? {
  159. let entry = entry?;
  160. let path = entry.path();
  161. if !is_managed_session_file(&path) {
  162. continue;
  163. }
  164. let metadata = entry.metadata()?;
  165. let modified_epoch_millis = metadata
  166. .modified()
  167. .ok()
  168. .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
  169. .map(|duration| duration.as_millis())
  170. .unwrap_or_default();
  171. let (id, message_count, parent_session_id, branch_name) =
  172. match Session::load_from_path(&path) {
  173. Ok(session) => {
  174. let parent_session_id = session
  175. .fork
  176. .as_ref()
  177. .map(|fork| fork.parent_session_id.clone());
  178. let branch_name = session
  179. .fork
  180. .as_ref()
  181. .and_then(|fork| fork.branch_name.clone());
  182. (
  183. session.session_id,
  184. session.messages.len(),
  185. parent_session_id,
  186. branch_name,
  187. )
  188. }
  189. Err(_) => (
  190. path.file_stem()
  191. .and_then(|value| value.to_str())
  192. .unwrap_or("unknown")
  193. .to_string(),
  194. 0,
  195. None,
  196. None,
  197. ),
  198. };
  199. sessions.push(ManagedSessionSummary {
  200. id,
  201. path,
  202. modified_epoch_millis,
  203. message_count,
  204. parent_session_id,
  205. branch_name,
  206. });
  207. }
  208. sessions.sort_by(|left, right| {
  209. right
  210. .modified_epoch_millis
  211. .cmp(&left.modified_epoch_millis)
  212. .then_with(|| right.id.cmp(&left.id))
  213. });
  214. Ok(sessions)
  215. }
  216. pub fn latest_managed_session() -> Result<ManagedSessionSummary, SessionControlError> {
  217. latest_managed_session_for(env::current_dir()?)
  218. }
  219. pub fn latest_managed_session_for(
  220. base_dir: impl AsRef<Path>,
  221. ) -> Result<ManagedSessionSummary, SessionControlError> {
  222. list_managed_sessions_for(base_dir)?
  223. .into_iter()
  224. .next()
  225. .ok_or_else(|| SessionControlError::Format(format_no_managed_sessions()))
  226. }
  227. pub fn load_managed_session(reference: &str) -> Result<LoadedManagedSession, SessionControlError> {
  228. load_managed_session_for(env::current_dir()?, reference)
  229. }
  230. pub fn load_managed_session_for(
  231. base_dir: impl AsRef<Path>,
  232. reference: &str,
  233. ) -> Result<LoadedManagedSession, SessionControlError> {
  234. let handle = resolve_session_reference_for(base_dir, reference)?;
  235. let session = Session::load_from_path(&handle.path)?;
  236. Ok(LoadedManagedSession {
  237. handle: SessionHandle {
  238. id: session.session_id.clone(),
  239. path: handle.path,
  240. },
  241. session,
  242. })
  243. }
  244. pub fn fork_managed_session(
  245. session: &Session,
  246. branch_name: Option<String>,
  247. ) -> Result<ForkedManagedSession, SessionControlError> {
  248. fork_managed_session_for(env::current_dir()?, session, branch_name)
  249. }
  250. pub fn fork_managed_session_for(
  251. base_dir: impl AsRef<Path>,
  252. session: &Session,
  253. branch_name: Option<String>,
  254. ) -> Result<ForkedManagedSession, SessionControlError> {
  255. let parent_session_id = session.session_id.clone();
  256. let forked = session.fork(branch_name);
  257. let handle = create_managed_session_handle_for(base_dir, &forked.session_id)?;
  258. let branch_name = forked
  259. .fork
  260. .as_ref()
  261. .and_then(|fork| fork.branch_name.clone());
  262. let forked = forked.with_persistence_path(handle.path.clone());
  263. forked.save_to_path(&handle.path)?;
  264. Ok(ForkedManagedSession {
  265. parent_session_id,
  266. handle,
  267. session: forked,
  268. branch_name,
  269. })
  270. }
  271. #[must_use]
  272. pub fn is_session_reference_alias(reference: &str) -> bool {
  273. SESSION_REFERENCE_ALIASES
  274. .iter()
  275. .any(|alias| reference.eq_ignore_ascii_case(alias))
  276. }
  277. fn session_id_from_path(path: &Path) -> Option<String> {
  278. path.file_name()
  279. .and_then(|value| value.to_str())
  280. .and_then(|name| {
  281. name.strip_suffix(&format!(".{PRIMARY_SESSION_EXTENSION}"))
  282. .or_else(|| name.strip_suffix(&format!(".{LEGACY_SESSION_EXTENSION}")))
  283. })
  284. .map(ToOwned::to_owned)
  285. }
  286. fn format_missing_session_reference(reference: &str) -> String {
  287. format!(
  288. "session not found: {reference}\nHint: managed sessions live in .claw/sessions/. Try `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL."
  289. )
  290. }
  291. fn format_no_managed_sessions() -> String {
  292. format!(
  293. "no managed sessions found in .claw/sessions/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`."
  294. )
  295. }
  296. #[cfg(test)]
  297. mod tests {
  298. use super::{
  299. create_managed_session_handle_for, fork_managed_session_for, is_session_reference_alias,
  300. list_managed_sessions_for, load_managed_session_for, resolve_session_reference_for,
  301. ManagedSessionSummary, LATEST_SESSION_REFERENCE,
  302. };
  303. use crate::session::Session;
  304. use std::fs;
  305. use std::path::{Path, PathBuf};
  306. use std::time::{SystemTime, UNIX_EPOCH};
  307. fn temp_dir() -> PathBuf {
  308. let nanos = SystemTime::now()
  309. .duration_since(UNIX_EPOCH)
  310. .expect("time should be after epoch")
  311. .as_nanos();
  312. std::env::temp_dir().join(format!("runtime-session-control-{nanos}"))
  313. }
  314. fn persist_session(root: &Path, text: &str) -> Session {
  315. let mut session = Session::new();
  316. session
  317. .push_user_text(text)
  318. .expect("session message should save");
  319. let handle = create_managed_session_handle_for(root, &session.session_id)
  320. .expect("managed session handle should build");
  321. let session = session.with_persistence_path(handle.path.clone());
  322. session
  323. .save_to_path(&handle.path)
  324. .expect("session should persist");
  325. session
  326. }
  327. fn wait_for_next_millisecond() {
  328. let start = SystemTime::now()
  329. .duration_since(UNIX_EPOCH)
  330. .expect("time should be after epoch")
  331. .as_millis();
  332. while SystemTime::now()
  333. .duration_since(UNIX_EPOCH)
  334. .expect("time should be after epoch")
  335. .as_millis()
  336. <= start
  337. {}
  338. }
  339. fn summary_by_id<'a>(
  340. summaries: &'a [ManagedSessionSummary],
  341. id: &str,
  342. ) -> &'a ManagedSessionSummary {
  343. summaries
  344. .iter()
  345. .find(|summary| summary.id == id)
  346. .expect("session summary should exist")
  347. }
  348. #[test]
  349. fn creates_and_lists_managed_sessions() {
  350. // given
  351. let root = temp_dir();
  352. fs::create_dir_all(&root).expect("root dir should exist");
  353. let older = persist_session(&root, "older session");
  354. wait_for_next_millisecond();
  355. let newer = persist_session(&root, "newer session");
  356. // when
  357. let sessions = list_managed_sessions_for(&root).expect("managed sessions should list");
  358. // then
  359. assert_eq!(sessions.len(), 2);
  360. assert_eq!(sessions[0].id, newer.session_id);
  361. assert_eq!(summary_by_id(&sessions, &older.session_id).message_count, 1);
  362. assert_eq!(summary_by_id(&sessions, &newer.session_id).message_count, 1);
  363. fs::remove_dir_all(root).expect("temp dir should clean up");
  364. }
  365. #[test]
  366. fn resolves_latest_alias_and_loads_session_from_workspace_root() {
  367. // given
  368. let root = temp_dir();
  369. fs::create_dir_all(&root).expect("root dir should exist");
  370. let older = persist_session(&root, "older session");
  371. wait_for_next_millisecond();
  372. let newer = persist_session(&root, "newer session");
  373. // when
  374. let handle = resolve_session_reference_for(&root, LATEST_SESSION_REFERENCE)
  375. .expect("latest alias should resolve");
  376. let loaded = load_managed_session_for(&root, "recent")
  377. .expect("recent alias should load the latest session");
  378. // then
  379. assert_eq!(handle.id, newer.session_id);
  380. assert_eq!(loaded.handle.id, newer.session_id);
  381. assert_eq!(loaded.session.messages.len(), 1);
  382. assert_ne!(loaded.handle.id, older.session_id);
  383. assert!(is_session_reference_alias("last"));
  384. fs::remove_dir_all(root).expect("temp dir should clean up");
  385. }
  386. #[test]
  387. fn forks_session_into_managed_storage_with_lineage() {
  388. // given
  389. let root = temp_dir();
  390. fs::create_dir_all(&root).expect("root dir should exist");
  391. let source = persist_session(&root, "parent session");
  392. // when
  393. let forked = fork_managed_session_for(&root, &source, Some("incident-review".to_string()))
  394. .expect("session should fork");
  395. let sessions = list_managed_sessions_for(&root).expect("managed sessions should list");
  396. let summary = summary_by_id(&sessions, &forked.handle.id);
  397. // then
  398. assert_eq!(forked.parent_session_id, source.session_id);
  399. assert_eq!(forked.branch_name.as_deref(), Some("incident-review"));
  400. assert_eq!(
  401. summary.parent_session_id.as_deref(),
  402. Some(source.session_id.as_str())
  403. );
  404. assert_eq!(summary.branch_name.as_deref(), Some("incident-review"));
  405. assert_eq!(
  406. forked.session.persistence_path(),
  407. Some(forked.handle.path.as_path())
  408. );
  409. fs::remove_dir_all(root).expect("temp dir should clean up");
  410. }
  411. }