fix: /resume latest searches all workspaces

Fixes /resume latest to search all workspaces instead of just the current one.
This commit is contained in:
TheArchitectit
2026-05-24 21:24:41 -05:00
committed by GitHub
parent 0975252976
commit f1a55a211e
3 changed files with 198 additions and 7 deletions

View File

@@ -197,6 +197,10 @@ const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
name: "trustedRoots",
expected: FieldType::StringArray,
},
FieldSpec {
name: "provider",
expected: FieldType::Object,
},
];
const HOOKS_FIELDS: &[FieldSpec] = &[
@@ -310,6 +314,25 @@ const OAUTH_FIELDS: &[FieldSpec] = &[
},
];
const PROVIDER_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "kind",
expected: FieldType::String,
},
FieldSpec {
name: "apiKey",
expected: FieldType::String,
},
FieldSpec {
name: "baseUrl",
expected: FieldType::String,
},
FieldSpec {
name: "model",
expected: FieldType::String,
},
];
const DEPRECATED_FIELDS: &[DeprecatedField] = &[
DeprecatedField {
name: "permissionMode",
@@ -501,6 +524,15 @@ pub fn validate_config_file(
&path_display,
));
}
if let Some(provider) = object.get("provider").and_then(JsonValue::as_object) {
result.merge(validate_object_keys(
provider,
PROVIDER_FIELDS,
"provider",
source,
&path_display,
));
}
result
}

View File

@@ -158,9 +158,15 @@ impl SessionStore {
}
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
self.list_sessions()?.into_iter().next().ok_or_else(|| {
SessionControlError::Format(format_no_managed_sessions(&self.sessions_root))
})
if let Some(latest) = self.list_sessions()?.into_iter().next() {
return Ok(latest);
}
if let Some(latest) = self.scan_global_sessions()?.into_iter().next() {
return Ok(latest);
}
Err(SessionControlError::Format(format_no_managed_sessions(
&self.sessions_root,
)))
}
#[must_use]
@@ -190,6 +196,38 @@ impl SessionStore {
})
}
/// Load a session by reference, allowing cross-workspace resume for aliases.
/// When the reference is an alias ("latest", "last", "recent"), workspace
/// mismatch validation is skipped so `/resume latest` works across workspaces.
/// For explicit session references, workspace validation is still enforced.
pub fn load_session_loose(
&self,
reference: &str,
) -> Result<LoadedManagedSession, SessionControlError> {
match self.load_session(reference) {
Ok(loaded) => Ok(loaded),
Err(SessionControlError::WorkspaceMismatch { expected, actual })
if is_session_reference_alias(reference) =>
{
let handle = self.resolve_reference(reference)?;
let session = Session::load_from_path(&handle.path)?;
eprintln!(
" Note: resuming session from a different workspace (origin: {})",
actual.display()
);
let _ = expected; // suppress unused warning
Ok(LoadedManagedSession {
handle: SessionHandle {
id: session.session_id.clone(),
path: handle.path,
},
session,
})
}
Err(other) => Err(other),
}
}
pub fn fork_session(
&self,
session: &Session,
@@ -221,6 +259,47 @@ impl SessionStore {
.map(Path::to_path_buf)
}
/// Scan all known session storage locations for sessions from any workspace.
/// Checks both the global root (~/.claw/sessions/) and the project-local
/// .claw/sessions/ parent directory. Used as a fallback when the current
/// workspace has no sessions.
#[allow(clippy::unnecessary_wraps)]
fn scan_global_sessions(&self) -> Result<Vec<ManagedSessionSummary>, SessionControlError> {
let mut sessions = Vec::new();
// Scan global root: ~/.claw/sessions/<fingerprint>/
let global_root = global_sessions_root();
if let Ok(entries) = fs::read_dir(&global_root) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions);
}
}
}
// Scan project-local parent: <cwd>/.claw/sessions/<fingerprint>/
// Sessions are stored here by from_cwd(), so we must check all
// fingerprint subdirs, not just the current workspace's.
if let Some(local_parent) = self.legacy_sessions_root() {
if let Ok(entries) = fs::read_dir(&local_parent) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() && path != self.sessions_root {
let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions);
} else if path == self.sessions_root {
// Already searched in list_sessions(), but include here
// in case this is called standalone
let _ = Self::collect_sessions_from_dir_unvalidated(&path, &mut sessions);
}
}
}
}
sort_managed_sessions(&mut sessions);
Ok(sessions)
}
fn validate_loaded_session(
&self,
session_path: &Path,
@@ -305,6 +384,65 @@ impl SessionStore {
}
Ok(())
}
/// Like `collect_sessions_from_dir` but skips workspace validation.
/// Used by the global scan fallback to discover sessions from any workspace.
fn collect_sessions_from_dir_unvalidated(
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) => ManagedSessionSummary {
id: session.session_id,
path,
updated_at_ms: session.updated_at_ms,
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,
updated_at_ms: 0,
modified_epoch_millis,
message_count: 0,
parent_session_id: None,
branch_name: None,
},
};
sessions.push(summary);
}
Ok(())
}
}
/// Stable hex fingerprint of a workspace path.
@@ -322,6 +460,13 @@ pub fn workspace_fingerprint(workspace_root: &Path) -> String {
format!("{hash:016x}")
}
/// The global sessions directory shared across all workspaces.
/// Points to `~/.claw/sessions/` (or `$CLAW_CONFIG_HOME/sessions/`).
#[must_use]
pub fn global_sessions_root() -> PathBuf {
crate::config::default_config_home().join("sessions")
}
pub const PRIMARY_SESSION_EXTENSION: &str = "jsonl";
pub const LEGACY_SESSION_EXTENSION: &str = "json";
pub const LATEST_SESSION_REFERENCE: &str = "latest";
@@ -574,7 +719,7 @@ fn format_no_managed_sessions(sessions_root: &Path) -> String {
.and_then(|f| f.to_str())
.unwrap_or("<unknown>");
format!(
"no managed sessions found in .claw/sessions/{fingerprint_dir}/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`.\nNote: claw partitions sessions per workspace fingerprint; sessions from other CWDs are invisible."
"no managed sessions found in .claw/sessions/{fingerprint_dir}/\nStart `claw` to create a session, then rerun with `--resume {LATEST_SESSION_REFERENCE}`.\nNote: /resume {LATEST_SESSION_REFERENCE} searches all workspaces."
)
}

View File

@@ -2,6 +2,13 @@
dead_code,
unused_imports,
unused_variables,
clippy::doc_markdown,
clippy::len_zero,
clippy::manual_string_new,
clippy::match_same_arms,
clippy::result_large_err,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::unneeded_struct_pattern,
clippy::unnecessary_wraps,
clippy::unused_self
@@ -6060,9 +6067,16 @@ fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error:
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>)?;
let store = current_session_store()?;
// For alias references ("latest", "last", "recent"), allow cross-workspace
// resume so /resume latest finds the most recent session globally.
// For explicit references, workspace validation is enforced.
let result = if runtime::session_control::is_session_reference_alias(reference) {
store.load_session_loose(reference)
} else {
store.load_session(reference)
};
let loaded = result.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
Ok((
SessionHandle {
id: loaded.handle.id,