1use std::fs;
19use std::path::{Path, PathBuf};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22use serde::{Deserialize, Serialize};
23use uuid::Uuid;
24
25use crate::agent::{Agent, Continue, SessionSupport};
26use crate::error::{Error, Result};
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[non_exhaustive]
31pub struct SessionRecord {
32 pub name: String,
36 pub project: String,
38 pub agent: Agent,
40 pub token: String,
42 pub created: i64,
44 pub updated: i64,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum Phase {
52 Create,
54 Continue,
56 Fork,
58}
59
60#[derive(Debug, Clone)]
62pub struct SessionStore {
63 dir: PathBuf,
64}
65
66pub(crate) struct SessionLease {
73 file: fs::File,
74}
75
76impl Drop for SessionLease {
77 fn drop(&mut self) {
78 let _ = fs2::FileExt::unlock(&self.file);
79 }
80}
81
82impl SessionStore {
83 pub fn open(dir: impl Into<PathBuf>) -> Self {
85 Self { dir: dir.into() }
86 }
87
88 #[must_use]
92 pub fn default_dir() -> Option<PathBuf> {
93 let base = if cfg!(windows) {
94 std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
95 } else {
96 std::env::var_os("XDG_STATE_HOME")
97 .map(PathBuf::from)
98 .or_else(|| {
99 std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local").join("state"))
100 })
101 };
102 Some(base?.join("agent-abstraction").join("sessions"))
103 }
104
105 #[must_use]
112 pub fn path_of(&self, project: &Path, name: &str) -> PathBuf {
113 self.dir
114 .join(project_slug(project))
115 .join(format!("{}.json", encode_segment(name)))
116 }
117
118 pub(crate) fn lease(&self, project: &Path, name: &str) -> Result<SessionLease> {
125 let path = self.path_of(project, name).with_extension("lock");
126 let store_err = |source| Error::Store {
127 path: path.display().to_string(),
128 source,
129 };
130 if let Some(parent) = path.parent() {
131 fs::create_dir_all(parent).map_err(store_err)?;
132 restrict_to_owner(parent).map_err(store_err)?;
133 }
134 let file = fs::OpenOptions::new()
135 .create(true)
136 .truncate(false)
137 .read(true)
138 .write(true)
139 .open(&path)
140 .map_err(store_err)?;
141 match fs2::FileExt::try_lock_exclusive(&file) {
142 Ok(()) => Ok(SessionLease { file }),
143 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
144 Err(Error::SessionBusy {
145 name: name.to_string(),
146 project: project.display().to_string(),
147 })
148 }
149 Err(error) => Err(store_err(error)),
150 }
151 }
152
153 pub fn get(&self, project: &Path, name: &str) -> Result<Option<SessionRecord>> {
163 let path = self.path_of(project, name);
164 let text = match fs::read_to_string(&path) {
165 Ok(text) => text,
166 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
167 Err(source) => {
168 return Err(Error::Store {
169 path: path.display().to_string(),
170 source,
171 });
172 }
173 };
174 serde_json::from_str(&text)
175 .map(Some)
176 .map_err(|e| Error::Store {
177 path: path.display().to_string(),
178 source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
179 })
180 }
181
182 pub fn list(&self, project: &Path) -> Result<Vec<SessionRecord>> {
192 let dir = self.dir.join(project_slug(project));
193 let store_err = |path: &Path, source| Error::Store {
194 path: path.display().to_string(),
195 source,
196 };
197 let entries = match fs::read_dir(&dir) {
198 Ok(entries) => entries,
199 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
201 Err(e) => return Err(store_err(&dir, e)),
202 };
203 let mut out = Vec::new();
204 for entry in entries {
205 let path = entry.map_err(|e| store_err(&dir, e))?.path();
206 if path.extension().is_none_or(|ext| ext != "json") {
210 continue;
211 }
212 let text = fs::read_to_string(&path).map_err(|e| store_err(&path, e))?;
213 out.push(serde_json::from_str(&text).map_err(|e| {
214 store_err(
215 &path,
216 std::io::Error::new(std::io::ErrorKind::InvalidData, e),
217 )
218 })?);
219 }
220 Ok(out)
221 }
222
223 #[must_use]
228 pub fn list_lossy(&self, project: &Path) -> Vec<SessionRecord> {
229 let dir = self.dir.join(project_slug(project));
230 let Ok(entries) = fs::read_dir(dir) else {
231 return Vec::new();
232 };
233 entries
234 .flatten()
235 .filter_map(|e| fs::read_to_string(e.path()).ok())
236 .filter_map(|text| serde_json::from_str(&text).ok())
237 .collect()
238 }
239
240 pub(crate) fn plan(
254 &self,
255 agent: Agent,
256 project: &Path,
257 name: &str,
258 fork: bool,
259 ) -> Result<(Phase, Continue)> {
260 let caps = agent.caps();
261 if caps.session == SessionSupport::None {
262 return Err(Error::Unsupported {
263 agent,
264 what: "named sessions (it exposes no session id headlessly)",
265 });
266 }
267 let existing = self.get(project, name)?;
268 if let Some(record) = &existing {
269 if record.agent != agent {
270 return Err(Error::SessionConflict {
271 name: name.to_string(),
272 bound: record.agent,
273 requested: agent,
274 });
275 }
276 }
277
278 Ok(match (existing, fork) {
279 (Some(record), true) => {
280 if !caps.fork {
281 return Err(Error::Unsupported {
282 agent,
283 what: "forking a session headlessly",
284 });
285 }
286 (Phase::Fork, Continue::Fork(record.token))
287 }
288 (Some(record), false) => (Phase::Continue, Continue::Resume(record.token)),
289 (None, _) => (
292 Phase::Create,
293 match caps.session {
294 SessionSupport::Minted => Continue::NewWith(Uuid::new_v4().to_string()),
295 SessionSupport::Printed | SessionSupport::None => Continue::New,
297 },
298 ),
299 })
300 }
301
302 pub fn bind(
308 &self,
309 agent: Agent,
310 project: &Path,
311 name: &str,
312 token: &str,
313 ) -> Result<SessionRecord> {
314 if let Some(existing) = self.get(project, name)?
318 && existing.agent != agent
319 {
320 return Err(Error::SessionConflict {
321 name: name.to_string(),
322 bound: existing.agent,
323 requested: agent,
324 });
325 }
326
327 let now = now_secs();
328 let record = SessionRecord {
329 name: name.to_string(),
330 project: project.display().to_string(),
331 agent,
332 token: token.to_string(),
333 created: self.get(project, name)?.map_or(now, |r| r.created),
334 updated: now,
335 };
336
337 let path = self.path_of(project, name);
338 let store_err = |source| Error::Store {
339 path: path.display().to_string(),
340 source,
341 };
342 if let Some(parent) = path.parent() {
343 fs::create_dir_all(parent).map_err(store_err)?;
344 restrict_to_owner(parent).map_err(store_err)?;
345 }
346 let mut text = serde_json::to_string_pretty(&record)
347 .map_err(|e| store_err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
348 text.push('\n');
349
350 let tmp = path.with_extension(format!("{}.{}.tmp", std::process::id(), next_temp_id()));
356 write_private(&tmp, text.as_bytes()).map_err(store_err)?;
357 fs::rename(&tmp, &path).map_err(|e| {
360 let _ = fs::remove_file(&tmp);
362 store_err(e)
363 })?;
364 if let Some(parent) = path.parent() {
368 sync_dir(parent).map_err(store_err)?;
369 }
370 Ok(record)
371 }
372
373 pub fn forget(&self, project: &Path, name: &str) -> Result<()> {
378 let path = self.path_of(project, name);
379 match fs::remove_file(&path) {
380 Ok(()) => Ok(()),
381 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
382 Err(source) => Err(Error::Store {
383 path: path.display().to_string(),
384 source,
385 }),
386 }
387 }
388}
389
390fn next_temp_id() -> u64 {
393 use std::sync::atomic::{AtomicU64, Ordering};
394 static COUNTER: AtomicU64 = AtomicU64::new(0);
395 COUNTER.fetch_add(1, Ordering::Relaxed)
396}
397
398fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
405 use std::io::Write as _;
406
407 let mut options = fs::OpenOptions::new();
408 options.write(true).create_new(true);
409 #[cfg(unix)]
410 {
411 use std::os::unix::fs::OpenOptionsExt as _;
412 options.mode(0o600);
413 }
414 let mut file = options.open(path)?;
415 file.write_all(bytes)?;
416 file.sync_all()
419}
420
421fn sync_dir(dir: &Path) -> std::io::Result<()> {
424 #[cfg(unix)]
425 {
426 fs::File::open(dir)?.sync_all()?;
427 }
428 #[cfg(not(unix))]
429 let _ = dir;
430 Ok(())
431}
432
433fn restrict_to_owner(dir: &Path) -> std::io::Result<()> {
436 #[cfg(unix)]
437 {
438 use std::os::unix::fs::PermissionsExt as _;
439 fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
440 }
441 #[cfg(not(unix))]
442 let _ = dir;
443 Ok(())
444}
445
446fn now_secs() -> i64 {
449 SystemTime::now()
450 .duration_since(UNIX_EPOCH)
451 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
452}
453
454const MAX_STEM: usize = 200;
458
459fn encode_segment(name: &str) -> String {
490 use std::fmt::Write as _;
491
492 let mut out = String::with_capacity(name.len());
493 for byte in name.bytes() {
494 if byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.')
495 {
496 out.push(byte as char);
497 } else {
498 let _ = write!(out, "%{byte:02X}");
500 }
501 }
502 if out.is_empty() {
503 return "%".into();
508 }
509 if out.len() > MAX_STEM {
510 let mut cut = MAX_STEM;
512 while cut > 0 && !is_encoding_boundary(&out, cut) {
513 cut -= 1;
514 }
515 return format!("{}-{:016x}", &out[..cut], fnv1a(name.as_bytes()));
516 }
517 out
518}
519
520fn is_encoding_boundary(s: &str, at: usize) -> bool {
522 let b = s.as_bytes();
523 !((at >= 1 && b[at - 1] == b'%') || (at >= 2 && b[at - 2] == b'%'))
524}
525
526fn fnv1a(bytes: &[u8]) -> u64 {
532 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
533 for byte in bytes {
534 hash ^= u64::from(*byte);
535 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
536 }
537 hash
538}
539
540fn project_slug(project: &Path) -> String {
543 encode_segment(&project.display().to_string())
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 fn store(tag: &str) -> (SessionStore, PathBuf) {
552 let dir = std::env::temp_dir().join(format!(
553 "agent-abstraction-{tag}-{}-{}",
554 std::process::id(),
555 now_secs()
556 ));
557 (SessionStore::open(dir), PathBuf::from("/home/me/proj"))
558 }
559
560 #[test]
561 fn names_and_projects_reduce_to_one_safe_segment() {
562 assert_eq!(encode_segment("greet-flow"), "greet-flow");
565 assert_eq!(encode_segment("v1.2_final"), "v1.2_final");
566 assert_eq!(encode_segment(""), "%");
569 assert_ne!(encode_segment(""), encode_segment("unnamed"));
570
571 for name in ["../../etc/passwd", "..", ".", "a/b", "a\\b"] {
574 let encoded = encode_segment(name);
575 assert!(!encoded.contains('/'), "{name:?} kept a separator");
576 assert!(!encoded.contains('\\'), "{name:?} kept a separator");
577 assert!(
578 Path::new(&encoded).components().count() == 1,
579 "{name:?} encoded to more than one component"
580 );
581 }
582 assert!(!project_slug(Path::new("/home/me/My Proj")).contains('/'));
583 }
584
585 #[test]
589 fn distinct_names_never_share_an_encoded_segment() {
590 let names = [
591 "café",
592 "cafe-",
593 "cafe",
594 "Chat",
595 "chat",
596 "CHAT",
597 "a/b",
598 "a-b",
599 "a b",
600 "..",
601 "%41",
602 "A",
603 "",
604 "unnamed",
605 "日本語",
606 "🙂",
607 ];
608 let mut seen = std::collections::HashMap::new();
609 for name in names {
610 let key = encode_segment(name).to_ascii_lowercase();
613 if let Some(previous) = seen.insert(key.clone(), name) {
614 panic!("{name:?} and {previous:?} both encode to {key:?}");
615 }
616 }
617 }
618
619 #[test]
620 fn a_very_long_name_stays_within_filename_limits_and_stays_unique() {
621 let a = "x".repeat(5_000);
622 let b = format!("{a}different");
623 let (ea, eb) = (encode_segment(&a), encode_segment(&b));
624
625 assert!(ea.len() < 250, "{}", ea.len());
627 assert!(eb.len() < 250);
628 assert_ne!(ea, eb, "truncation must not collapse distinct names");
629 }
630
631 #[test]
632 fn truncation_never_splits_an_escape_sequence() {
633 let encoded = encode_segment(&"A".repeat(2_000));
635 let stem = encoded.rsplit_once('-').unwrap().0;
636 for (i, _) in stem.match_indices('%') {
638 assert!(i + 2 < stem.len(), "escape split at {i} in {stem:?}");
639 }
640 }
641
642 #[test]
645 fn the_record_preserves_the_original_name() {
646 let (store, project) = store("original-name");
647 store
648 .bind(Agent::Claude, &project, "Greet Flow ☕", "t-1")
649 .unwrap();
650 let record = store.get(&project, "Greet Flow ☕").unwrap().unwrap();
651 assert_eq!(record.name, "Greet Flow ☕");
652 assert_eq!(store.list(&project).unwrap()[0].name, "Greet Flow ☕");
653 fs::remove_dir_all(&store.dir).ok();
654 }
655
656 #[test]
657 fn a_path_traversing_name_cannot_escape_the_store() {
658 let (store, project) = store("escape");
659 for name in ["../../etc/passwd", "..", "/etc/passwd", "a/../../b"] {
660 let path = store.path_of(&project, name);
661 assert!(path.starts_with(&store.dir), "{name:?} escaped to {path:?}");
662 assert_eq!(
665 path.strip_prefix(&store.dir).unwrap().components().count(),
666 2,
667 "{name:?} produced extra path components: {path:?}"
668 );
669 }
670 }
671
672 #[test]
673 fn a_missing_session_plans_a_create() {
674 let (store, project) = store("create");
675 let (phase, cont) = store.plan(Agent::Claude, &project, "chat", false).unwrap();
676 assert_eq!(phase, Phase::Create);
677 let Continue::NewWith(id) = cont else {
679 panic!("a minting agent must allocate an id up front, got {cont:?}")
680 };
681 assert!(Uuid::parse_str(&id).is_ok(), "{id} must be a UUID");
682 }
683
684 #[test]
685 fn a_printing_agent_starts_without_an_id() {
686 let (store, project) = store("printed");
687 let (phase, cont) = store.plan(Agent::Codex, &project, "chat", false).unwrap();
688 assert_eq!(phase, Phase::Create);
689 assert_eq!(cont, Continue::New, "codex's id only exists once printed");
690 }
691
692 #[test]
693 fn a_bound_session_plans_a_continue_and_survives_a_round_trip() {
694 let (store, project) = store("continue");
695 store
696 .bind(Agent::Claude, &project, "chat", "sess-1")
697 .unwrap();
698
699 let (phase, cont) = store.plan(Agent::Claude, &project, "chat", false).unwrap();
700 assert_eq!(phase, Phase::Continue);
701 assert_eq!(cont, Continue::Resume("sess-1".into()));
702
703 let record = store.get(&project, "chat").unwrap().unwrap();
704 assert_eq!(record.token, "sess-1");
705 assert_eq!(record.agent, Agent::Claude);
706 fs::remove_dir_all(&store.dir).ok();
707 }
708
709 #[test]
710 fn rebinding_refreshes_the_token_but_keeps_the_creation_time() {
711 let (store, project) = store("rebind");
712 let first = store
713 .bind(Agent::Claude, &project, "chat", "sess-1")
714 .unwrap();
715 let second = store
716 .bind(Agent::Claude, &project, "chat", "sess-2")
717 .unwrap();
718 assert_eq!(second.token, "sess-2");
719 assert_eq!(second.created, first.created);
720 assert!(second.updated >= first.updated);
721 fs::remove_dir_all(&store.dir).ok();
722 }
723
724 #[test]
725 fn a_session_cannot_migrate_between_agents() {
726 let (store, project) = store("conflict");
727 store
728 .bind(Agent::Claude, &project, "chat", "sess-1")
729 .unwrap();
730 let err = store
731 .plan(Agent::Codex, &project, "chat", false)
732 .unwrap_err();
733 assert!(
734 matches!(err, Error::SessionConflict { bound, requested, .. }
735 if bound == Agent::Claude && requested == Agent::Codex),
736 "got {err:?}"
737 );
738 fs::remove_dir_all(&store.dir).ok();
739 }
740
741 #[test]
742 fn forking_is_refused_by_agents_that_cannot_fork() {
743 let (store, project) = store("fork");
744 store.bind(Agent::Codex, &project, "chat", "t-1").unwrap();
745 assert!(matches!(
746 store.plan(Agent::Codex, &project, "chat", true),
747 Err(Error::Unsupported { .. })
748 ));
749
750 store.bind(Agent::Claude, &project, "c2", "sess-1").unwrap();
751 let (phase, cont) = store.plan(Agent::Claude, &project, "c2", true).unwrap();
752 assert_eq!(phase, Phase::Fork);
753 assert_eq!(cont, Continue::Fork("sess-1".into()));
754 fs::remove_dir_all(&store.dir).ok();
755 }
756
757 #[test]
758 fn forking_a_session_that_does_not_exist_yet_just_creates_one() {
759 let (store, project) = store("fork-new");
760 let (phase, _) = store.plan(Agent::Claude, &project, "fresh", true).unwrap();
761 assert_eq!(phase, Phase::Create, "nothing to branch from yet");
762 }
763
764 #[test]
765 fn a_corrupt_record_is_reported_rather_than_silently_ignored() {
766 let (store, project) = store("corrupt");
767 let path = store.path_of(&project, "chat");
768 fs::create_dir_all(path.parent().unwrap()).unwrap();
769 fs::write(&path, b"{ not json").unwrap();
770 assert!(matches!(
773 store.get(&project, "chat"),
774 Err(Error::Store { .. })
775 ));
776 assert!(matches!(
777 store.plan(Agent::Claude, &project, "chat", false),
778 Err(Error::Store { .. })
779 ));
780 fs::remove_dir_all(&store.dir).ok();
781 }
782
783 #[test]
784 fn sessions_list_per_project_and_forgetting_is_idempotent() {
785 let (store, project) = store("list");
786 store.bind(Agent::Claude, &project, "a", "t-a").unwrap();
787 store.bind(Agent::Claude, &project, "b", "t-b").unwrap();
788 let mut names: Vec<_> = store
789 .list(&project)
790 .unwrap()
791 .into_iter()
792 .map(|r| r.name)
793 .collect();
794 names.sort();
795 assert_eq!(names, ["a", "b"]);
796
797 store.forget(&project, "a").unwrap();
798 assert!(store.get(&project, "a").unwrap().is_none());
799 store.forget(&project, "a").unwrap();
801 assert_eq!(store.list(&project).unwrap().len(), 1);
802 fs::remove_dir_all(&store.dir).ok();
803 }
804
805 #[test]
806 fn the_same_name_in_two_projects_does_not_collide() {
807 let (store, project) = store("projects");
808 let other = PathBuf::from("/home/me/other");
809 store.bind(Agent::Claude, &project, "chat", "t-1").unwrap();
810 store.bind(Agent::Claude, &other, "chat", "t-2").unwrap();
811 assert_eq!(store.get(&project, "chat").unwrap().unwrap().token, "t-1");
812 assert_eq!(store.get(&other, "chat").unwrap().unwrap().token, "t-2");
813 fs::remove_dir_all(&store.dir).ok();
814 }
815}