mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-12 19:14:51 +08:00
Prevent cross-worktree session bleed during managed session resume/load
ROADMAP #41 was still leaving a phantom-completion class open: managed sessions could be resumed from the wrong workspace, and the CLI/runtime paths were split between partially isolated storage and older helper flows. This squashes the verified team work into one deliverable that routes managed session operations through the per-worktree SessionStore, rejects workspace mismatches explicitly, extends lane-event taxonomy for workspace mismatch reporting, and updates the affected CLI regression fixtures/docs so the new contract is enforced without losing same- workspace legacy coverage. Constraint: Keep same-workspace legacy flat sessions readable while blocking cross-worktree misuse Constraint: No new dependencies; stay within the ROADMAP #41 changed-file scope Rejected: Leave team auto-checkpoint history as final branch state | noisy/non-lore history for a single roadmap fix Confidence: high Scope-risk: moderate Reversibility: clean Directive: Preserve workspace_root validation on future resume/load helpers; do not reintroduce path-only fallback without equivalent mismatch checks Tested: cargo test -p runtime session_control -- --nocapture; cargo test -p rusty-claude-cli resume -- --nocapture; cargo test -p rusty-claude-cli --test cli_flags_and_config_defaults; cargo test -p rusty-claude-cli --test output_format_contract; cargo test -p rusty-claude-cli --test resume_slash_commands; cargo test --workspace --exclude compat-harness; cargo check --workspace --all-targets; git diff --check Not-tested: cargo clippy --workspace --all-targets -- -D warnings (pre-existing failures in unchanged rust/crates/rusty-claude-cli/build.rs) Related: ROADMAP #41
This commit is contained in:
@@ -36,6 +36,8 @@ pub enum LaneEventName {
|
||||
Closed,
|
||||
#[serde(rename = "branch.stale_against_main")]
|
||||
BranchStaleAgainstMain,
|
||||
#[serde(rename = "branch.workspace_mismatch")]
|
||||
BranchWorkspaceMismatch,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -67,6 +69,7 @@ pub enum LaneFailureClass {
|
||||
McpHandshake,
|
||||
GatewayRouting,
|
||||
ToolRuntime,
|
||||
WorkspaceMismatch,
|
||||
Infra,
|
||||
}
|
||||
|
||||
@@ -277,6 +280,10 @@ mod tests {
|
||||
LaneEventName::BranchStaleAgainstMain,
|
||||
"branch.stale_against_main",
|
||||
),
|
||||
(
|
||||
LaneEventName::BranchWorkspaceMismatch,
|
||||
"branch.workspace_mismatch",
|
||||
),
|
||||
];
|
||||
|
||||
for (event, expected) in cases {
|
||||
@@ -300,6 +307,7 @@ mod tests {
|
||||
(LaneFailureClass::McpHandshake, "mcp_handshake"),
|
||||
(LaneFailureClass::GatewayRouting, "gateway_routing"),
|
||||
(LaneFailureClass::ToolRuntime, "tool_runtime"),
|
||||
(LaneFailureClass::WorkspaceMismatch, "workspace_mismatch"),
|
||||
(LaneFailureClass::Infra, "infra"),
|
||||
];
|
||||
|
||||
@@ -329,6 +337,38 @@ mod tests {
|
||||
assert_eq!(failed.detail.as_deref(), Some("broken server"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_mismatch_failure_class_round_trips_in_branch_event_payloads() {
|
||||
let mismatch = LaneEvent::new(
|
||||
LaneEventName::BranchWorkspaceMismatch,
|
||||
LaneEventStatus::Blocked,
|
||||
"2026-04-04T00:00:02Z",
|
||||
)
|
||||
.with_failure_class(LaneFailureClass::WorkspaceMismatch)
|
||||
.with_detail("session belongs to /tmp/repo-a but current workspace is /tmp/repo-b")
|
||||
.with_data(json!({
|
||||
"expectedWorkspaceRoot": "/tmp/repo-a",
|
||||
"actualWorkspaceRoot": "/tmp/repo-b",
|
||||
"sessionId": "sess-123",
|
||||
}));
|
||||
|
||||
let mismatch_json = serde_json::to_value(&mismatch).expect("lane event should serialize");
|
||||
assert_eq!(mismatch_json["event"], "branch.workspace_mismatch");
|
||||
assert_eq!(mismatch_json["failureClass"], "workspace_mismatch");
|
||||
assert_eq!(
|
||||
mismatch_json["data"]["expectedWorkspaceRoot"],
|
||||
"/tmp/repo-a"
|
||||
);
|
||||
|
||||
let round_trip: LaneEvent =
|
||||
serde_json::from_value(mismatch_json).expect("lane event should deserialize");
|
||||
assert_eq!(round_trip.event, LaneEventName::BranchWorkspaceMismatch);
|
||||
assert_eq!(
|
||||
round_trip.failure_class,
|
||||
Some(LaneFailureClass::WorkspaceMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_events_can_carry_worktree_and_supersession_metadata() {
|
||||
let event = LaneEvent::commit_created(
|
||||
|
||||
@@ -121,6 +121,17 @@ impl SessionStore {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
Err(SessionControlError::Format(
|
||||
format_missing_session_reference(session_id),
|
||||
))
|
||||
@@ -128,61 +139,9 @@ impl SessionStore {
|
||||
|
||||
pub fn list_sessions(&self) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
|
||||
let mut sessions = Vec::new();
|
||||
let read_result = fs::read_dir(&self.sessions_root);
|
||||
let entries = match read_result {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(sessions),
|
||||
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 (id, message_count, parent_session_id, branch_name) =
|
||||
match Session::load_from_path(&path) {
|
||||
Ok(session) => {
|
||||
let parent_session_id = session
|
||||
.fork
|
||||
.as_ref()
|
||||
.map(|fork| fork.parent_session_id.clone());
|
||||
let branch_name = session
|
||||
.fork
|
||||
.as_ref()
|
||||
.and_then(|fork| fork.branch_name.clone());
|
||||
(
|
||||
session.session_id,
|
||||
session.messages.len(),
|
||||
parent_session_id,
|
||||
branch_name,
|
||||
)
|
||||
}
|
||||
Err(_) => (
|
||||
path.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
};
|
||||
sessions.push(ManagedSessionSummary {
|
||||
id,
|
||||
path,
|
||||
modified_epoch_millis,
|
||||
message_count,
|
||||
parent_session_id,
|
||||
branch_name,
|
||||
});
|
||||
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)?;
|
||||
}
|
||||
sessions.sort_by(|left, right| {
|
||||
right
|
||||
@@ -206,6 +165,7 @@ impl SessionStore {
|
||||
) -> Result<LoadedManagedSession, SessionControlError> {
|
||||
let handle = self.resolve_reference(reference)?;
|
||||
let session = Session::load_from_path(&handle.path)?;
|
||||
self.validate_loaded_session(&handle.path, &session)?;
|
||||
Ok(LoadedManagedSession {
|
||||
handle: SessionHandle {
|
||||
id: session.session_id.clone(),
|
||||
@@ -221,7 +181,9 @@ impl SessionStore {
|
||||
branch_name: Option<String>,
|
||||
) -> Result<ForkedManagedSession, SessionControlError> {
|
||||
let parent_session_id = session.session_id.clone();
|
||||
let forked = session.fork(branch_name);
|
||||
let forked = session
|
||||
.fork(branch_name)
|
||||
.with_workspace_root(self.workspace_root.clone());
|
||||
let handle = self.create_handle(&forked.session_id);
|
||||
let branch_name = forked
|
||||
.fork
|
||||
@@ -236,6 +198,96 @@ impl SessionStore {
|
||||
branch_name,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
modified_epoch_millis,
|
||||
message_count: 0,
|
||||
parent_session_id: None,
|
||||
branch_name: None,
|
||||
},
|
||||
};
|
||||
sessions.push(summary);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable hex fingerprint of a workspace path.
|
||||
@@ -294,6 +346,7 @@ pub enum SessionControlError {
|
||||
Io(std::io::Error),
|
||||
Session(SessionError),
|
||||
Format(String),
|
||||
WorkspaceMismatch { expected: PathBuf, actual: PathBuf },
|
||||
}
|
||||
|
||||
impl Display for SessionControlError {
|
||||
@@ -302,6 +355,12 @@ impl Display for SessionControlError {
|
||||
Self::Io(error) => write!(f, "{error}"),
|
||||
Self::Session(error) => write!(f, "{error}"),
|
||||
Self::Format(error) => write!(f, "{error}"),
|
||||
Self::WorkspaceMismatch { expected, actual } => write!(
|
||||
f,
|
||||
"session workspace mismatch: expected {}, found {}",
|
||||
expected.display(),
|
||||
actual.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,9 +386,8 @@ pub fn sessions_dir() -> Result<PathBuf, SessionControlError> {
|
||||
pub fn managed_sessions_dir_for(
|
||||
base_dir: impl AsRef<Path>,
|
||||
) -> Result<PathBuf, SessionControlError> {
|
||||
let path = base_dir.as_ref().join(".claw").join("sessions");
|
||||
fs::create_dir_all(&path)?;
|
||||
Ok(path)
|
||||
let store = SessionStore::from_cwd(base_dir)?;
|
||||
Ok(store.sessions_dir().to_path_buf())
|
||||
}
|
||||
|
||||
pub fn create_managed_session_handle(
|
||||
@@ -342,10 +400,8 @@ pub fn create_managed_session_handle_for(
|
||||
base_dir: impl AsRef<Path>,
|
||||
session_id: &str,
|
||||
) -> Result<SessionHandle, SessionControlError> {
|
||||
let id = session_id.to_string();
|
||||
let path =
|
||||
managed_sessions_dir_for(base_dir)?.join(format!("{id}.{PRIMARY_SESSION_EXTENSION}"));
|
||||
Ok(SessionHandle { id, path })
|
||||
let store = SessionStore::from_cwd(base_dir)?;
|
||||
Ok(store.create_handle(session_id))
|
||||
}
|
||||
|
||||
pub fn resolve_session_reference(reference: &str) -> Result<SessionHandle, SessionControlError> {
|
||||
@@ -356,36 +412,8 @@ pub fn resolve_session_reference_for(
|
||||
base_dir: impl AsRef<Path>,
|
||||
reference: &str,
|
||||
) -> Result<SessionHandle, SessionControlError> {
|
||||
let base_dir = base_dir.as_ref();
|
||||
if is_session_reference_alias(reference) {
|
||||
let latest = latest_managed_session_for(base_dir)?;
|
||||
return Ok(SessionHandle {
|
||||
id: latest.id,
|
||||
path: latest.path,
|
||||
});
|
||||
}
|
||||
|
||||
let direct = PathBuf::from(reference);
|
||||
let candidate = if direct.is_absolute() {
|
||||
direct.clone()
|
||||
} else {
|
||||
base_dir.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(
|
||||
format_missing_session_reference(reference),
|
||||
));
|
||||
} else {
|
||||
resolve_managed_session_path_for(base_dir, reference)?
|
||||
};
|
||||
|
||||
Ok(SessionHandle {
|
||||
id: session_id_from_path(&path).unwrap_or_else(|| reference.to_string()),
|
||||
path,
|
||||
})
|
||||
let store = SessionStore::from_cwd(base_dir)?;
|
||||
store.resolve_reference(reference)
|
||||
}
|
||||
|
||||
pub fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, SessionControlError> {
|
||||
@@ -396,16 +424,8 @@ pub fn resolve_managed_session_path_for(
|
||||
base_dir: impl AsRef<Path>,
|
||||
session_id: &str,
|
||||
) -> Result<PathBuf, SessionControlError> {
|
||||
let directory = managed_sessions_dir_for(base_dir)?;
|
||||
for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] {
|
||||
let path = directory.join(format!("{session_id}.{extension}"));
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
Err(SessionControlError::Format(
|
||||
format_missing_session_reference(session_id),
|
||||
))
|
||||
let store = SessionStore::from_cwd(base_dir)?;
|
||||
store.resolve_managed_path(session_id)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -424,64 +444,8 @@ pub fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, SessionCont
|
||||
pub fn list_managed_sessions_for(
|
||||
base_dir: impl AsRef<Path>,
|
||||
) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
|
||||
let mut sessions = Vec::new();
|
||||
for entry in fs::read_dir(managed_sessions_dir_for(base_dir)?)? {
|
||||
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 (id, message_count, parent_session_id, branch_name) =
|
||||
match Session::load_from_path(&path) {
|
||||
Ok(session) => {
|
||||
let parent_session_id = session
|
||||
.fork
|
||||
.as_ref()
|
||||
.map(|fork| fork.parent_session_id.clone());
|
||||
let branch_name = session
|
||||
.fork
|
||||
.as_ref()
|
||||
.and_then(|fork| fork.branch_name.clone());
|
||||
(
|
||||
session.session_id,
|
||||
session.messages.len(),
|
||||
parent_session_id,
|
||||
branch_name,
|
||||
)
|
||||
}
|
||||
Err(_) => (
|
||||
path.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
};
|
||||
sessions.push(ManagedSessionSummary {
|
||||
id,
|
||||
path,
|
||||
modified_epoch_millis,
|
||||
message_count,
|
||||
parent_session_id,
|
||||
branch_name,
|
||||
});
|
||||
}
|
||||
sessions.sort_by(|left, right| {
|
||||
right
|
||||
.modified_epoch_millis
|
||||
.cmp(&left.modified_epoch_millis)
|
||||
.then_with(|| right.id.cmp(&left.id))
|
||||
});
|
||||
Ok(sessions)
|
||||
let store = SessionStore::from_cwd(base_dir)?;
|
||||
store.list_sessions()
|
||||
}
|
||||
|
||||
pub fn latest_managed_session() -> Result<ManagedSessionSummary, SessionControlError> {
|
||||
@@ -491,10 +455,8 @@ pub fn latest_managed_session() -> Result<ManagedSessionSummary, SessionControlE
|
||||
pub fn latest_managed_session_for(
|
||||
base_dir: impl AsRef<Path>,
|
||||
) -> Result<ManagedSessionSummary, SessionControlError> {
|
||||
list_managed_sessions_for(base_dir)?
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions()))
|
||||
let store = SessionStore::from_cwd(base_dir)?;
|
||||
store.latest_session()
|
||||
}
|
||||
|
||||
pub fn load_managed_session(reference: &str) -> Result<LoadedManagedSession, SessionControlError> {
|
||||
@@ -505,15 +467,8 @@ pub fn load_managed_session_for(
|
||||
base_dir: impl AsRef<Path>,
|
||||
reference: &str,
|
||||
) -> Result<LoadedManagedSession, SessionControlError> {
|
||||
let handle = resolve_session_reference_for(base_dir, reference)?;
|
||||
let session = Session::load_from_path(&handle.path)?;
|
||||
Ok(LoadedManagedSession {
|
||||
handle: SessionHandle {
|
||||
id: session.session_id.clone(),
|
||||
path: handle.path,
|
||||
},
|
||||
session,
|
||||
})
|
||||
let store = SessionStore::from_cwd(base_dir)?;
|
||||
store.load_session(reference)
|
||||
}
|
||||
|
||||
pub fn fork_managed_session(
|
||||
@@ -528,21 +483,8 @@ pub fn fork_managed_session_for(
|
||||
session: &Session,
|
||||
branch_name: Option<String>,
|
||||
) -> Result<ForkedManagedSession, SessionControlError> {
|
||||
let parent_session_id = session.session_id.clone();
|
||||
let forked = session.fork(branch_name);
|
||||
let handle = create_managed_session_handle_for(base_dir, &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,
|
||||
})
|
||||
let store = SessionStore::from_cwd(base_dir)?;
|
||||
store.fork_session(session, branch_name)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -574,12 +516,36 @@ fn format_no_managed_sessions() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn format_legacy_session_missing_workspace_root(
|
||||
session_path: &Path,
|
||||
workspace_root: &Path,
|
||||
) -> String {
|
||||
format!(
|
||||
"legacy session is missing workspace binding: {}\nOpen 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))
|
||||
}
|
||||
|
||||
#[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,
|
||||
workspace_fingerprint, ManagedSessionSummary, SessionStore, LATEST_SESSION_REFERENCE,
|
||||
workspace_fingerprint, ManagedSessionSummary, SessionControlError, SessionStore,
|
||||
LATEST_SESSION_REFERENCE,
|
||||
};
|
||||
use crate::session::Session;
|
||||
use std::fs;
|
||||
@@ -595,7 +561,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn persist_session(root: &Path, text: &str) -> Session {
|
||||
let mut session = Session::new();
|
||||
let mut session = Session::new().with_workspace_root(root.to_path_buf());
|
||||
session
|
||||
.push_user_text(text)
|
||||
.expect("session message should save");
|
||||
@@ -708,7 +674,7 @@ mod tests {
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
fn persist_session_via_store(store: &SessionStore, text: &str) -> Session {
|
||||
let mut session = Session::new();
|
||||
let mut session = Session::new().with_workspace_root(store.workspace_root().to_path_buf());
|
||||
session
|
||||
.push_user_text(text)
|
||||
.expect("session message should save");
|
||||
@@ -820,6 +786,95 @@ mod tests {
|
||||
fs::remove_dir_all(base).expect("temp dir should clean up");
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_store_latest_and_resolve_reference() {
|
||||
// given
|
||||
|
||||
Reference in New Issue
Block a user