2026-04-05 18:46:06 +00:00
#![ allow(dead_code) ]
2026-04-04 00:43:30 +09:00
use std ::env ;
use std ::fmt ::{ Display , Formatter } ;
use std ::fs ;
use std ::path ::{ Path , PathBuf } ;
use std ::time ::UNIX_EPOCH ;
use crate ::session ::{ Session , SessionError } ;
2026-04-07 15:55:38 +09:00
/// Per-worktree session store that namespaces on-disk session files by
/// workspace fingerprint so that parallel `opencode serve` instances never
/// collide.
///
/// Create via [`SessionStore::from_cwd`] (derives the store path from the
/// server's working directory) or [`SessionStore::from_data_dir`] (honours an
/// explicit `--data-dir` flag). Both constructors produce a directory layout
/// of `<data_dir>/sessions/<workspace_hash>/` where `<workspace_hash>` is a
/// stable hex digest of the canonical workspace root.
#[ derive(Debug, Clone, PartialEq, Eq) ]
pub struct SessionStore {
/// Resolved root of the session namespace, e.g.
/// `/home/user/project/.claw/sessions/a1b2c3d4e5f60718/`.
sessions_root : PathBuf ,
/// The canonical workspace path that was fingerprinted.
workspace_root : PathBuf ,
}
impl SessionStore {
/// Build a store from the server's current working directory.
///
/// The on-disk layout becomes `<cwd>/.claw/sessions/<workspace_hash>/`.
pub fn from_cwd ( cwd : impl AsRef < Path > ) -> Result < Self , SessionControlError > {
let cwd = cwd . as_ref ( ) ;
2026-04-21 21:06:09 +09:00
// #151: canonicalize so equivalent paths (symlinks, relative vs
// absolute, /tmp vs /private/tmp on macOS) produce the same
// workspace_fingerprint. Falls back to the raw path if canonicalize
// fails (e.g. the directory doesn't exist yet).
let canonical_cwd = fs ::canonicalize ( cwd ) . unwrap_or_else ( | _ | cwd . to_path_buf ( ) ) ;
let sessions_root = canonical_cwd
2026-04-07 15:55:38 +09:00
. join ( " .claw " )
. join ( " sessions " )
2026-04-21 21:06:09 +09:00
. join ( workspace_fingerprint ( & canonical_cwd ) ) ;
2026-04-07 15:55:38 +09:00
fs ::create_dir_all ( & sessions_root ) ? ;
Ok ( Self {
sessions_root ,
2026-04-21 21:06:09 +09:00
workspace_root : canonical_cwd ,
2026-04-07 15:55:38 +09:00
} )
}
/// Build a store from an explicit `--data-dir` flag.
///
/// The on-disk layout becomes `<data_dir>/sessions/<workspace_hash>/`
/// where `<workspace_hash>` is derived from `workspace_root`.
pub fn from_data_dir (
data_dir : impl AsRef < Path > ,
workspace_root : impl AsRef < Path > ,
) -> Result < Self , SessionControlError > {
let workspace_root = workspace_root . as_ref ( ) ;
2026-04-21 21:06:09 +09:00
// #151: canonicalize workspace_root for consistent fingerprinting
// across equivalent path representations.
let canonical_workspace = fs ::canonicalize ( workspace_root )
. unwrap_or_else ( | _ | workspace_root . to_path_buf ( ) ) ;
2026-04-07 15:55:38 +09:00
let sessions_root = data_dir
. as_ref ( )
. join ( " sessions " )
2026-04-21 21:06:09 +09:00
. join ( workspace_fingerprint ( & canonical_workspace ) ) ;
2026-04-07 15:55:38 +09:00
fs ::create_dir_all ( & sessions_root ) ? ;
Ok ( Self {
sessions_root ,
2026-04-21 21:06:09 +09:00
workspace_root : canonical_workspace ,
2026-04-07 15:55:38 +09:00
} )
}
/// The fully resolved sessions directory for this namespace.
#[ must_use ]
pub fn sessions_dir ( & self ) -> & Path {
& self . sessions_root
}
/// The workspace root this store is bound to.
#[ must_use ]
pub fn workspace_root ( & self ) -> & Path {
& self . workspace_root
}
2026-04-11 17:24:44 +00:00
#[ must_use ]
2026-04-07 15:55:38 +09:00
pub fn create_handle ( & self , session_id : & str ) -> SessionHandle {
let id = session_id . to_string ( ) ;
let path = self
. sessions_root
. join ( format! ( " {id} . {PRIMARY_SESSION_EXTENSION} " ) ) ;
SessionHandle { id , path }
}
pub fn resolve_reference ( & self , reference : & str ) -> Result < SessionHandle , SessionControlError > {
if is_session_reference_alias ( reference ) {
let latest = self . latest_session ( ) ? ;
return Ok ( SessionHandle {
id : latest . id ,
path : latest . path ,
} ) ;
}
let direct = PathBuf ::from ( reference ) ;
let candidate = if direct . is_absolute ( ) {
direct . clone ( )
} else {
self . workspace_root . join ( & direct )
} ;
let looks_like_path = direct . extension ( ) . is_some ( ) | | direct . components ( ) . count ( ) > 1 ;
let path = if candidate . exists ( ) {
candidate
} else if looks_like_path {
return Err ( SessionControlError ::Format (
2026-04-21 22:18:12 +09:00
format_missing_session_reference ( reference , & self . sessions_root ) ,
2026-04-07 15:55:38 +09:00
) ) ;
} else {
self . resolve_managed_path ( reference ) ?
} ;
Ok ( SessionHandle {
id : session_id_from_path ( & path ) . unwrap_or_else ( | | reference . to_string ( ) ) ,
path ,
} )
}
pub fn resolve_managed_path ( & self , session_id : & str ) -> Result < PathBuf , SessionControlError > {
for extension in [ PRIMARY_SESSION_EXTENSION , LEGACY_SESSION_EXTENSION ] {
let path = self . sessions_root . join ( format! ( " {session_id} . {extension} " ) ) ;
if path . exists ( ) {
return Ok ( path ) ;
}
}
2026-04-11 16:08:01 +00:00
if let Some ( legacy_root ) = self . legacy_sessions_root ( ) {
for extension in [ PRIMARY_SESSION_EXTENSION , LEGACY_SESSION_EXTENSION ] {
let path = legacy_root . join ( format! ( " {session_id} . {extension} " ) ) ;
if ! path . exists ( ) {
continue ;
}
let session = Session ::load_from_path ( & path ) ? ;
self . validate_loaded_session ( & path , & session ) ? ;
return Ok ( path ) ;
}
}
2026-04-07 15:55:38 +09:00
Err ( SessionControlError ::Format (
2026-04-21 22:18:12 +09:00
format_missing_session_reference ( session_id , & self . sessions_root ) ,
2026-04-07 15:55:38 +09:00
) )
}
pub fn list_sessions ( & self ) -> Result < Vec < ManagedSessionSummary > , SessionControlError > {
let mut sessions = Vec ::new ( ) ;
2026-04-11 16:08:01 +00:00
self . collect_sessions_from_dir ( & self . sessions_root , & mut sessions ) ? ;
if let Some ( legacy_root ) = self . legacy_sessions_root ( ) {
self . collect_sessions_from_dir ( & legacy_root , & mut sessions ) ? ;
2026-04-07 15:55:38 +09:00
}
2026-04-12 07:49:32 +00:00
sort_managed_sessions ( & mut sessions ) ;
2026-04-07 15:55:38 +09:00
Ok ( sessions )
}
pub fn latest_session ( & self ) -> Result < ManagedSessionSummary , SessionControlError > {
self . list_sessions ( ) ?
. into_iter ( )
. next ( )
2026-04-21 22:18:12 +09:00
. ok_or_else ( | | SessionControlError ::Format ( format_no_managed_sessions ( & self . sessions_root ) ) )
2026-04-07 15:55:38 +09:00
}
pub fn load_session (
& self ,
reference : & str ,
) -> Result < LoadedManagedSession , SessionControlError > {
let handle = self . resolve_reference ( reference ) ? ;
let session = Session ::load_from_path ( & handle . path ) ? ;
2026-04-11 16:08:01 +00:00
self . validate_loaded_session ( & handle . path , & session ) ? ;
2026-04-07 15:55:38 +09:00
Ok ( LoadedManagedSession {
handle : SessionHandle {
id : session . session_id . clone ( ) ,
path : handle . path ,
} ,
session ,
} )
}
pub fn fork_session (
& self ,
session : & Session ,
branch_name : Option < String > ,
) -> Result < ForkedManagedSession , SessionControlError > {
let parent_session_id = session . session_id . clone ( ) ;
2026-04-11 16:08:01 +00:00
let forked = session
. fork ( branch_name )
. with_workspace_root ( self . workspace_root . clone ( ) ) ;
2026-04-07 15:55:38 +09:00
let handle = self . create_handle ( & forked . session_id ) ;
let branch_name = forked
. fork
. as_ref ( )
. and_then ( | fork | fork . branch_name . clone ( ) ) ;
let forked = forked . with_persistence_path ( handle . path . clone ( ) ) ;
forked . save_to_path ( & handle . path ) ? ;
Ok ( ForkedManagedSession {
parent_session_id ,
handle ,
session : forked ,
branch_name ,
} )
}
2026-04-11 16:08:01 +00:00
fn legacy_sessions_root ( & self ) -> Option < PathBuf > {
self . sessions_root
. parent ( )
. filter ( | parent | parent . file_name ( ) . is_some_and ( | name | name = = " sessions " ) )
. map ( Path ::to_path_buf )
}
fn validate_loaded_session (
& self ,
session_path : & Path ,
session : & Session ,
) -> Result < ( ) , SessionControlError > {
let Some ( actual ) = session . workspace_root ( ) else {
if path_is_within_workspace ( session_path , & self . workspace_root ) {
return Ok ( ( ) ) ;
}
return Err ( SessionControlError ::Format (
format_legacy_session_missing_workspace_root ( session_path , & self . workspace_root ) ,
) ) ;
} ;
if workspace_roots_match ( actual , & self . workspace_root ) {
return Ok ( ( ) ) ;
}
Err ( SessionControlError ::WorkspaceMismatch {
expected : self . workspace_root . clone ( ) ,
actual : actual . to_path_buf ( ) ,
} )
}
fn collect_sessions_from_dir (
& self ,
directory : & Path ,
sessions : & mut Vec < ManagedSessionSummary > ,
) -> Result < ( ) , SessionControlError > {
let entries = match fs ::read_dir ( directory ) {
Ok ( entries ) = > entries ,
Err ( err ) if err . kind ( ) = = std ::io ::ErrorKind ::NotFound = > return Ok ( ( ) ) ,
Err ( err ) = > return Err ( err . into ( ) ) ,
} ;
for entry in entries {
let entry = entry ? ;
let path = entry . path ( ) ;
if ! is_managed_session_file ( & path ) {
continue ;
}
let metadata = entry . metadata ( ) ? ;
let modified_epoch_millis = metadata
. modified ( )
. ok ( )
. and_then ( | time | time . duration_since ( UNIX_EPOCH ) . ok ( ) )
. map ( | duration | duration . as_millis ( ) )
. unwrap_or_default ( ) ;
let summary = match Session ::load_from_path ( & path ) {
Ok ( session ) = > {
if self . validate_loaded_session ( & path , & session ) . is_err ( ) {
continue ;
}
ManagedSessionSummary {
id : session . session_id ,
path ,
2026-04-12 07:49:32 +00:00
updated_at_ms : session . updated_at_ms ,
2026-04-11 16:08:01 +00:00
modified_epoch_millis ,
message_count : session . messages . len ( ) ,
parent_session_id : session
. fork
. as_ref ( )
. map ( | fork | fork . parent_session_id . clone ( ) ) ,
branch_name : session
. fork
. as_ref ( )
. and_then ( | fork | fork . branch_name . clone ( ) ) ,
}
}
Err ( _ ) = > ManagedSessionSummary {
id : path
. file_stem ( )
. and_then ( | value | value . to_str ( ) )
. unwrap_or ( " unknown " )
. to_string ( ) ,
path ,
2026-04-12 07:49:32 +00:00
updated_at_ms : 0 ,
2026-04-11 16:08:01 +00:00
modified_epoch_millis ,
message_count : 0 ,
parent_session_id : None ,
branch_name : None ,
} ,
} ;
sessions . push ( summary ) ;
}
Ok ( ( ) )
}
2026-04-07 15:55:38 +09:00
}
/// Stable hex fingerprint of a workspace path.
///
/// Uses FNV-1a (64-bit) to produce a 16-char hex string that partitions the
/// on-disk session directory per workspace root.
#[ must_use ]
pub fn workspace_fingerprint ( workspace_root : & Path ) -> String {
let input = workspace_root . to_string_lossy ( ) ;
let mut hash = 0xcbf2_9ce4_8422_2325_ u64 ;
for byte in input . as_bytes ( ) {
hash ^ = u64 ::from ( * byte ) ;
hash = hash . wrapping_mul ( 0x0100_0000_01b3 ) ;
}
format! ( " {hash:016x} " )
}
2026-04-04 00:43:30 +09:00
pub const PRIMARY_SESSION_EXTENSION : & str = " jsonl " ;
pub const LEGACY_SESSION_EXTENSION : & str = " json " ;
pub const LATEST_SESSION_REFERENCE : & str = " latest " ;
const SESSION_REFERENCE_ALIASES : & [ & str ] = & [ LATEST_SESSION_REFERENCE , " last " , " recent " ] ;
#[ derive(Debug, Clone, PartialEq, Eq) ]
pub struct SessionHandle {
pub id : String ,
pub path : PathBuf ,
}
#[ derive(Debug, Clone, PartialEq, Eq) ]
pub struct ManagedSessionSummary {
pub id : String ,
pub path : PathBuf ,
2026-04-12 07:49:32 +00:00
pub updated_at_ms : u64 ,
2026-04-04 00:43:30 +09:00
pub modified_epoch_millis : u128 ,
pub message_count : usize ,
pub parent_session_id : Option < String > ,
pub branch_name : Option < String > ,
}
2026-04-12 07:49:32 +00:00
fn sort_managed_sessions ( sessions : & mut [ ManagedSessionSummary ] ) {
sessions . sort_by ( | left , right | {
right
. updated_at_ms
. cmp ( & left . updated_at_ms )
. then_with ( | | right . modified_epoch_millis . cmp ( & left . modified_epoch_millis ) )
. then_with ( | | right . id . cmp ( & left . id ) )
} ) ;
}
2026-04-04 00:43:30 +09:00
#[ derive(Debug, Clone, PartialEq, Eq) ]
pub struct LoadedManagedSession {
pub handle : SessionHandle ,
pub session : Session ,
}
#[ derive(Debug, Clone, PartialEq, Eq) ]
pub struct ForkedManagedSession {
pub parent_session_id : String ,
pub handle : SessionHandle ,
pub session : Session ,
pub branch_name : Option < String > ,
}
#[ derive(Debug) ]
pub enum SessionControlError {
Io ( std ::io ::Error ) ,
Session ( SessionError ) ,
Format ( String ) ,
2026-04-11 16:08:01 +00:00
WorkspaceMismatch { expected : PathBuf , actual : PathBuf } ,
2026-04-04 00:43:30 +09:00
}
impl Display for SessionControlError {
fn fmt ( & self , f : & mut Formatter < '_ > ) -> std ::fmt ::Result {
match self {
Self ::Io ( error ) = > write! ( f , " {error} " ) ,
Self ::Session ( error ) = > write! ( f , " {error} " ) ,
Self ::Format ( error ) = > write! ( f , " {error} " ) ,
2026-04-11 16:08:01 +00:00
Self ::WorkspaceMismatch { expected , actual } = > write! (
f ,
" session workspace mismatch: expected {}, found {} " ,
expected . display ( ) ,
actual . display ( )
) ,
2026-04-04 00:43:30 +09:00
}
}
}
impl std ::error ::Error for SessionControlError { }
impl From < std ::io ::Error > for SessionControlError {
fn from ( value : std ::io ::Error ) -> Self {
Self ::Io ( value )
}
}
impl From < SessionError > for SessionControlError {
fn from ( value : SessionError ) -> Self {
Self ::Session ( value )
}
}
pub fn sessions_dir ( ) -> Result < PathBuf , SessionControlError > {
managed_sessions_dir_for ( env ::current_dir ( ) ? )
}
pub fn managed_sessions_dir_for (
base_dir : impl AsRef < Path > ,
) -> Result < PathBuf , SessionControlError > {
2026-04-11 16:08:01 +00:00
let store = SessionStore ::from_cwd ( base_dir ) ? ;
Ok ( store . sessions_dir ( ) . to_path_buf ( ) )
2026-04-04 00:43:30 +09:00
}
pub fn create_managed_session_handle (
session_id : & str ,
) -> Result < SessionHandle , SessionControlError > {
create_managed_session_handle_for ( env ::current_dir ( ) ? , session_id )
}
pub fn create_managed_session_handle_for (
base_dir : impl AsRef < Path > ,
session_id : & str ,
) -> Result < SessionHandle , SessionControlError > {
2026-04-11 16:08:01 +00:00
let store = SessionStore ::from_cwd ( base_dir ) ? ;
Ok ( store . create_handle ( session_id ) )
2026-04-04 00:43:30 +09:00
}
pub fn resolve_session_reference ( reference : & str ) -> Result < SessionHandle , SessionControlError > {
resolve_session_reference_for ( env ::current_dir ( ) ? , reference )
}
pub fn resolve_session_reference_for (
base_dir : impl AsRef < Path > ,
reference : & str ,
) -> Result < SessionHandle , SessionControlError > {
2026-04-11 16:08:01 +00:00
let store = SessionStore ::from_cwd ( base_dir ) ? ;
store . resolve_reference ( reference )
2026-04-04 00:43:30 +09:00
}
pub fn resolve_managed_session_path ( session_id : & str ) -> Result < PathBuf , SessionControlError > {
resolve_managed_session_path_for ( env ::current_dir ( ) ? , session_id )
}
pub fn resolve_managed_session_path_for (
base_dir : impl AsRef < Path > ,
session_id : & str ,
) -> Result < PathBuf , SessionControlError > {
2026-04-11 16:08:01 +00:00
let store = SessionStore ::from_cwd ( base_dir ) ? ;
store . resolve_managed_path ( session_id )
2026-04-04 00:43:30 +09:00
}
#[ must_use ]
pub fn is_managed_session_file ( path : & Path ) -> bool {
path . extension ( )
. and_then ( | ext | ext . to_str ( ) )
. is_some_and ( | extension | {
extension = = PRIMARY_SESSION_EXTENSION | | extension = = LEGACY_SESSION_EXTENSION
} )
}
pub fn list_managed_sessions ( ) -> Result < Vec < ManagedSessionSummary > , SessionControlError > {
list_managed_sessions_for ( env ::current_dir ( ) ? )
}
pub fn list_managed_sessions_for (
base_dir : impl AsRef < Path > ,
) -> Result < Vec < ManagedSessionSummary > , SessionControlError > {
2026-04-11 16:08:01 +00:00
let store = SessionStore ::from_cwd ( base_dir ) ? ;
store . list_sessions ( )
2026-04-04 00:43:30 +09:00
}
pub fn latest_managed_session ( ) -> Result < ManagedSessionSummary , SessionControlError > {
latest_managed_session_for ( env ::current_dir ( ) ? )
}
pub fn latest_managed_session_for (
base_dir : impl AsRef < Path > ,
) -> Result < ManagedSessionSummary , SessionControlError > {
2026-04-11 16:08:01 +00:00
let store = SessionStore ::from_cwd ( base_dir ) ? ;
store . latest_session ( )
2026-04-04 00:43:30 +09:00
}
pub fn load_managed_session ( reference : & str ) -> Result < LoadedManagedSession , SessionControlError > {
load_managed_session_for ( env ::current_dir ( ) ? , reference )
}
pub fn load_managed_session_for (
base_dir : impl AsRef < Path > ,
reference : & str ,
) -> Result < LoadedManagedSession , SessionControlError > {
2026-04-11 16:08:01 +00:00
let store = SessionStore ::from_cwd ( base_dir ) ? ;
store . load_session ( reference )
2026-04-04 00:43:30 +09:00
}
pub fn fork_managed_session (
session : & Session ,
branch_name : Option < String > ,
) -> Result < ForkedManagedSession , SessionControlError > {
fork_managed_session_for ( env ::current_dir ( ) ? , session , branch_name )
}
pub fn fork_managed_session_for (
base_dir : impl AsRef < Path > ,
session : & Session ,
branch_name : Option < String > ,
) -> Result < ForkedManagedSession , SessionControlError > {
2026-04-11 16:08:01 +00:00
let store = SessionStore ::from_cwd ( base_dir ) ? ;
store . fork_session ( session , branch_name )
2026-04-04 00:43:30 +09:00
}
#[ must_use ]
pub fn is_session_reference_alias ( reference : & str ) -> bool {
SESSION_REFERENCE_ALIASES
. iter ( )
. any ( | alias | reference . eq_ignore_ascii_case ( alias ) )
}
fn session_id_from_path ( path : & Path ) -> Option < String > {
path . file_name ( )
. and_then ( | value | value . to_str ( ) )
. and_then ( | name | {
name . strip_suffix ( & format! ( " . {PRIMARY_SESSION_EXTENSION} " ) )
. or_else ( | | name . strip_suffix ( & format! ( " . {LEGACY_SESSION_EXTENSION} " ) ) )
} )
. map ( ToOwned ::to_owned )
}
2026-04-21 22:18:12 +09:00
fn format_missing_session_reference ( reference : & str , sessions_root : & Path ) -> String {
// #80: show the actual workspace-fingerprint directory instead of lying about .claw/sessions/
let fingerprint_dir = sessions_root
. file_name ( )
. and_then ( | f | f . to_str ( ) )
. unwrap_or ( " <unknown> " ) ;
2026-04-04 00:43:30 +09:00
format! (
2026-04-21 22:18:12 +09:00
" session not found: {reference} \n Hint: managed sessions live in .claw/sessions/{fingerprint_dir}/ (workspace-specific partition). \n Try `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL. "
2026-04-04 00:43:30 +09:00
)
}
2026-04-21 22:18:12 +09:00
fn format_no_managed_sessions ( sessions_root : & Path ) -> String {
// #80: show the actual workspace-fingerprint directory instead of lying about .claw/sessions/
let fingerprint_dir = sessions_root
. file_name ( )
. and_then ( | f | f . to_str ( ) )
. unwrap_or ( " <unknown> " ) ;
2026-04-04 00:43:30 +09:00
format! (
2026-04-21 22:18:12 +09:00
" no managed sessions found in .claw/sessions/{fingerprint_dir}/ \n Start `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`. \n Note: claw partitions sessions per workspace fingerprint; sessions from other CWDs are invisible. "
2026-04-04 00:43:30 +09:00
)
}
2026-04-11 16:08:01 +00:00
fn format_legacy_session_missing_workspace_root (
session_path : & Path ,
workspace_root : & Path ,
) -> String {
format! (
" legacy session is missing workspace binding: {} \n Open it from its original workspace or re-save it from {}. " ,
session_path . display ( ) ,
workspace_root . display ( )
)
}
fn workspace_roots_match ( left : & Path , right : & Path ) -> bool {
canonicalize_for_compare ( left ) = = canonicalize_for_compare ( right )
}
fn canonicalize_for_compare ( path : & Path ) -> PathBuf {
fs ::canonicalize ( path ) . unwrap_or_else ( | _ | path . to_path_buf ( ) )
}
fn path_is_within_workspace ( path : & Path , workspace_root : & Path ) -> bool {
canonicalize_for_compare ( path ) . starts_with ( canonicalize_for_compare ( workspace_root ) )
}
2026-04-04 00:43:30 +09:00
#[ cfg(test) ]
mod tests {
use super ::{
create_managed_session_handle_for , fork_managed_session_for , is_session_reference_alias ,
list_managed_sessions_for , load_managed_session_for , resolve_session_reference_for ,
2026-04-11 16:08:01 +00:00
workspace_fingerprint , ManagedSessionSummary , SessionControlError , SessionStore ,
LATEST_SESSION_REFERENCE ,
2026-04-04 00:43:30 +09:00
} ;
use crate ::session ::Session ;
use std ::fs ;
use std ::path ::{ Path , PathBuf } ;
use std ::time ::{ SystemTime , UNIX_EPOCH } ;
fn temp_dir ( ) -> PathBuf {
let nanos = SystemTime ::now ( )
. duration_since ( UNIX_EPOCH )
. expect ( " time should be after epoch " )
. as_nanos ( ) ;
std ::env ::temp_dir ( ) . join ( format! ( " runtime-session-control- {nanos} " ) )
}
fn persist_session ( root : & Path , text : & str ) -> Session {
2026-04-11 16:08:01 +00:00
let mut session = Session ::new ( ) . with_workspace_root ( root . to_path_buf ( ) ) ;
2026-04-04 00:43:30 +09:00
session
. push_user_text ( text )
. expect ( " session message should save " ) ;
let handle = create_managed_session_handle_for ( root , & session . session_id )
. expect ( " managed session handle should build " ) ;
let session = session . with_persistence_path ( handle . path . clone ( ) ) ;
session
. save_to_path ( & handle . path )
. expect ( " session should persist " ) ;
session
}
fn wait_for_next_millisecond ( ) {
let start = SystemTime ::now ( )
. duration_since ( UNIX_EPOCH )
. expect ( " time should be after epoch " )
. as_millis ( ) ;
while SystemTime ::now ( )
. duration_since ( UNIX_EPOCH )
. expect ( " time should be after epoch " )
. as_millis ( )
< = start
{ }
}
fn summary_by_id < ' a > (
summaries : & ' a [ ManagedSessionSummary ] ,
id : & str ,
) -> & ' a ManagedSessionSummary {
summaries
. iter ( )
. find ( | summary | summary . id = = id )
. expect ( " session summary should exist " )
}
2026-04-12 07:49:32 +00:00
#[ test ]
fn latest_session_prefers_semantic_updated_at_over_file_mtime ( ) {
let mut sessions = vec! [
ManagedSessionSummary {
id : " older-file-newer-session " . to_string ( ) ,
path : PathBuf ::from ( " /tmp/older " ) ,
updated_at_ms : 200 ,
modified_epoch_millis : 100 ,
message_count : 2 ,
parent_session_id : None ,
branch_name : None ,
} ,
ManagedSessionSummary {
id : " newer-file-older-session " . to_string ( ) ,
path : PathBuf ::from ( " /tmp/newer " ) ,
updated_at_ms : 100 ,
modified_epoch_millis : 200 ,
message_count : 1 ,
parent_session_id : None ,
branch_name : None ,
} ,
] ;
crate ::session_control ::sort_managed_sessions ( & mut sessions ) ;
assert_eq! ( sessions [ 0 ] . id , " older-file-newer-session " ) ;
assert_eq! ( sessions [ 1 ] . id , " newer-file-older-session " ) ;
}
2026-04-04 00:43:30 +09:00
#[ test ]
fn creates_and_lists_managed_sessions ( ) {
// given
let root = temp_dir ( ) ;
fs ::create_dir_all ( & root ) . expect ( " root dir should exist " ) ;
let older = persist_session ( & root , " older session " ) ;
wait_for_next_millisecond ( ) ;
let newer = persist_session ( & root , " newer session " ) ;
// when
let sessions = list_managed_sessions_for ( & root ) . expect ( " managed sessions should list " ) ;
// then
assert_eq! ( sessions . len ( ) , 2 ) ;
assert_eq! ( sessions [ 0 ] . id , newer . session_id ) ;
assert_eq! ( summary_by_id ( & sessions , & older . session_id ) . message_count , 1 ) ;
assert_eq! ( summary_by_id ( & sessions , & newer . session_id ) . message_count , 1 ) ;
fs ::remove_dir_all ( root ) . expect ( " temp dir should clean up " ) ;
}
#[ test ]
fn resolves_latest_alias_and_loads_session_from_workspace_root ( ) {
// given
let root = temp_dir ( ) ;
fs ::create_dir_all ( & root ) . expect ( " root dir should exist " ) ;
let older = persist_session ( & root , " older session " ) ;
wait_for_next_millisecond ( ) ;
let newer = persist_session ( & root , " newer session " ) ;
// when
let handle = resolve_session_reference_for ( & root , LATEST_SESSION_REFERENCE )
. expect ( " latest alias should resolve " ) ;
let loaded = load_managed_session_for ( & root , " recent " )
. expect ( " recent alias should load the latest session " ) ;
// then
assert_eq! ( handle . id , newer . session_id ) ;
assert_eq! ( loaded . handle . id , newer . session_id ) ;
assert_eq! ( loaded . session . messages . len ( ) , 1 ) ;
assert_ne! ( loaded . handle . id , older . session_id ) ;
assert! ( is_session_reference_alias ( " last " ) ) ;
fs ::remove_dir_all ( root ) . expect ( " temp dir should clean up " ) ;
}
#[ test ]
fn forks_session_into_managed_storage_with_lineage ( ) {
// given
let root = temp_dir ( ) ;
fs ::create_dir_all ( & root ) . expect ( " root dir should exist " ) ;
let source = persist_session ( & root , " parent session " ) ;
// when
let forked = fork_managed_session_for ( & root , & source , Some ( " incident-review " . to_string ( ) ) )
. expect ( " session should fork " ) ;
let sessions = list_managed_sessions_for ( & root ) . expect ( " managed sessions should list " ) ;
let summary = summary_by_id ( & sessions , & forked . handle . id ) ;
// then
assert_eq! ( forked . parent_session_id , source . session_id ) ;
assert_eq! ( forked . branch_name . as_deref ( ) , Some ( " incident-review " ) ) ;
assert_eq! (
summary . parent_session_id . as_deref ( ) ,
Some ( source . session_id . as_str ( ) )
) ;
assert_eq! ( summary . branch_name . as_deref ( ) , Some ( " incident-review " ) ) ;
assert_eq! (
forked . session . persistence_path ( ) ,
Some ( forked . handle . path . as_path ( ) )
) ;
fs ::remove_dir_all ( root ) . expect ( " temp dir should clean up " ) ;
}
2026-04-07 15:55:38 +09:00
// ------------------------------------------------------------------
// Per-worktree session isolation (SessionStore) tests
// ------------------------------------------------------------------
fn persist_session_via_store ( store : & SessionStore , text : & str ) -> Session {
2026-04-11 16:08:01 +00:00
let mut session = Session ::new ( ) . with_workspace_root ( store . workspace_root ( ) . to_path_buf ( ) ) ;
2026-04-07 15:55:38 +09:00
session
. push_user_text ( text )
. expect ( " session message should save " ) ;
let handle = store . create_handle ( & session . session_id ) ;
let session = session . with_persistence_path ( handle . path . clone ( ) ) ;
session
. save_to_path ( & handle . path )
. expect ( " session should persist " ) ;
session
}
#[ test ]
fn workspace_fingerprint_is_deterministic_and_differs_per_path ( ) {
// given
let path_a = Path ::new ( " /tmp/worktree-alpha " ) ;
let path_b = Path ::new ( " /tmp/worktree-beta " ) ;
// when
let fp_a1 = workspace_fingerprint ( path_a ) ;
let fp_a2 = workspace_fingerprint ( path_a ) ;
let fp_b = workspace_fingerprint ( path_b ) ;
// then
assert_eq! ( fp_a1 , fp_a2 , " same path must produce the same fingerprint " ) ;
assert_ne! (
fp_a1 , fp_b ,
" different paths must produce different fingerprints "
) ;
assert_eq! ( fp_a1 . len ( ) , 16 , " fingerprint must be a 16-char hex string " ) ;
}
2026-04-21 21:06:09 +09:00
/// #151 regression: equivalent paths (e.g. `/tmp/foo` vs `/private/tmp/foo`
/// on macOS where `/tmp` is a symlink to `/private/tmp`) must resolve to
/// the same session store. Previously they diverged because
/// `workspace_fingerprint()` hashed the raw path string. Now
/// `SessionStore::from_cwd()` canonicalizes first.
#[ test ]
fn session_store_from_cwd_canonicalizes_equivalent_paths ( ) {
let base = temp_dir ( ) ;
let real_dir = base . join ( " real-workspace " ) ;
fs ::create_dir_all ( & real_dir ) . expect ( " real workspace should exist " ) ;
// Build two stores via different but equivalent path representations:
// the raw path and the canonicalized path.
let raw_path = real_dir . clone ( ) ;
let canonical_path = fs ::canonicalize ( & real_dir ) . expect ( " canonicalize ok " ) ;
let store_from_raw =
SessionStore ::from_cwd ( & raw_path ) . expect ( " store from raw should build " ) ;
let store_from_canonical =
SessionStore ::from_cwd ( & canonical_path ) . expect ( " store from canonical should build " ) ;
assert_eq! (
store_from_raw . sessions_dir ( ) ,
store_from_canonical . sessions_dir ( ) ,
" equivalent paths must produce the same sessions dir (raw={} canonical={}) " ,
raw_path . display ( ) ,
canonical_path . display ( )
) ;
if base . exists ( ) {
fs ::remove_dir_all ( base ) . expect ( " cleanup ok " ) ;
}
}
2026-04-07 15:55:38 +09:00
#[ test ]
fn session_store_from_cwd_isolates_sessions_by_workspace ( ) {
// given
let base = temp_dir ( ) ;
let workspace_a = base . join ( " repo-alpha " ) ;
let workspace_b = base . join ( " repo-beta " ) ;
fs ::create_dir_all ( & workspace_a ) . expect ( " workspace a should exist " ) ;
fs ::create_dir_all ( & workspace_b ) . expect ( " workspace b should exist " ) ;
let store_a = SessionStore ::from_cwd ( & workspace_a ) . expect ( " store a should build " ) ;
let store_b = SessionStore ::from_cwd ( & workspace_b ) . expect ( " store b should build " ) ;
// when
let session_a = persist_session_via_store ( & store_a , " alpha work " ) ;
let _session_b = persist_session_via_store ( & store_b , " beta work " ) ;
// then — each store only sees its own sessions
let list_a = store_a . list_sessions ( ) . expect ( " list a " ) ;
let list_b = store_b . list_sessions ( ) . expect ( " list b " ) ;
assert_eq! ( list_a . len ( ) , 1 , " store a should see exactly one session " ) ;
assert_eq! ( list_b . len ( ) , 1 , " store b should see exactly one session " ) ;
assert_eq! ( list_a [ 0 ] . id , session_a . session_id ) ;
assert_ne! (
store_a . sessions_dir ( ) ,
store_b . sessions_dir ( ) ,
" session directories must differ across workspaces "
) ;
fs ::remove_dir_all ( base ) . expect ( " temp dir should clean up " ) ;
}
#[ test ]
fn session_store_from_data_dir_namespaces_by_workspace ( ) {
// given
let base = temp_dir ( ) ;
let data_dir = base . join ( " global-data " ) ;
let workspace_a = PathBuf ::from ( " /tmp/project-one " ) ;
let workspace_b = PathBuf ::from ( " /tmp/project-two " ) ;
fs ::create_dir_all ( & data_dir ) . expect ( " data dir should exist " ) ;
let store_a =
SessionStore ::from_data_dir ( & data_dir , & workspace_a ) . expect ( " store a should build " ) ;
let store_b =
SessionStore ::from_data_dir ( & data_dir , & workspace_b ) . expect ( " store b should build " ) ;
// when
persist_session_via_store ( & store_a , " work in project-one " ) ;
persist_session_via_store ( & store_b , " work in project-two " ) ;
// then
assert_ne! (
store_a . sessions_dir ( ) ,
store_b . sessions_dir ( ) ,
" data-dir stores must namespace by workspace "
) ;
assert_eq! ( store_a . list_sessions ( ) . expect ( " list a " ) . len ( ) , 1 ) ;
assert_eq! ( store_b . list_sessions ( ) . expect ( " list b " ) . len ( ) , 1 ) ;
assert_eq! ( store_a . workspace_root ( ) , workspace_a . as_path ( ) ) ;
assert_eq! ( store_b . workspace_root ( ) , workspace_b . as_path ( ) ) ;
fs ::remove_dir_all ( base ) . expect ( " temp dir should clean up " ) ;
}
#[ test ]
fn session_store_create_and_load_round_trip ( ) {
// given
let base = temp_dir ( ) ;
fs ::create_dir_all ( & base ) . expect ( " base dir should exist " ) ;
let store = SessionStore ::from_cwd ( & base ) . expect ( " store should build " ) ;
let session = persist_session_via_store ( & store , " round-trip message " ) ;
// when
let loaded = store
. load_session ( & session . session_id )
. expect ( " session should load via store " ) ;
// then
assert_eq! ( loaded . handle . id , session . session_id ) ;
assert_eq! ( loaded . session . messages . len ( ) , 1 ) ;
fs ::remove_dir_all ( base ) . expect ( " temp dir should clean up " ) ;
}
2026-04-11 16:08:01 +00:00
#[ test ]
fn session_store_rejects_legacy_session_from_other_workspace ( ) {
// given
let base = temp_dir ( ) ;
let workspace_a = base . join ( " repo-alpha " ) ;
let workspace_b = base . join ( " repo-beta " ) ;
fs ::create_dir_all ( & workspace_a ) . expect ( " workspace a should exist " ) ;
fs ::create_dir_all ( & workspace_b ) . expect ( " workspace b should exist " ) ;
2026-04-21 21:06:09 +09:00
// #151: canonicalize so test expectations match the store's canonical
// workspace_root. Without this, the test builds sessions with a raw
// path but the store resolves to the canonical form.
let workspace_a = fs ::canonicalize ( & workspace_a ) . unwrap_or ( workspace_a ) ;
let workspace_b = fs ::canonicalize ( & workspace_b ) . unwrap_or ( workspace_b ) ;
2026-04-11 16:08:01 +00:00
let store_b = SessionStore ::from_cwd ( & workspace_b ) . expect ( " store b should build " ) ;
let legacy_root = workspace_b . join ( " .claw " ) . join ( " sessions " ) ;
fs ::create_dir_all ( & legacy_root ) . expect ( " legacy root should exist " ) ;
let legacy_path = legacy_root . join ( " legacy-cross.jsonl " ) ;
let session = Session ::new ( )
. with_workspace_root ( workspace_a . clone ( ) )
. with_persistence_path ( legacy_path . clone ( ) ) ;
session
. save_to_path ( & legacy_path )
. expect ( " legacy session should persist " ) ;
// when
let err = store_b
. load_session ( " legacy-cross " )
. expect_err ( " workspace mismatch should be rejected " ) ;
// then
match err {
SessionControlError ::WorkspaceMismatch { expected , actual } = > {
assert_eq! ( expected , workspace_b ) ;
assert_eq! ( actual , workspace_a ) ;
}
other = > panic! ( " expected workspace mismatch, got {other:?} " ) ,
}
fs ::remove_dir_all ( base ) . expect ( " temp dir should clean up " ) ;
}
#[ test ]
fn session_store_loads_safe_legacy_session_from_same_workspace ( ) {
// given
let base = temp_dir ( ) ;
fs ::create_dir_all ( & base ) . expect ( " base dir should exist " ) ;
2026-04-21 21:06:09 +09:00
// #151: canonicalize for path-representation consistency with store.
let base = fs ::canonicalize ( & base ) . unwrap_or ( base ) ;
2026-04-11 16:08:01 +00:00
let store = SessionStore ::from_cwd ( & base ) . expect ( " store should build " ) ;
let legacy_root = base . join ( " .claw " ) . join ( " sessions " ) ;
let legacy_path = legacy_root . join ( " legacy-safe.jsonl " ) ;
fs ::create_dir_all ( & legacy_root ) . expect ( " legacy root should exist " ) ;
let session = Session ::new ( )
. with_workspace_root ( base . clone ( ) )
. with_persistence_path ( legacy_path . clone ( ) ) ;
session
. save_to_path ( & legacy_path )
. expect ( " legacy session should persist " ) ;
// when
let loaded = store
. load_session ( " legacy-safe " )
. expect ( " same-workspace legacy session should load " ) ;
// then
assert_eq! ( loaded . handle . id , session . session_id ) ;
assert_eq! ( loaded . handle . path , legacy_path ) ;
assert_eq! ( loaded . session . workspace_root ( ) , Some ( base . as_path ( ) ) ) ;
fs ::remove_dir_all ( base ) . expect ( " temp dir should clean up " ) ;
}
#[ test ]
fn session_store_loads_unbound_legacy_session_from_same_workspace ( ) {
// given
let base = temp_dir ( ) ;
fs ::create_dir_all ( & base ) . expect ( " base dir should exist " ) ;
2026-04-21 21:06:09 +09:00
// #151: canonicalize for path-representation consistency with store.
let base = fs ::canonicalize ( & base ) . unwrap_or ( base ) ;
2026-04-11 16:08:01 +00:00
let store = SessionStore ::from_cwd ( & base ) . expect ( " store should build " ) ;
let legacy_root = base . join ( " .claw " ) . join ( " sessions " ) ;
let legacy_path = legacy_root . join ( " legacy-unbound.json " ) ;
fs ::create_dir_all ( & legacy_root ) . expect ( " legacy root should exist " ) ;
let session = Session ::new ( ) . with_persistence_path ( legacy_path . clone ( ) ) ;
session
. save_to_path ( & legacy_path )
. expect ( " legacy session should persist " ) ;
// when
let loaded = store
. load_session ( " legacy-unbound " )
. expect ( " same-workspace legacy session without workspace binding should load " ) ;
// then
assert_eq! ( loaded . handle . path , legacy_path ) ;
assert_eq! ( loaded . session . workspace_root ( ) , None ) ;
fs ::remove_dir_all ( base ) . expect ( " temp dir should clean up " ) ;
}
2026-04-07 15:55:38 +09:00
#[ test ]
fn session_store_latest_and_resolve_reference ( ) {
// given
let base = temp_dir ( ) ;
fs ::create_dir_all ( & base ) . expect ( " base dir should exist " ) ;
let store = SessionStore ::from_cwd ( & base ) . expect ( " store should build " ) ;
let _older = persist_session_via_store ( & store , " older " ) ;
wait_for_next_millisecond ( ) ;
let newer = persist_session_via_store ( & store , " newer " ) ;
// when
let latest = store . latest_session ( ) . expect ( " latest should resolve " ) ;
let handle = store
. resolve_reference ( " latest " )
. expect ( " latest alias should resolve " ) ;
// then
assert_eq! ( latest . id , newer . session_id ) ;
assert_eq! ( handle . id , newer . session_id ) ;
fs ::remove_dir_all ( base ) . expect ( " temp dir should clean up " ) ;
}
#[ test ]
fn session_store_fork_stays_in_same_namespace ( ) {
// given
let base = temp_dir ( ) ;
fs ::create_dir_all ( & base ) . expect ( " base dir should exist " ) ;
let store = SessionStore ::from_cwd ( & base ) . expect ( " store should build " ) ;
let source = persist_session_via_store ( & store , " parent work " ) ;
// when
let forked = store
. fork_session ( & source , Some ( " bugfix " . to_string ( ) ) )
. expect ( " fork should succeed " ) ;
let sessions = store . list_sessions ( ) . expect ( " list sessions " ) ;
// then
assert_eq! (
sessions . len ( ) ,
2 ,
" forked session must land in the same namespace "
) ;
assert_eq! ( forked . parent_session_id , source . session_id ) ;
assert_eq! ( forked . branch_name . as_deref ( ) , Some ( " bugfix " ) ) ;
assert! (
forked . handle . path . starts_with ( store . sessions_dir ( ) ) ,
" forked session path must be inside the store namespace "
) ;
fs ::remove_dir_all ( base ) . expect ( " temp dir should clean up " ) ;
}
2026-04-04 00:43:30 +09:00
}