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
66impl SessionStore {
67 pub fn open(dir: impl Into<PathBuf>) -> Self {
69 Self { dir: dir.into() }
70 }
71
72 #[must_use]
76 pub fn default_dir() -> Option<PathBuf> {
77 let base = if cfg!(windows) {
78 std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
79 } else {
80 std::env::var_os("XDG_STATE_HOME")
81 .map(PathBuf::from)
82 .or_else(|| {
83 std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local").join("state"))
84 })
85 };
86 Some(base?.join("agent-abstraction").join("sessions"))
87 }
88
89 #[must_use]
96 pub fn path_of(&self, project: &Path, name: &str) -> PathBuf {
97 self.dir
98 .join(project_slug(project))
99 .join(format!("{}.json", encode_segment(name)))
100 }
101
102 pub fn get(&self, project: &Path, name: &str) -> Result<Option<SessionRecord>> {
112 let path = self.path_of(project, name);
113 let text = match fs::read_to_string(&path) {
114 Ok(text) => text,
115 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
116 Err(source) => {
117 return Err(Error::Store {
118 path: path.display().to_string(),
119 source,
120 });
121 }
122 };
123 serde_json::from_str(&text)
124 .map(Some)
125 .map_err(|e| Error::Store {
126 path: path.display().to_string(),
127 source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
128 })
129 }
130
131 pub fn list(&self, project: &Path) -> Result<Vec<SessionRecord>> {
141 let dir = self.dir.join(project_slug(project));
142 let store_err = |path: &Path, source| Error::Store {
143 path: path.display().to_string(),
144 source,
145 };
146 let entries = match fs::read_dir(&dir) {
147 Ok(entries) => entries,
148 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
150 Err(e) => return Err(store_err(&dir, e)),
151 };
152 let mut out = Vec::new();
153 for entry in entries {
154 let path = entry.map_err(|e| store_err(&dir, e))?.path();
155 if path.extension().is_some_and(|ext| ext == "tmp") {
157 continue;
158 }
159 let text = fs::read_to_string(&path).map_err(|e| store_err(&path, e))?;
160 out.push(serde_json::from_str(&text).map_err(|e| {
161 store_err(
162 &path,
163 std::io::Error::new(std::io::ErrorKind::InvalidData, e),
164 )
165 })?);
166 }
167 Ok(out)
168 }
169
170 #[must_use]
175 pub fn list_lossy(&self, project: &Path) -> Vec<SessionRecord> {
176 let dir = self.dir.join(project_slug(project));
177 let Ok(entries) = fs::read_dir(dir) else {
178 return Vec::new();
179 };
180 entries
181 .flatten()
182 .filter_map(|e| fs::read_to_string(e.path()).ok())
183 .filter_map(|text| serde_json::from_str(&text).ok())
184 .collect()
185 }
186
187 pub(crate) fn plan(
201 &self,
202 agent: Agent,
203 project: &Path,
204 name: &str,
205 fork: bool,
206 ) -> Result<(Phase, Continue)> {
207 let caps = agent.caps();
208 if caps.session == SessionSupport::None {
209 return Err(Error::Unsupported {
210 agent,
211 what: "named sessions (it exposes no session id headlessly)",
212 });
213 }
214 let existing = self.get(project, name)?;
215 if let Some(record) = &existing {
216 if record.agent != agent {
217 return Err(Error::SessionConflict {
218 name: name.to_string(),
219 bound: record.agent,
220 requested: agent,
221 });
222 }
223 }
224
225 Ok(match (existing, fork) {
226 (Some(record), true) => {
227 if !caps.fork {
228 return Err(Error::Unsupported {
229 agent,
230 what: "forking a session headlessly",
231 });
232 }
233 (Phase::Fork, Continue::Fork(record.token))
234 }
235 (Some(record), false) => (Phase::Continue, Continue::Resume(record.token)),
236 (None, _) => (
239 Phase::Create,
240 match caps.session {
241 SessionSupport::Minted => Continue::NewWith(Uuid::new_v4().to_string()),
242 SessionSupport::Printed | SessionSupport::None => Continue::New,
244 },
245 ),
246 })
247 }
248
249 pub fn bind(
255 &self,
256 agent: Agent,
257 project: &Path,
258 name: &str,
259 token: &str,
260 ) -> Result<SessionRecord> {
261 if let Some(existing) = self.get(project, name)?
265 && existing.agent != agent
266 {
267 return Err(Error::SessionConflict {
268 name: name.to_string(),
269 bound: existing.agent,
270 requested: agent,
271 });
272 }
273
274 let now = now_secs();
275 let record = SessionRecord {
276 name: name.to_string(),
277 project: project.display().to_string(),
278 agent,
279 token: token.to_string(),
280 created: self.get(project, name)?.map_or(now, |r| r.created),
281 updated: now,
282 };
283
284 let path = self.path_of(project, name);
285 let store_err = |source| Error::Store {
286 path: path.display().to_string(),
287 source,
288 };
289 if let Some(parent) = path.parent() {
290 fs::create_dir_all(parent).map_err(store_err)?;
291 restrict_to_owner(parent).map_err(store_err)?;
292 }
293 let mut text = serde_json::to_string_pretty(&record)
294 .map_err(|e| store_err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
295 text.push('\n');
296
297 let tmp = path.with_extension(format!("{}.{}.tmp", std::process::id(), next_temp_id()));
303 write_private(&tmp, text.as_bytes()).map_err(store_err)?;
304 fs::rename(&tmp, &path).map_err(|e| {
307 let _ = fs::remove_file(&tmp);
309 store_err(e)
310 })?;
311 if let Some(parent) = path.parent() {
315 sync_dir(parent).map_err(store_err)?;
316 }
317 Ok(record)
318 }
319
320 pub fn forget(&self, project: &Path, name: &str) -> Result<()> {
325 let path = self.path_of(project, name);
326 match fs::remove_file(&path) {
327 Ok(()) => Ok(()),
328 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
329 Err(source) => Err(Error::Store {
330 path: path.display().to_string(),
331 source,
332 }),
333 }
334 }
335}
336
337fn next_temp_id() -> u64 {
340 use std::sync::atomic::{AtomicU64, Ordering};
341 static COUNTER: AtomicU64 = AtomicU64::new(0);
342 COUNTER.fetch_add(1, Ordering::Relaxed)
343}
344
345fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
352 use std::io::Write as _;
353
354 let mut options = fs::OpenOptions::new();
355 options.write(true).create_new(true);
356 #[cfg(unix)]
357 {
358 use std::os::unix::fs::OpenOptionsExt as _;
359 options.mode(0o600);
360 }
361 let mut file = options.open(path)?;
362 file.write_all(bytes)?;
363 file.sync_all()
366}
367
368fn sync_dir(dir: &Path) -> std::io::Result<()> {
371 #[cfg(unix)]
372 {
373 fs::File::open(dir)?.sync_all()?;
374 }
375 #[cfg(not(unix))]
376 let _ = dir;
377 Ok(())
378}
379
380fn restrict_to_owner(dir: &Path) -> std::io::Result<()> {
383 #[cfg(unix)]
384 {
385 use std::os::unix::fs::PermissionsExt as _;
386 fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?;
387 }
388 #[cfg(not(unix))]
389 let _ = dir;
390 Ok(())
391}
392
393fn now_secs() -> i64 {
396 SystemTime::now()
397 .duration_since(UNIX_EPOCH)
398 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
399}
400
401const MAX_STEM: usize = 200;
405
406fn encode_segment(name: &str) -> String {
437 use std::fmt::Write as _;
438
439 let mut out = String::with_capacity(name.len());
440 for byte in name.bytes() {
441 if byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.')
442 {
443 out.push(byte as char);
444 } else {
445 let _ = write!(out, "%{byte:02X}");
447 }
448 }
449 if out.is_empty() {
450 return "%".into();
455 }
456 if out.len() > MAX_STEM {
457 let mut cut = MAX_STEM;
459 while cut > 0 && !is_encoding_boundary(&out, cut) {
460 cut -= 1;
461 }
462 return format!("{}-{:016x}", &out[..cut], fnv1a(name.as_bytes()));
463 }
464 out
465}
466
467fn is_encoding_boundary(s: &str, at: usize) -> bool {
469 let b = s.as_bytes();
470 !((at >= 1 && b[at - 1] == b'%') || (at >= 2 && b[at - 2] == b'%'))
471}
472
473fn fnv1a(bytes: &[u8]) -> u64 {
479 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
480 for byte in bytes {
481 hash ^= u64::from(*byte);
482 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
483 }
484 hash
485}
486
487fn project_slug(project: &Path) -> String {
490 encode_segment(&project.display().to_string())
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 fn store(tag: &str) -> (SessionStore, PathBuf) {
499 let dir = std::env::temp_dir().join(format!(
500 "agent-abstraction-{tag}-{}-{}",
501 std::process::id(),
502 now_secs()
503 ));
504 (SessionStore::open(dir), PathBuf::from("/home/me/proj"))
505 }
506
507 #[test]
508 fn names_and_projects_reduce_to_one_safe_segment() {
509 assert_eq!(encode_segment("greet-flow"), "greet-flow");
512 assert_eq!(encode_segment("v1.2_final"), "v1.2_final");
513 assert_eq!(encode_segment(""), "%");
516 assert_ne!(encode_segment(""), encode_segment("unnamed"));
517
518 for name in ["../../etc/passwd", "..", ".", "a/b", "a\\b"] {
521 let encoded = encode_segment(name);
522 assert!(!encoded.contains('/'), "{name:?} kept a separator");
523 assert!(!encoded.contains('\\'), "{name:?} kept a separator");
524 assert!(
525 Path::new(&encoded).components().count() == 1,
526 "{name:?} encoded to more than one component"
527 );
528 }
529 assert!(!project_slug(Path::new("/home/me/My Proj")).contains('/'));
530 }
531
532 #[test]
536 fn distinct_names_never_share_an_encoded_segment() {
537 let names = [
538 "café",
539 "cafe-",
540 "cafe",
541 "Chat",
542 "chat",
543 "CHAT",
544 "a/b",
545 "a-b",
546 "a b",
547 "..",
548 "%41",
549 "A",
550 "",
551 "unnamed",
552 "日本語",
553 "🙂",
554 ];
555 let mut seen = std::collections::HashMap::new();
556 for name in names {
557 let key = encode_segment(name).to_ascii_lowercase();
560 if let Some(previous) = seen.insert(key.clone(), name) {
561 panic!("{name:?} and {previous:?} both encode to {key:?}");
562 }
563 }
564 }
565
566 #[test]
567 fn a_very_long_name_stays_within_filename_limits_and_stays_unique() {
568 let a = "x".repeat(5_000);
569 let b = format!("{a}different");
570 let (ea, eb) = (encode_segment(&a), encode_segment(&b));
571
572 assert!(ea.len() < 250, "{}", ea.len());
574 assert!(eb.len() < 250);
575 assert_ne!(ea, eb, "truncation must not collapse distinct names");
576 }
577
578 #[test]
579 fn truncation_never_splits_an_escape_sequence() {
580 let encoded = encode_segment(&"A".repeat(2_000));
582 let stem = encoded.rsplit_once('-').unwrap().0;
583 for (i, _) in stem.match_indices('%') {
585 assert!(i + 2 < stem.len(), "escape split at {i} in {stem:?}");
586 }
587 }
588
589 #[test]
592 fn the_record_preserves_the_original_name() {
593 let (store, project) = store("original-name");
594 store
595 .bind(Agent::Claude, &project, "Greet Flow ☕", "t-1")
596 .unwrap();
597 let record = store.get(&project, "Greet Flow ☕").unwrap().unwrap();
598 assert_eq!(record.name, "Greet Flow ☕");
599 assert_eq!(store.list(&project).unwrap()[0].name, "Greet Flow ☕");
600 fs::remove_dir_all(&store.dir).ok();
601 }
602
603 #[test]
604 fn a_path_traversing_name_cannot_escape_the_store() {
605 let (store, project) = store("escape");
606 for name in ["../../etc/passwd", "..", "/etc/passwd", "a/../../b"] {
607 let path = store.path_of(&project, name);
608 assert!(path.starts_with(&store.dir), "{name:?} escaped to {path:?}");
609 assert_eq!(
612 path.strip_prefix(&store.dir).unwrap().components().count(),
613 2,
614 "{name:?} produced extra path components: {path:?}"
615 );
616 }
617 }
618
619 #[test]
620 fn a_missing_session_plans_a_create() {
621 let (store, project) = store("create");
622 let (phase, cont) = store.plan(Agent::Claude, &project, "chat", false).unwrap();
623 assert_eq!(phase, Phase::Create);
624 let Continue::NewWith(id) = cont else {
626 panic!("a minting agent must allocate an id up front, got {cont:?}")
627 };
628 assert!(Uuid::parse_str(&id).is_ok(), "{id} must be a UUID");
629 }
630
631 #[test]
632 fn a_printing_agent_starts_without_an_id() {
633 let (store, project) = store("printed");
634 let (phase, cont) = store.plan(Agent::Codex, &project, "chat", false).unwrap();
635 assert_eq!(phase, Phase::Create);
636 assert_eq!(cont, Continue::New, "codex's id only exists once printed");
637 }
638
639 #[test]
640 fn a_bound_session_plans_a_continue_and_survives_a_round_trip() {
641 let (store, project) = store("continue");
642 store
643 .bind(Agent::Claude, &project, "chat", "sess-1")
644 .unwrap();
645
646 let (phase, cont) = store.plan(Agent::Claude, &project, "chat", false).unwrap();
647 assert_eq!(phase, Phase::Continue);
648 assert_eq!(cont, Continue::Resume("sess-1".into()));
649
650 let record = store.get(&project, "chat").unwrap().unwrap();
651 assert_eq!(record.token, "sess-1");
652 assert_eq!(record.agent, Agent::Claude);
653 fs::remove_dir_all(&store.dir).ok();
654 }
655
656 #[test]
657 fn rebinding_refreshes_the_token_but_keeps_the_creation_time() {
658 let (store, project) = store("rebind");
659 let first = store
660 .bind(Agent::Claude, &project, "chat", "sess-1")
661 .unwrap();
662 let second = store
663 .bind(Agent::Claude, &project, "chat", "sess-2")
664 .unwrap();
665 assert_eq!(second.token, "sess-2");
666 assert_eq!(second.created, first.created);
667 assert!(second.updated >= first.updated);
668 fs::remove_dir_all(&store.dir).ok();
669 }
670
671 #[test]
672 fn a_session_cannot_migrate_between_agents() {
673 let (store, project) = store("conflict");
674 store
675 .bind(Agent::Claude, &project, "chat", "sess-1")
676 .unwrap();
677 let err = store
678 .plan(Agent::Codex, &project, "chat", false)
679 .unwrap_err();
680 assert!(
681 matches!(err, Error::SessionConflict { bound, requested, .. }
682 if bound == Agent::Claude && requested == Agent::Codex),
683 "got {err:?}"
684 );
685 fs::remove_dir_all(&store.dir).ok();
686 }
687
688 #[test]
689 fn forking_is_refused_by_agents_that_cannot_fork() {
690 let (store, project) = store("fork");
691 store.bind(Agent::Codex, &project, "chat", "t-1").unwrap();
692 assert!(matches!(
693 store.plan(Agent::Codex, &project, "chat", true),
694 Err(Error::Unsupported { .. })
695 ));
696
697 store.bind(Agent::Claude, &project, "c2", "sess-1").unwrap();
698 let (phase, cont) = store.plan(Agent::Claude, &project, "c2", true).unwrap();
699 assert_eq!(phase, Phase::Fork);
700 assert_eq!(cont, Continue::Fork("sess-1".into()));
701 fs::remove_dir_all(&store.dir).ok();
702 }
703
704 #[test]
705 fn forking_a_session_that_does_not_exist_yet_just_creates_one() {
706 let (store, project) = store("fork-new");
707 let (phase, _) = store.plan(Agent::Claude, &project, "fresh", true).unwrap();
708 assert_eq!(phase, Phase::Create, "nothing to branch from yet");
709 }
710
711 #[test]
712 fn a_corrupt_record_is_reported_rather_than_silently_ignored() {
713 let (store, project) = store("corrupt");
714 let path = store.path_of(&project, "chat");
715 fs::create_dir_all(path.parent().unwrap()).unwrap();
716 fs::write(&path, b"{ not json").unwrap();
717 assert!(matches!(
720 store.get(&project, "chat"),
721 Err(Error::Store { .. })
722 ));
723 assert!(matches!(
724 store.plan(Agent::Claude, &project, "chat", false),
725 Err(Error::Store { .. })
726 ));
727 fs::remove_dir_all(&store.dir).ok();
728 }
729
730 #[test]
731 fn sessions_list_per_project_and_forgetting_is_idempotent() {
732 let (store, project) = store("list");
733 store.bind(Agent::Claude, &project, "a", "t-a").unwrap();
734 store.bind(Agent::Claude, &project, "b", "t-b").unwrap();
735 let mut names: Vec<_> = store
736 .list(&project)
737 .unwrap()
738 .into_iter()
739 .map(|r| r.name)
740 .collect();
741 names.sort();
742 assert_eq!(names, ["a", "b"]);
743
744 store.forget(&project, "a").unwrap();
745 assert!(store.get(&project, "a").unwrap().is_none());
746 store.forget(&project, "a").unwrap();
748 assert_eq!(store.list(&project).unwrap().len(), 1);
749 fs::remove_dir_all(&store.dir).ok();
750 }
751
752 #[test]
753 fn the_same_name_in_two_projects_does_not_collide() {
754 let (store, project) = store("projects");
755 let other = PathBuf::from("/home/me/other");
756 store.bind(Agent::Claude, &project, "chat", "t-1").unwrap();
757 store.bind(Agent::Claude, &other, "chat", "t-2").unwrap();
758 assert_eq!(store.get(&project, "chat").unwrap().unwrap().token, "t-1");
759 assert_eq!(store.get(&other, "chat").unwrap().unwrap().token, "t-2");
760 fs::remove_dir_all(&store.dir).ok();
761 }
762}