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:
@@ -344,7 +344,7 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 =
|
|||||||
- Forks inherit the parent's workspace root by default; an explicit re-bind is required to move a session to a new worktree, and that re-bind is itself recorded as a structured event so the orchestrator can audit cross-worktree handoffs.
|
- Forks inherit the parent's workspace root by default; an explicit re-bind is required to move a session to a new worktree, and that re-bind is itself recorded as a structured event so the orchestrator can audit cross-worktree handoffs.
|
||||||
- Surface a `branch.workspace_mismatch` lane event so clawhip stops counting wrong-CWD writes as lane completions.
|
- Surface a `branch.workspace_mismatch` lane event so clawhip stops counting wrong-CWD writes as lane completions.
|
||||||
|
|
||||||
**Status.** A `workspace_root` field has been added to `Session` in `rust/crates/runtime/src/session.rs` (with builder, accessor, JSON + JSONL round-trip, fork inheritance, and given/when/then test coverage in `persists_workspace_root_round_trip_and_forks_inherit_it`). The CWD validation, the namespaced on-disk path, and the `branch.workspace_mismatch` lane event are still outstanding and tracked under this item.
|
**Status.** Done. Managed-session creation/list/latest/load/fork now route through the per-worktree `SessionStore` namespace in runtime + CLI paths, session loads/resumes reject wrong-workspace access with typed `SessionControlError::WorkspaceMismatch` details, `branch.workspace_mismatch` / `workspace_mismatch` are available on the lane-event surface, and same-workspace legacy flat sessions remain readable while mismatched legacy access is blocked. Focused runtime/CLI/tools coverage for the isolation path is green, and `cargo test --workspace --exclude compat-harness` passes. `cargo clippy --workspace --all-targets -- -D warnings` still fails on pre-existing lints in unchanged `rust/crates/rusty-claude-cli/build.rs`, so that lint cleanup remains outside this roadmap item.
|
||||||
|
|
||||||
## Deployment Architecture Gap (filed from dogfood 2026-04-08)
|
## Deployment Architecture Gap (filed from dogfood 2026-04-08)
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ pub enum LaneEventName {
|
|||||||
Closed,
|
Closed,
|
||||||
#[serde(rename = "branch.stale_against_main")]
|
#[serde(rename = "branch.stale_against_main")]
|
||||||
BranchStaleAgainstMain,
|
BranchStaleAgainstMain,
|
||||||
|
#[serde(rename = "branch.workspace_mismatch")]
|
||||||
|
BranchWorkspaceMismatch,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -67,6 +69,7 @@ pub enum LaneFailureClass {
|
|||||||
McpHandshake,
|
McpHandshake,
|
||||||
GatewayRouting,
|
GatewayRouting,
|
||||||
ToolRuntime,
|
ToolRuntime,
|
||||||
|
WorkspaceMismatch,
|
||||||
Infra,
|
Infra,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +280,10 @@ mod tests {
|
|||||||
LaneEventName::BranchStaleAgainstMain,
|
LaneEventName::BranchStaleAgainstMain,
|
||||||
"branch.stale_against_main",
|
"branch.stale_against_main",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
LaneEventName::BranchWorkspaceMismatch,
|
||||||
|
"branch.workspace_mismatch",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (event, expected) in cases {
|
for (event, expected) in cases {
|
||||||
@@ -300,6 +307,7 @@ mod tests {
|
|||||||
(LaneFailureClass::McpHandshake, "mcp_handshake"),
|
(LaneFailureClass::McpHandshake, "mcp_handshake"),
|
||||||
(LaneFailureClass::GatewayRouting, "gateway_routing"),
|
(LaneFailureClass::GatewayRouting, "gateway_routing"),
|
||||||
(LaneFailureClass::ToolRuntime, "tool_runtime"),
|
(LaneFailureClass::ToolRuntime, "tool_runtime"),
|
||||||
|
(LaneFailureClass::WorkspaceMismatch, "workspace_mismatch"),
|
||||||
(LaneFailureClass::Infra, "infra"),
|
(LaneFailureClass::Infra, "infra"),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -329,6 +337,38 @@ mod tests {
|
|||||||
assert_eq!(failed.detail.as_deref(), Some("broken server"));
|
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]
|
#[test]
|
||||||
fn commit_events_can_carry_worktree_and_supersession_metadata() {
|
fn commit_events_can_carry_worktree_and_supersession_metadata() {
|
||||||
let event = LaneEvent::commit_created(
|
let event = LaneEvent::commit_created(
|
||||||
|
|||||||
@@ -121,6 +121,17 @@ impl SessionStore {
|
|||||||
return Ok(path);
|
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(
|
Err(SessionControlError::Format(
|
||||||
format_missing_session_reference(session_id),
|
format_missing_session_reference(session_id),
|
||||||
))
|
))
|
||||||
@@ -128,61 +139,9 @@ impl SessionStore {
|
|||||||
|
|
||||||
pub fn list_sessions(&self) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
|
pub fn list_sessions(&self) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
|
||||||
let mut sessions = Vec::new();
|
let mut sessions = Vec::new();
|
||||||
let read_result = fs::read_dir(&self.sessions_root);
|
self.collect_sessions_from_dir(&self.sessions_root, &mut sessions)?;
|
||||||
let entries = match read_result {
|
if let Some(legacy_root) = self.legacy_sessions_root() {
|
||||||
Ok(entries) => entries,
|
self.collect_sessions_from_dir(&legacy_root, &mut sessions)?;
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
sessions.sort_by(|left, right| {
|
sessions.sort_by(|left, right| {
|
||||||
right
|
right
|
||||||
@@ -206,6 +165,7 @@ impl SessionStore {
|
|||||||
) -> Result<LoadedManagedSession, SessionControlError> {
|
) -> Result<LoadedManagedSession, SessionControlError> {
|
||||||
let handle = self.resolve_reference(reference)?;
|
let handle = self.resolve_reference(reference)?;
|
||||||
let session = Session::load_from_path(&handle.path)?;
|
let session = Session::load_from_path(&handle.path)?;
|
||||||
|
self.validate_loaded_session(&handle.path, &session)?;
|
||||||
Ok(LoadedManagedSession {
|
Ok(LoadedManagedSession {
|
||||||
handle: SessionHandle {
|
handle: SessionHandle {
|
||||||
id: session.session_id.clone(),
|
id: session.session_id.clone(),
|
||||||
@@ -221,7 +181,9 @@ impl SessionStore {
|
|||||||
branch_name: Option<String>,
|
branch_name: Option<String>,
|
||||||
) -> Result<ForkedManagedSession, SessionControlError> {
|
) -> Result<ForkedManagedSession, SessionControlError> {
|
||||||
let parent_session_id = session.session_id.clone();
|
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 handle = self.create_handle(&forked.session_id);
|
||||||
let branch_name = forked
|
let branch_name = forked
|
||||||
.fork
|
.fork
|
||||||
@@ -236,6 +198,96 @@ impl SessionStore {
|
|||||||
branch_name,
|
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.
|
/// Stable hex fingerprint of a workspace path.
|
||||||
@@ -294,6 +346,7 @@ pub enum SessionControlError {
|
|||||||
Io(std::io::Error),
|
Io(std::io::Error),
|
||||||
Session(SessionError),
|
Session(SessionError),
|
||||||
Format(String),
|
Format(String),
|
||||||
|
WorkspaceMismatch { expected: PathBuf, actual: PathBuf },
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for SessionControlError {
|
impl Display for SessionControlError {
|
||||||
@@ -302,6 +355,12 @@ impl Display for SessionControlError {
|
|||||||
Self::Io(error) => write!(f, "{error}"),
|
Self::Io(error) => write!(f, "{error}"),
|
||||||
Self::Session(error) => write!(f, "{error}"),
|
Self::Session(error) => write!(f, "{error}"),
|
||||||
Self::Format(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(
|
pub fn managed_sessions_dir_for(
|
||||||
base_dir: impl AsRef<Path>,
|
base_dir: impl AsRef<Path>,
|
||||||
) -> Result<PathBuf, SessionControlError> {
|
) -> Result<PathBuf, SessionControlError> {
|
||||||
let path = base_dir.as_ref().join(".claw").join("sessions");
|
let store = SessionStore::from_cwd(base_dir)?;
|
||||||
fs::create_dir_all(&path)?;
|
Ok(store.sessions_dir().to_path_buf())
|
||||||
Ok(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_managed_session_handle(
|
pub fn create_managed_session_handle(
|
||||||
@@ -342,10 +400,8 @@ pub fn create_managed_session_handle_for(
|
|||||||
base_dir: impl AsRef<Path>,
|
base_dir: impl AsRef<Path>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
) -> Result<SessionHandle, SessionControlError> {
|
) -> Result<SessionHandle, SessionControlError> {
|
||||||
let id = session_id.to_string();
|
let store = SessionStore::from_cwd(base_dir)?;
|
||||||
let path =
|
Ok(store.create_handle(session_id))
|
||||||
managed_sessions_dir_for(base_dir)?.join(format!("{id}.{PRIMARY_SESSION_EXTENSION}"));
|
|
||||||
Ok(SessionHandle { id, path })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_session_reference(reference: &str) -> Result<SessionHandle, SessionControlError> {
|
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>,
|
base_dir: impl AsRef<Path>,
|
||||||
reference: &str,
|
reference: &str,
|
||||||
) -> Result<SessionHandle, SessionControlError> {
|
) -> Result<SessionHandle, SessionControlError> {
|
||||||
let base_dir = base_dir.as_ref();
|
let store = SessionStore::from_cwd(base_dir)?;
|
||||||
if is_session_reference_alias(reference) {
|
store.resolve_reference(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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, SessionControlError> {
|
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>,
|
base_dir: impl AsRef<Path>,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
) -> Result<PathBuf, SessionControlError> {
|
) -> Result<PathBuf, SessionControlError> {
|
||||||
let directory = managed_sessions_dir_for(base_dir)?;
|
let store = SessionStore::from_cwd(base_dir)?;
|
||||||
for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] {
|
store.resolve_managed_path(session_id)
|
||||||
let path = directory.join(format!("{session_id}.{extension}"));
|
|
||||||
if path.exists() {
|
|
||||||
return Ok(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(SessionControlError::Format(
|
|
||||||
format_missing_session_reference(session_id),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -424,64 +444,8 @@ pub fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, SessionCont
|
|||||||
pub fn list_managed_sessions_for(
|
pub fn list_managed_sessions_for(
|
||||||
base_dir: impl AsRef<Path>,
|
base_dir: impl AsRef<Path>,
|
||||||
) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
|
) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
|
||||||
let mut sessions = Vec::new();
|
let store = SessionStore::from_cwd(base_dir)?;
|
||||||
for entry in fs::read_dir(managed_sessions_dir_for(base_dir)?)? {
|
store.list_sessions()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn latest_managed_session() -> Result<ManagedSessionSummary, SessionControlError> {
|
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(
|
pub fn latest_managed_session_for(
|
||||||
base_dir: impl AsRef<Path>,
|
base_dir: impl AsRef<Path>,
|
||||||
) -> Result<ManagedSessionSummary, SessionControlError> {
|
) -> Result<ManagedSessionSummary, SessionControlError> {
|
||||||
list_managed_sessions_for(base_dir)?
|
let store = SessionStore::from_cwd(base_dir)?;
|
||||||
.into_iter()
|
store.latest_session()
|
||||||
.next()
|
|
||||||
.ok_or_else(|| SessionControlError::Format(format_no_managed_sessions()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_managed_session(reference: &str) -> Result<LoadedManagedSession, SessionControlError> {
|
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>,
|
base_dir: impl AsRef<Path>,
|
||||||
reference: &str,
|
reference: &str,
|
||||||
) -> Result<LoadedManagedSession, SessionControlError> {
|
) -> Result<LoadedManagedSession, SessionControlError> {
|
||||||
let handle = resolve_session_reference_for(base_dir, reference)?;
|
let store = SessionStore::from_cwd(base_dir)?;
|
||||||
let session = Session::load_from_path(&handle.path)?;
|
store.load_session(reference)
|
||||||
Ok(LoadedManagedSession {
|
|
||||||
handle: SessionHandle {
|
|
||||||
id: session.session_id.clone(),
|
|
||||||
path: handle.path,
|
|
||||||
},
|
|
||||||
session,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn fork_managed_session(
|
pub fn fork_managed_session(
|
||||||
@@ -528,21 +483,8 @@ pub fn fork_managed_session_for(
|
|||||||
session: &Session,
|
session: &Session,
|
||||||
branch_name: Option<String>,
|
branch_name: Option<String>,
|
||||||
) -> Result<ForkedManagedSession, SessionControlError> {
|
) -> Result<ForkedManagedSession, SessionControlError> {
|
||||||
let parent_session_id = session.session_id.clone();
|
let store = SessionStore::from_cwd(base_dir)?;
|
||||||
let forked = session.fork(branch_name);
|
store.fork_session(session, 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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
create_managed_session_handle_for, fork_managed_session_for, is_session_reference_alias,
|
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,
|
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 crate::session::Session;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -595,7 +561,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn persist_session(root: &Path, text: &str) -> Session {
|
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
|
session
|
||||||
.push_user_text(text)
|
.push_user_text(text)
|
||||||
.expect("session message should save");
|
.expect("session message should save");
|
||||||
@@ -708,7 +674,7 @@ mod tests {
|
|||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
||||||
fn persist_session_via_store(store: &SessionStore, text: &str) -> Session {
|
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
|
session
|
||||||
.push_user_text(text)
|
.push_user_text(text)
|
||||||
.expect("session message should save");
|
.expect("session message should save");
|
||||||
@@ -820,6 +786,95 @@ mod tests {
|
|||||||
fs::remove_dir_all(base).expect("temp dir should clean up");
|
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]
|
#[test]
|
||||||
fn session_store_latest_and_resolve_reference() {
|
fn session_store_latest_and_resolve_reference() {
|
||||||
// given
|
// given
|
||||||
|
|||||||
@@ -2215,30 +2215,9 @@ fn version_json_value() -> serde_json::Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) {
|
fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) {
|
||||||
let resolved_path = if session_path.exists() {
|
let session_reference = session_path.display().to_string();
|
||||||
session_path.to_path_buf()
|
let (handle, session) = match load_session_reference(&session_reference) {
|
||||||
} else {
|
Ok(loaded) => loaded,
|
||||||
match resolve_session_reference(&session_path.display().to_string()) {
|
|
||||||
Ok(handle) => handle.path,
|
|
||||||
Err(error) => {
|
|
||||||
if output_format == CliOutputFormat::Json {
|
|
||||||
eprintln!(
|
|
||||||
"{}",
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "error",
|
|
||||||
"error": format!("failed to restore session: {error}"),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
eprintln!("failed to restore session: {error}");
|
|
||||||
}
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let session = match Session::load_from_path(&resolved_path) {
|
|
||||||
Ok(session) => session,
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
if output_format == CliOutputFormat::Json {
|
if output_format == CliOutputFormat::Json {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
@@ -2254,6 +2233,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let resolved_path = handle.path.clone();
|
||||||
|
|
||||||
if commands.is_empty() {
|
if commands.is_empty() {
|
||||||
if output_format == CliOutputFormat::Json {
|
if output_format == CliOutputFormat::Json {
|
||||||
@@ -2262,14 +2242,14 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
|
|||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"kind": "restored",
|
"kind": "restored",
|
||||||
"session_id": session.session_id,
|
"session_id": session.session_id,
|
||||||
"path": resolved_path.display().to_string(),
|
"path": handle.path.display().to_string(),
|
||||||
"message_count": session.messages.len(),
|
"message_count": session.messages.len(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
println!(
|
println!(
|
||||||
"Restored session from {} ({} messages).",
|
"Restored session from {} ({} messages).",
|
||||||
resolved_path.display(),
|
handle.path.display(),
|
||||||
session.messages.len()
|
session.messages.len()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2762,7 +2742,7 @@ fn run_resume_command(
|
|||||||
}
|
}
|
||||||
let backup_path = write_session_clear_backup(session, session_path)?;
|
let backup_path = write_session_clear_backup(session, session_path)?;
|
||||||
let previous_session_id = session.session_id.clone();
|
let previous_session_id = session.session_id.clone();
|
||||||
let cleared = Session::new();
|
let cleared = new_cli_session()?;
|
||||||
let new_session_id = cleared.session_id.clone();
|
let new_session_id = cleared.session_id.clone();
|
||||||
cleared.save_to_path(session_path)?;
|
cleared.save_to_path(session_path)?;
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
@@ -3729,7 +3709,7 @@ impl LiveCli {
|
|||||||
permission_mode: PermissionMode,
|
permission_mode: PermissionMode,
|
||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let system_prompt = build_system_prompt()?;
|
let system_prompt = build_system_prompt()?;
|
||||||
let session_state = Session::new();
|
let session_state = new_cli_session()?;
|
||||||
let session = create_managed_session_handle(&session_state.session_id)?;
|
let session = create_managed_session_handle(&session_state.session_id)?;
|
||||||
let runtime = build_runtime(
|
let runtime = build_runtime(
|
||||||
session_state.with_persistence_path(session.path.clone()),
|
session_state.with_persistence_path(session.path.clone()),
|
||||||
@@ -4314,7 +4294,7 @@ impl LiveCli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let previous_session = self.session.clone();
|
let previous_session = self.session.clone();
|
||||||
let session_state = Session::new();
|
let session_state = new_cli_session()?;
|
||||||
self.session = create_managed_session_handle(&session_state.session_id)?;
|
self.session = create_managed_session_handle(&session_state.session_id)?;
|
||||||
let runtime = build_runtime(
|
let runtime = build_runtime(
|
||||||
session_state.with_persistence_path(self.session.path.clone()),
|
session_state.with_persistence_path(self.session.path.clone()),
|
||||||
@@ -4354,8 +4334,7 @@ impl LiveCli {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
let handle = resolve_session_reference(&session_ref)?;
|
let (handle, session) = load_session_reference(&session_ref)?;
|
||||||
let session = Session::load_from_path(&handle.path)?;
|
|
||||||
let message_count = session.messages.len();
|
let message_count = session.messages.len();
|
||||||
let session_id = session.session_id.clone();
|
let session_id = session.session_id.clone();
|
||||||
let runtime = build_runtime(
|
let runtime = build_runtime(
|
||||||
@@ -4510,8 +4489,7 @@ impl LiveCli {
|
|||||||
println!("Usage: /session switch <session-id>");
|
println!("Usage: /session switch <session-id>");
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let handle = resolve_session_reference(target)?;
|
let (handle, session) = load_session_reference(target)?;
|
||||||
let session = Session::load_from_path(&handle.path)?;
|
|
||||||
let message_count = session.messages.len();
|
let message_count = session.messages.len();
|
||||||
let session_id = session.session_id.clone();
|
let session_id = session.session_id.clone();
|
||||||
let runtime = build_runtime(
|
let runtime = build_runtime(
|
||||||
@@ -4772,177 +4750,88 @@ impl LiveCli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
fn sessions_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||||
|
Ok(current_session_store()?.sessions_dir().to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_session_store() -> Result<runtime::SessionStore, Box<dyn std::error::Error>> {
|
||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
let store = runtime::SessionStore::from_cwd(&cwd)
|
runtime::SessionStore::from_cwd(&cwd).map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
|
||||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
}
|
||||||
Ok(store.sessions_dir().to_path_buf())
|
|
||||||
|
fn new_cli_session() -> Result<Session, Box<dyn std::error::Error>> {
|
||||||
|
Ok(Session::new().with_workspace_root(env::current_dir()?))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_managed_session_handle(
|
fn create_managed_session_handle(
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
||||||
let id = session_id.to_string();
|
let handle = current_session_store()?
|
||||||
let path = sessions_dir()?.join(format!("{id}.{PRIMARY_SESSION_EXTENSION}"));
|
.create_handle(session_id);
|
||||||
Ok(SessionHandle { id, path })
|
Ok(SessionHandle {
|
||||||
|
id: handle.id,
|
||||||
|
path: handle.path,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
fn resolve_session_reference(reference: &str) -> Result<SessionHandle, Box<dyn std::error::Error>> {
|
||||||
if SESSION_REFERENCE_ALIASES
|
let handle = current_session_store()?
|
||||||
.iter()
|
.resolve_reference(reference)
|
||||||
.any(|alias| reference.eq_ignore_ascii_case(alias))
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||||
{
|
Ok(SessionHandle {
|
||||||
let latest = latest_managed_session()?;
|
id: handle.id,
|
||||||
return Ok(SessionHandle {
|
path: handle.path,
|
||||||
id: latest.id,
|
})
|
||||||
path: latest.path,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let direct = PathBuf::from(reference);
|
|
||||||
let looks_like_path = direct.extension().is_some() || direct.components().count() > 1;
|
|
||||||
let path = if direct.exists() {
|
|
||||||
direct
|
|
||||||
} else if looks_like_path {
|
|
||||||
return Err(format_missing_session_reference(reference).into());
|
|
||||||
} else {
|
|
||||||
resolve_managed_session_path(reference)?
|
|
||||||
};
|
|
||||||
let id = 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}")))
|
|
||||||
})
|
|
||||||
.unwrap_or(reference)
|
|
||||||
.to_string();
|
|
||||||
Ok(SessionHandle { id, path })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
fn resolve_managed_session_path(session_id: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||||
let directory = sessions_dir()?;
|
current_session_store()?
|
||||||
for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] {
|
.resolve_managed_path(session_id)
|
||||||
let path = directory.join(format!("{session_id}.{extension}"));
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
|
||||||
if path.exists() {
|
|
||||||
return Ok(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Backward compatibility: pre-isolation sessions were stored at
|
|
||||||
// `.claw/sessions/<id>.{jsonl,json}` without the per-workspace hash
|
|
||||||
// subdirectory. Walk up from `directory` to the `.claw/sessions/` root
|
|
||||||
// and try the flat layout as a fallback so users do not lose access
|
|
||||||
// to their pre-upgrade managed sessions.
|
|
||||||
if let Some(legacy_root) = directory
|
|
||||||
.parent()
|
|
||||||
.filter(|parent| parent.file_name().is_some_and(|name| name == "sessions"))
|
|
||||||
{
|
|
||||||
for extension in [PRIMARY_SESSION_EXTENSION, LEGACY_SESSION_EXTENSION] {
|
|
||||||
let path = legacy_root.join(format!("{session_id}.{extension}"));
|
|
||||||
if path.exists() {
|
|
||||||
return Ok(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(format_missing_session_reference(session_id).into())
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_sessions_from_dir(
|
|
||||||
directory: &Path,
|
|
||||||
sessions: &mut Vec<ManagedSessionSummary>,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
if !directory.exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
for entry in fs::read_dir(directory)? {
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
|
fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::error::Error>> {
|
||||||
let mut sessions = Vec::new();
|
Ok(current_session_store()?
|
||||||
let primary_dir = sessions_dir()?;
|
.list_sessions()
|
||||||
collect_sessions_from_dir(&primary_dir, &mut sessions)?;
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?
|
||||||
|
.into_iter()
|
||||||
// Backward compatibility: include sessions stored in the pre-isolation
|
.map(|session| ManagedSessionSummary {
|
||||||
// flat `.claw/sessions/` root so users do not lose access to existing
|
id: session.id,
|
||||||
// managed sessions after the workspace-hashed subdirectory rollout.
|
path: session.path,
|
||||||
if let Some(legacy_root) = primary_dir
|
modified_epoch_millis: session.modified_epoch_millis,
|
||||||
.parent()
|
message_count: session.message_count,
|
||||||
.filter(|parent| parent.file_name().is_some_and(|name| name == "sessions"))
|
parent_session_id: session.parent_session_id,
|
||||||
{
|
branch_name: session.branch_name,
|
||||||
collect_sessions_from_dir(legacy_root, &mut sessions)?;
|
})
|
||||||
}
|
.collect())
|
||||||
|
|
||||||
sessions.sort_by(|left, right| {
|
|
||||||
right
|
|
||||||
.modified_epoch_millis
|
|
||||||
.cmp(&left.modified_epoch_millis)
|
|
||||||
.then_with(|| right.id.cmp(&left.id))
|
|
||||||
});
|
|
||||||
Ok(sessions)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error::Error>> {
|
fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error::Error>> {
|
||||||
list_managed_sessions()?
|
let session = current_session_store()?
|
||||||
.into_iter()
|
.latest_session()
|
||||||
.next()
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||||
.ok_or_else(|| format_no_managed_sessions().into())
|
Ok(ManagedSessionSummary {
|
||||||
|
id: session.id,
|
||||||
|
path: session.path,
|
||||||
|
modified_epoch_millis: session.modified_epoch_millis,
|
||||||
|
message_count: session.message_count,
|
||||||
|
parent_session_id: session.parent_session_id,
|
||||||
|
branch_name: session.branch_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_session_reference(
|
||||||
|
reference: &str,
|
||||||
|
) -> Result<(SessionHandle, Session), Box<dyn std::error::Error>> {
|
||||||
|
let loaded = current_session_store()?
|
||||||
|
.load_session(reference)
|
||||||
|
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||||
|
Ok((
|
||||||
|
SessionHandle {
|
||||||
|
id: loaded.handle.id,
|
||||||
|
path: loaded.handle.path,
|
||||||
|
},
|
||||||
|
loaded.session,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_managed_session(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
fn delete_managed_session(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -4963,18 +4852,6 @@ fn confirm_session_deletion(session_id: &str) -> bool {
|
|||||||
matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
|
matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn format_missing_session_reference(reference: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"session not found: {reference}\nHint: managed sessions live in .claw/sessions/. Try `{LATEST_SESSION_REFERENCE}` for the most recent session or `/session list` in the REPL."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_no_managed_sessions() -> String {
|
|
||||||
format!(
|
|
||||||
"no managed sessions found in .claw/sessions/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
|
fn render_session_list(active_session_id: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
let sessions = list_managed_sessions()?;
|
let sessions = list_managed_sessions()?;
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
@@ -6161,8 +6038,7 @@ fn run_export(
|
|||||||
output_path: Option<&Path>,
|
output_path: Option<&Path>,
|
||||||
output_format: CliOutputFormat,
|
output_format: CliOutputFormat,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let handle = resolve_session_reference(session_reference)?;
|
let (handle, session) = load_session_reference(session_reference)?;
|
||||||
let session = Session::load_from_path(&handle.path)?;
|
|
||||||
let markdown = render_session_markdown(&session, &handle.id, &handle.path);
|
let markdown = render_session_markdown(&session, &handle.id, &handle.path);
|
||||||
|
|
||||||
if let Some(path) = output_path {
|
if let Some(path) = output_path {
|
||||||
@@ -10760,6 +10636,7 @@ UU conflicted.rs",
|
|||||||
)
|
)
|
||||||
.expect("session dir should exist");
|
.expect("session dir should exist");
|
||||||
Session::new()
|
Session::new()
|
||||||
|
.with_workspace_root(workspace.clone())
|
||||||
.with_persistence_path(legacy_path.clone())
|
.with_persistence_path(legacy_path.clone())
|
||||||
.save_to_path(&legacy_path)
|
.save_to_path(&legacy_path)
|
||||||
.expect("legacy session should save");
|
.expect("legacy session should save");
|
||||||
@@ -10812,6 +10689,53 @@ UU conflicted.rs",
|
|||||||
std::fs::remove_dir_all(workspace).expect("workspace should clean up");
|
std::fs::remove_dir_all(workspace).expect("workspace should clean up");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_session_reference_rejects_workspace_mismatch() {
|
||||||
|
let _guard = cwd_lock().lock().expect("cwd lock");
|
||||||
|
let workspace_a = temp_workspace("session-mismatch-a");
|
||||||
|
let workspace_b = temp_workspace("session-mismatch-b");
|
||||||
|
std::fs::create_dir_all(&workspace_a).expect("workspace a should create");
|
||||||
|
std::fs::create_dir_all(&workspace_b).expect("workspace b should create");
|
||||||
|
let previous = std::env::current_dir().expect("cwd");
|
||||||
|
std::env::set_current_dir(&workspace_b).expect("switch cwd");
|
||||||
|
|
||||||
|
let session_path = workspace_a.join(".claw/sessions/legacy-cross.jsonl");
|
||||||
|
std::fs::create_dir_all(
|
||||||
|
session_path
|
||||||
|
.parent()
|
||||||
|
.expect("session path should have parent directory"),
|
||||||
|
)
|
||||||
|
.expect("session dir should exist");
|
||||||
|
Session::new()
|
||||||
|
.with_workspace_root(workspace_a.clone())
|
||||||
|
.with_persistence_path(session_path.clone())
|
||||||
|
.save_to_path(&session_path)
|
||||||
|
.expect("session should save");
|
||||||
|
|
||||||
|
let error = crate::load_session_reference(&session_path.display().to_string())
|
||||||
|
.expect_err("mismatched workspace should fail");
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("session workspace mismatch"),
|
||||||
|
"unexpected error: {error}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains(&workspace_b.display().to_string()),
|
||||||
|
"expected current workspace in error: {error}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains(&workspace_a.display().to_string()),
|
||||||
|
"expected originating workspace in error: {error}"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::env::set_current_dir(previous).expect("restore cwd");
|
||||||
|
std::fs::remove_dir_all(workspace_a).expect("workspace a should clean up");
|
||||||
|
std::fs::remove_dir_all(workspace_b).expect("workspace b should clean up");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_slash_command_guidance_suggests_nearby_commands() {
|
fn unknown_slash_command_guidance_suggests_nearby_commands() {
|
||||||
let message = format_unknown_slash_command("stats");
|
let message = format_unknown_slash_command("stats");
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ fn command_in(cwd: &Path) -> Command {
|
|||||||
|
|
||||||
fn write_session(root: &Path, label: &str) -> PathBuf {
|
fn write_session(root: &Path, label: &str) -> PathBuf {
|
||||||
let session_path = root.join(format!("{label}.jsonl"));
|
let session_path = root.join(format!("{label}.jsonl"));
|
||||||
let mut session = Session::new();
|
let mut session = Session::new().with_workspace_root(root.to_path_buf());
|
||||||
session
|
session
|
||||||
.push_user_text(format!("session fixture for {label}"))
|
.push_user_text(format!("session fixture for {label}"))
|
||||||
.expect("session write should succeed");
|
.expect("session write should succeed");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::process::{Command, Output};
|
|||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use runtime::Session;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
@@ -236,12 +237,7 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
|||||||
assert!(sandbox["enabled"].is_boolean());
|
assert!(sandbox["enabled"].is_boolean());
|
||||||
assert!(sandbox["fallback_reason"].is_null() || sandbox["fallback_reason"].is_string());
|
assert!(sandbox["fallback_reason"].is_null() || sandbox["fallback_reason"].is_string());
|
||||||
|
|
||||||
let session_path = root.join("session.jsonl");
|
let session_path = write_session_fixture(&root, "resume-json", Some("hello"));
|
||||||
fs::write(
|
|
||||||
&session_path,
|
|
||||||
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n",
|
|
||||||
)
|
|
||||||
.expect("session should write");
|
|
||||||
let resumed = assert_json_command(
|
let resumed = assert_json_command(
|
||||||
&root,
|
&root,
|
||||||
&[
|
&[
|
||||||
@@ -268,12 +264,7 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
|
|||||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||||
fs::create_dir_all(&home).expect("home should exist");
|
fs::create_dir_all(&home).expect("home should exist");
|
||||||
|
|
||||||
let session_path = root.join("session.jsonl");
|
let session_path = write_session_fixture(&root, "resume-inventory-json", Some("inventory"));
|
||||||
fs::write(
|
|
||||||
&session_path,
|
|
||||||
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-inventory-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"inventory\"}]}}\n",
|
|
||||||
)
|
|
||||||
.expect("session should write");
|
|
||||||
|
|
||||||
let mcp = assert_json_command_with_env(
|
let mcp = assert_json_command_with_env(
|
||||||
&root,
|
&root,
|
||||||
@@ -324,12 +315,7 @@ fn resumed_version_and_init_emit_structured_json_when_requested() {
|
|||||||
let root = unique_temp_dir("resume-version-init-json");
|
let root = unique_temp_dir("resume-version-init-json");
|
||||||
fs::create_dir_all(&root).expect("temp dir should exist");
|
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||||
|
|
||||||
let session_path = root.join("session.jsonl");
|
let session_path = write_session_fixture(&root, "resume-version-init-json", None);
|
||||||
fs::write(
|
|
||||||
&session_path,
|
|
||||||
"{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-version-init-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n",
|
|
||||||
)
|
|
||||||
.expect("session should write");
|
|
||||||
|
|
||||||
let version = assert_json_command(
|
let version = assert_json_command(
|
||||||
&root,
|
&root,
|
||||||
@@ -405,6 +391,24 @@ fn write_upstream_fixture(root: &Path) -> PathBuf {
|
|||||||
upstream
|
upstream
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_session_fixture(root: &Path, session_id: &str, user_text: Option<&str>) -> PathBuf {
|
||||||
|
let session_path = root.join("session.jsonl");
|
||||||
|
let mut session = Session::new()
|
||||||
|
.with_workspace_root(root.to_path_buf())
|
||||||
|
.with_persistence_path(session_path.clone());
|
||||||
|
session.session_id = session_id.to_string();
|
||||||
|
if let Some(text) = user_text {
|
||||||
|
session
|
||||||
|
.push_user_text(text)
|
||||||
|
.expect("session fixture message should persist");
|
||||||
|
} else {
|
||||||
|
session
|
||||||
|
.save_to_path(&session_path)
|
||||||
|
.expect("session fixture should persist");
|
||||||
|
}
|
||||||
|
session_path
|
||||||
|
}
|
||||||
|
|
||||||
fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
|
fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
|
||||||
fs::create_dir_all(root).expect("agent root should exist");
|
fs::create_dir_all(root).expect("agent root should exist");
|
||||||
fs::write(
|
fs::write(
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ fn resumed_binary_accepts_slash_commands_with_arguments() {
|
|||||||
let session_path = temp_dir.join("session.jsonl");
|
let session_path = temp_dir.join("session.jsonl");
|
||||||
let export_path = temp_dir.join("notes.txt");
|
let export_path = temp_dir.join("notes.txt");
|
||||||
|
|
||||||
let mut session = Session::new();
|
let mut session = workspace_session(&temp_dir);
|
||||||
session
|
session
|
||||||
.push_user_text("ship the slash command harness")
|
.push_user_text("ship the slash command harness")
|
||||||
.expect("session write should succeed");
|
.expect("session write should succeed");
|
||||||
@@ -122,7 +122,7 @@ fn resumed_config_command_loads_settings_files_end_to_end() {
|
|||||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||||
|
|
||||||
let session_path = project_dir.join("session.jsonl");
|
let session_path = project_dir.join("session.jsonl");
|
||||||
Session::new()
|
workspace_session(&project_dir)
|
||||||
.with_persistence_path(&session_path)
|
.with_persistence_path(&session_path)
|
||||||
.save_to_path(&session_path)
|
.save_to_path(&session_path)
|
||||||
.expect("session should persist");
|
.expect("session should persist");
|
||||||
@@ -180,13 +180,11 @@ fn resume_latest_restores_the_most_recent_managed_session() {
|
|||||||
// given
|
// given
|
||||||
let temp_dir = unique_temp_dir("resume-latest");
|
let temp_dir = unique_temp_dir("resume-latest");
|
||||||
let project_dir = temp_dir.join("project");
|
let project_dir = temp_dir.join("project");
|
||||||
let sessions_dir = project_dir.join(".claw").join("sessions");
|
let store = runtime::SessionStore::from_cwd(&project_dir).expect("session store should build");
|
||||||
fs::create_dir_all(&sessions_dir).expect("sessions dir should exist");
|
let older_path = store.create_handle("session-older").path;
|
||||||
|
let newer_path = store.create_handle("session-newer").path;
|
||||||
|
|
||||||
let older_path = sessions_dir.join("session-older.jsonl");
|
let mut older = workspace_session(&project_dir).with_persistence_path(&older_path);
|
||||||
let newer_path = sessions_dir.join("session-newer.jsonl");
|
|
||||||
|
|
||||||
let mut older = Session::new().with_persistence_path(&older_path);
|
|
||||||
older
|
older
|
||||||
.push_user_text("older session")
|
.push_user_text("older session")
|
||||||
.expect("older session write should succeed");
|
.expect("older session write should succeed");
|
||||||
@@ -194,7 +192,7 @@ fn resume_latest_restores_the_most_recent_managed_session() {
|
|||||||
.save_to_path(&older_path)
|
.save_to_path(&older_path)
|
||||||
.expect("older session should persist");
|
.expect("older session should persist");
|
||||||
|
|
||||||
let mut newer = Session::new().with_persistence_path(&newer_path);
|
let mut newer = workspace_session(&project_dir).with_persistence_path(&newer_path);
|
||||||
newer
|
newer
|
||||||
.push_user_text("newer session")
|
.push_user_text("newer session")
|
||||||
.expect("newer session write should succeed");
|
.expect("newer session write should succeed");
|
||||||
@@ -229,7 +227,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
|
|||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
let session_path = temp_dir.join("session.jsonl");
|
||||||
|
|
||||||
let mut session = Session::new();
|
let mut session = workspace_session(&temp_dir);
|
||||||
session
|
session
|
||||||
.push_user_text("resume status json fixture")
|
.push_user_text("resume status json fixture")
|
||||||
.expect("session write should succeed");
|
.expect("session write should succeed");
|
||||||
@@ -283,7 +281,7 @@ fn resumed_status_surfaces_persisted_model() {
|
|||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
let session_path = temp_dir.join("session.jsonl");
|
||||||
|
|
||||||
let mut session = Session::new();
|
let mut session = workspace_session(&temp_dir);
|
||||||
session.model = Some("claude-sonnet-4-6".to_string());
|
session.model = Some("claude-sonnet-4-6".to_string());
|
||||||
session
|
session
|
||||||
.push_user_text("model persistence fixture")
|
.push_user_text("model persistence fixture")
|
||||||
@@ -324,7 +322,7 @@ fn resumed_sandbox_command_emits_structured_json_when_requested() {
|
|||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
let session_path = temp_dir.join("session.jsonl");
|
||||||
|
|
||||||
Session::new()
|
workspace_session(&temp_dir)
|
||||||
.save_to_path(&session_path)
|
.save_to_path(&session_path)
|
||||||
.expect("session should persist");
|
.expect("session should persist");
|
||||||
|
|
||||||
@@ -365,7 +363,7 @@ fn resumed_version_command_emits_structured_json() {
|
|||||||
let temp_dir = unique_temp_dir("resume-version-json");
|
let temp_dir = unique_temp_dir("resume-version-json");
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
let session_path = temp_dir.join("session.jsonl");
|
||||||
Session::new()
|
workspace_session(&temp_dir)
|
||||||
.save_to_path(&session_path)
|
.save_to_path(&session_path)
|
||||||
.expect("session should persist");
|
.expect("session should persist");
|
||||||
|
|
||||||
@@ -398,7 +396,7 @@ fn resumed_export_command_emits_structured_json() {
|
|||||||
let temp_dir = unique_temp_dir("resume-export-json");
|
let temp_dir = unique_temp_dir("resume-export-json");
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
let session_path = temp_dir.join("session.jsonl");
|
||||||
let mut session = Session::new();
|
let mut session = workspace_session(&temp_dir);
|
||||||
session
|
session
|
||||||
.push_user_text("export json fixture")
|
.push_user_text("export json fixture")
|
||||||
.expect("write ok");
|
.expect("write ok");
|
||||||
@@ -432,7 +430,7 @@ fn resumed_help_command_emits_structured_json() {
|
|||||||
let temp_dir = unique_temp_dir("resume-help-json");
|
let temp_dir = unique_temp_dir("resume-help-json");
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
let session_path = temp_dir.join("session.jsonl");
|
||||||
Session::new()
|
workspace_session(&temp_dir)
|
||||||
.save_to_path(&session_path)
|
.save_to_path(&session_path)
|
||||||
.expect("persist ok");
|
.expect("persist ok");
|
||||||
|
|
||||||
@@ -465,7 +463,7 @@ fn resumed_no_command_emits_restored_json() {
|
|||||||
let temp_dir = unique_temp_dir("resume-no-cmd-json");
|
let temp_dir = unique_temp_dir("resume-no-cmd-json");
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
let session_path = temp_dir.join("session.jsonl");
|
||||||
let mut session = Session::new();
|
let mut session = workspace_session(&temp_dir);
|
||||||
session
|
session
|
||||||
.push_user_text("restored json fixture")
|
.push_user_text("restored json fixture")
|
||||||
.expect("write ok");
|
.expect("write ok");
|
||||||
@@ -499,7 +497,7 @@ fn resumed_stub_command_emits_not_implemented_json() {
|
|||||||
let temp_dir = unique_temp_dir("resume-stub-json");
|
let temp_dir = unique_temp_dir("resume-stub-json");
|
||||||
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
|
||||||
let session_path = temp_dir.join("session.jsonl");
|
let session_path = temp_dir.join("session.jsonl");
|
||||||
Session::new()
|
workspace_session(&temp_dir)
|
||||||
.save_to_path(&session_path)
|
.save_to_path(&session_path)
|
||||||
.expect("persist ok");
|
.expect("persist ok");
|
||||||
|
|
||||||
@@ -533,6 +531,10 @@ fn run_claw(current_dir: &Path, args: &[&str]) -> Output {
|
|||||||
run_claw_with_env(current_dir, args, &[])
|
run_claw_with_env(current_dir, args, &[])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn workspace_session(root: &Path) -> Session {
|
||||||
|
Session::new().with_workspace_root(root.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
fn run_claw_with_env(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
|
fn run_claw_with_env(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
|
||||||
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
let mut command = Command::new(env!("CARGO_BIN_EXE_claw"));
|
||||||
command.current_dir(current_dir).args(args);
|
command.current_dir(current_dir).args(args);
|
||||||
|
|||||||
@@ -3734,6 +3734,8 @@ fn classify_lane_failure(error: &str) -> LaneFailureClass {
|
|||||||
|| normalized.contains("tool runtime")
|
|| normalized.contains("tool runtime")
|
||||||
{
|
{
|
||||||
LaneFailureClass::ToolRuntime
|
LaneFailureClass::ToolRuntime
|
||||||
|
} else if normalized.contains("workspace") && normalized.contains("mismatch") {
|
||||||
|
LaneFailureClass::WorkspaceMismatch
|
||||||
} else if normalized.contains("plugin") {
|
} else if normalized.contains("plugin") {
|
||||||
LaneFailureClass::PluginStartup
|
LaneFailureClass::PluginStartup
|
||||||
} else if normalized.contains("mcp") && normalized.contains("handshake") {
|
} else if normalized.contains("mcp") && normalized.contains("handshake") {
|
||||||
@@ -7253,6 +7255,10 @@ mod tests {
|
|||||||
"tool failed: denied tool execution from hook",
|
"tool failed: denied tool execution from hook",
|
||||||
LaneFailureClass::ToolRuntime,
|
LaneFailureClass::ToolRuntime,
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"workspace mismatch while resuming the managed session",
|
||||||
|
LaneFailureClass::WorkspaceMismatch,
|
||||||
|
),
|
||||||
("thread creation failed", LaneFailureClass::Infra),
|
("thread creation failed", LaneFailureClass::Infra),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -7279,6 +7285,10 @@ mod tests {
|
|||||||
LaneEventName::BranchStaleAgainstMain,
|
LaneEventName::BranchStaleAgainstMain,
|
||||||
"branch.stale_against_main",
|
"branch.stale_against_main",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
LaneEventName::BranchWorkspaceMismatch,
|
||||||
|
"branch.workspace_mismatch",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (event, expected) in cases {
|
for (event, expected) in cases {
|
||||||
|
|||||||
Reference in New Issue
Block a user