diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index de4aafaa42..b246fe8719 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -651,8 +651,8 @@ impl PersistenceManager { /// Resolve the on-disk sessions directory for `workspace_path`. /// /// Callers may pass either a logical workspace root or an already-resolved - /// managed sessions directory. Local workspace roots are slugified under - /// `~/.openbitfun/projects/`; already-resolved local/remote sessions + /// managed sessions directory. Local workspace roots use compact runtime + /// keys under `~/.openbitfun/projects/`; already-resolved local/remote sessions /// directories are used as-is. fn project_sessions_dir(&self, workspace_path: &Path) -> PathBuf { if self.is_resolved_sessions_dir(workspace_path) { @@ -8065,7 +8065,7 @@ mod tests { assert!(runtime.snapshot_by_hash_dir.exists()); assert!(runtime.snapshot_metadata_dir.exists()); assert!(runtime.snapshot_operations_dir.exists()); - assert!(runtime.plans_dir.exists()); + assert!(!runtime.runtime_root.join("plans").exists()); assert!(runtime.layout_state_file.exists()); } diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 07ea7eda0c..d63d616934 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -10022,6 +10022,8 @@ mod tests { SessionRelationship, SessionRelationshipKind, ToolCallData, ToolItemData, ToolResultData, TurnStatus, UserMessageData, }; + #[cfg(feature = "remote-workspace")] + use crate::service::WorkspaceRuntimeService; use crate::util::errors::OpenBitFunError; use dashmap::{try_result::TryResult, DashMap}; use openbitfun_core_types::{ @@ -15019,12 +15021,13 @@ mod tests { let workspace = TestWorkspace::new(); let path_manager = workspace.path_manager(); let port = CoreSessionStorePort::with_path_manager_for_tests(path_manager.clone()); - let sessions_dir = - openbitfun_services_integrations::remote_ssh::remote_workspace_session_mirror_dir( - path_manager.remote_ssh_mirror_root_dir(), - "example-host", - "/root/repo", - ); + WorkspaceRuntimeService::new(path_manager.clone()) + .ensure_remote_workspace_runtime("example-host", "/root/repo") + .await + .expect("remote runtime should be ensured"); + let runtime = WorkspaceRuntimeService::new(path_manager.clone()) + .context_for_remote_workspace("example-host", "/root/repo"); + let sessions_dir = runtime.sessions_dir; let resolved = port .resolve_session_storage_path(SessionStoragePathRequest { workspace_path: sessions_dir.clone(), @@ -15037,12 +15040,7 @@ mod tests { assert_eq!(resolved.storage_kind, SessionStorageKind::Remote); assert_eq!(resolved.effective_storage_path, sessions_dir); - let runtime_root = - openbitfun_services_integrations::remote_ssh::remote_workspace_runtime_root( - path_manager.remote_ssh_mirror_root_dir(), - "example-host", - "/root/repo", - ); + let runtime_root = runtime.runtime_root; let runtime_root_resolution = port .resolve_session_storage_path(SessionStoragePathRequest { workspace_path: runtime_root.clone(), diff --git a/src/crates/assembly/core/src/agentic/session/session_store_port.rs b/src/crates/assembly/core/src/agentic/session/session_store_port.rs index afb93aefef..b6b7a3401b 100644 --- a/src/crates/assembly/core/src/agentic/session/session_store_port.rs +++ b/src/crates/assembly/core/src/agentic/session/session_store_port.rs @@ -156,6 +156,32 @@ impl CoreSessionStorePort { .is_some_and(|candidate| candidate == projects_root) } + fn project_runtime_sessions_kind( + path_manager: &PathManager, + path: &Path, + ) -> SessionStorageKind { + let Some(runtime_root) = path.parent() else { + return SessionStorageKind::Local; + }; + let state_path = runtime_root + .join("config") + .join("runtime_layout_state.json"); + let Ok(bytes) = std::fs::read(state_path) else { + return SessionStorageKind::Local; + }; + let Ok(state) = serde_json::from_slice::(&bytes) else { + return SessionStorageKind::Local; + }; + if state.get("target_kind").and_then(serde_json::Value::as_str) + == Some("remote_workspace_mirror") + && Self::is_confined_to_managed_root(&path_manager.projects_root(), path) + { + SessionStorageKind::Remote + } else { + SessionStorageKind::Local + } + } + pub(crate) fn resolved_sessions_dir_kind( path_manager: &PathManager, path: &Path, @@ -185,8 +211,11 @@ impl CoreSessionStorePort { .parent() .and_then(|runtime_root| runtime_root.parent()) .is_some_and(|candidate| candidate == projects_root.as_path()); - (has_local_shape && Self::is_confined_to_managed_root(&projects_root, path)) - .then_some(SessionStorageKind::Local) + if has_local_shape && Self::is_confined_to_managed_root(&projects_root, path) { + Some(Self::project_runtime_sessions_kind(path_manager, path)) + } else { + None + } } } diff --git a/src/crates/assembly/core/src/agentic/tools/file_permissions.rs b/src/crates/assembly/core/src/agentic/tools/file_permissions.rs index eb14963387..c71a052280 100644 --- a/src/crates/assembly/core/src/agentic/tools/file_permissions.rs +++ b/src/crates/assembly/core/src/agentic/tools/file_permissions.rs @@ -147,7 +147,10 @@ fn is_current_workspace_plan_path( return Ok(false); } - let plans_root = context.current_workspace_runtime_root()?.join("plans"); + let workspace_root = context.workspace_root().ok_or_else(|| { + OpenBitFunError::validation("A workspace is required for plan permissions".to_string()) + })?; + let plans_root = get_path_manager_arc().project_plans_dir(workspace_root); is_local_path_within_root(Path::new(&resolved.resolved_path), &plans_root) } @@ -157,8 +160,6 @@ fn openbitfun_managed_local_roots(context: &ToolUseContext) -> OpenBitFunResult< return Ok(roots); } - let runtime_root = context.current_workspace_runtime_root()?; - roots.push(runtime_root.join("plans")); if let Some(session_id) = context.session_id.as_deref() { roots.push( context @@ -317,7 +318,7 @@ mod tests { let workspace = temp.path().join("workspace"); let runtime_root = temp.path().join("runtime"); let terminal_root = temp.path().join("terminals"); - let plan = runtime_root.join("plans/plan.plan.md"); + let plan = workspace.join(".openbitfun/plans/plan.plan.md"); let reference = runtime_root.join("sessions/session-1/artifacts/session-references/ref.md"); let compression = runtime_root.join("sessions/session-1/artifacts/compression-transcripts/turn.md"); @@ -377,7 +378,7 @@ mod tests { let temp = tempfile::tempdir().expect("temp dir"); let workspace = temp.path().join("workspace"); let runtime_root = temp.path().join("runtime"); - let plan = runtime_root.join("plans/plan.plan.md"); + let plan = workspace.join(".openbitfun/plans/plan.plan.md"); let transcript = runtime_root.join("sessions/session-1/artifacts/compression-transcripts/turn.md"); fs::create_dir_all(&workspace).expect("workspace dir"); @@ -440,6 +441,25 @@ mod tests { assert_eq!(transcript_edit[0].action, "edit"); } + #[test] + fn project_openbitfun_files_are_not_globally_exempt_from_edit_permission() { + let temp = tempfile::tempdir().expect("temp dir"); + let workspace = temp.path().join("workspace"); + let config_file = workspace.join(".openbitfun/config/settings.json"); + fs::create_dir_all(config_file.parent().expect("config parent")).expect("config dir"); + fs::write(&config_file, "{}").expect("config file"); + + let context = + ToolUseContext::for_tool_listing(Some(WorkspaceBinding::new(None, workspace)), None); + let file_path = config_file.to_string_lossy(); + let intents = file_permission_intents("edit", [file_path.as_ref()], &context) + .expect("project config permission intent"); + + assert_eq!(intents.len(), 1); + assert_eq!(intents[0].action, "edit"); + assert_eq!(intents[0].resources.len(), 1); + } + #[test] fn unmanaged_runtime_paths_still_add_external_directory_intent() { let temp = tempfile::tempdir().expect("temp dir"); diff --git a/src/crates/assembly/core/src/agentic/workspace.rs b/src/crates/assembly/core/src/agentic/workspace.rs index c5bfd1722f..12e3be41b3 100644 --- a/src/crates/assembly/core/src/agentic/workspace.rs +++ b/src/crates/assembly/core/src/agentic/workspace.rs @@ -225,9 +225,7 @@ mod tests { use openbitfun_core_types::{ SessionExecutionTarget, SessionExecutionTargetKind, WorktreeLifecycle, }; - use openbitfun_services_core::workspace_identity::{ - remote_workspace_session_mirror_dir, workspace_session_identity, - }; + use openbitfun_services_core::workspace_identity::workspace_session_identity; use std::path::PathBuf; #[test] @@ -249,11 +247,9 @@ mod tests { assert!(matches!(binding.backend, WorkspaceBackend::Remote { .. })); assert_eq!( binding.session_storage_dir(), - remote_workspace_session_mirror_dir( - crate::infrastructure::get_path_manager_arc().remote_ssh_mirror_root_dir(), - "127.0.0.1", - "/home/wsp/projects/test" - ) + WorkspaceRuntimeService::new(crate::infrastructure::get_path_manager_arc()) + .context_for_remote_workspace("127.0.0.1", "/home/wsp/projects/test") + .sessions_dir ); } diff --git a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs index 5dd023d391..2ddba75cd0 100644 --- a/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs +++ b/src/crates/assembly/core/src/infrastructure/app_paths/path_manager.rs @@ -3,8 +3,11 @@ //! Provides unified management for all app storage paths, supporting user, project, and temporary levels use crate::util::errors::*; -use log::{debug, error}; +use log::{debug, error, warn}; use openbitfun_services_core::product_identity::{data_namespace, hidden_data_directory}; +use openbitfun_services_core::workspace_identity::{ + local_workspace_runtime_key, remote_workspace_runtime_key, remote_workspace_runtime_root, +}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; @@ -35,8 +38,8 @@ pub struct PathManager { /// Optional override for the product home directory, used by tests to avoid /// touching the real user home. product_home_override: Option, - /// Cache of runtime slugs keyed by the original and canonical workspace paths. - project_runtime_slug_cache: Arc>>, + /// Cache of runtime keys keyed by the original and canonical workspace paths. + project_runtime_key_cache: Arc>>, } impl PathManager { @@ -49,7 +52,7 @@ impl PathManager { Ok(Self { user_root, product_home_override, - project_runtime_slug_cache: Arc::new(Mutex::new(HashMap::new())), + project_runtime_key_cache: Arc::new(Mutex::new(HashMap::new())), }) } @@ -374,12 +377,134 @@ impl PathManager { self.product_home_dir().join("worktrees") } - /// Get the runtime root for a workspace: ~/.openbitfun/projects// + /// Get the runtime root for a workspace: ~/.openbitfun/projects/<24-hex-key>/ pub fn project_runtime_root(&self, workspace_path: &Path) -> PathBuf { + let runtime_root = self + .projects_root() + .join(self.project_runtime_key(workspace_path)); + self.migrate_legacy_project_runtime(workspace_path, &runtime_root) + } + + /// Get the legacy runtime root for a local workspace. + /// + /// This remains available only for lazy migration from the pre-24-hex + /// path scheme. + pub fn legacy_project_runtime_root(&self, workspace_path: &Path) -> PathBuf { self.projects_root() .join(self.project_runtime_slug(workspace_path)) } + /// Get the runtime root for a remote workspace: ~/.openbitfun/projects/<24-hex-key>/. + pub fn remote_workspace_runtime_root(&self, ssh_host: &str, remote_root_norm: &str) -> PathBuf { + let runtime_root = self + .projects_root() + .join(remote_workspace_runtime_key(ssh_host, remote_root_norm)); + self.migrate_legacy_remote_runtime(ssh_host, remote_root_norm, &runtime_root) + } + + fn project_runtime_key(&self, workspace_path: &Path) -> String { + let requested_path = workspace_path.to_path_buf(); + if let Some(key) = self.cached_project_runtime_key(&requested_path) { + return key; + } + + let canonical_path = + dunce::canonicalize(workspace_path).unwrap_or_else(|_| requested_path.clone()); + if canonical_path != requested_path { + if let Some(key) = self.cached_project_runtime_key(&canonical_path) { + self.store_project_runtime_key(&requested_path, &key); + return key; + } + } + + let canonical = canonical_path.to_string_lossy().replace('\\', "/"); + let key = local_workspace_runtime_key(&canonical); + self.store_project_runtime_key(&canonical_path, &key); + if canonical_path != requested_path { + self.store_project_runtime_key(&requested_path, &key); + } + key + } + + fn migrate_legacy_project_runtime( + &self, + workspace_path: &Path, + runtime_root: &Path, + ) -> PathBuf { + if runtime_root.exists() { + return runtime_root.to_path_buf(); + } + + let legacy_root = self.legacy_project_runtime_root(workspace_path); + if legacy_root == runtime_root || !legacy_root.is_dir() { + return runtime_root.to_path_buf(); + } + + if let Err(error) = std::fs::create_dir_all(self.projects_root()) { + warn!( + "Failed to prepare workspace projects root for runtime migration: root={}, error={}", + self.projects_root().display(), + error + ); + return legacy_root; + } + + if let Err(error) = std::fs::rename(&legacy_root, runtime_root) { + warn!( + "Failed to migrate legacy workspace runtime: legacy_root={}, runtime_root={}, error={}", + legacy_root.display(), + runtime_root.display(), + error + ); + if !runtime_root.exists() { + return legacy_root; + } + } + runtime_root.to_path_buf() + } + + fn migrate_legacy_remote_runtime( + &self, + ssh_host: &str, + remote_root_norm: &str, + runtime_root: &Path, + ) -> PathBuf { + if runtime_root.exists() { + return runtime_root.to_path_buf(); + } + + let legacy_root = remote_workspace_runtime_root( + self.remote_ssh_mirror_root_dir(), + ssh_host, + remote_root_norm, + ); + if legacy_root == runtime_root || !legacy_root.is_dir() { + return runtime_root.to_path_buf(); + } + + if let Err(error) = std::fs::create_dir_all(self.projects_root()) { + warn!( + "Failed to prepare workspace projects root for remote runtime migration: root={}, error={}", + self.projects_root().display(), + error + ); + return legacy_root; + } + + if let Err(error) = std::fs::rename(&legacy_root, runtime_root) { + warn!( + "Failed to migrate legacy remote workspace runtime: legacy_root={}, runtime_root={}, error={}", + legacy_root.display(), + runtime_root.display(), + error + ); + if !runtime_root.exists() { + return legacy_root; + } + } + runtime_root.to_path_buf() + } + /// Get project internal config directory: {project}/.openbitfun/config/ pub fn project_internal_config_dir(&self, workspace_path: &Path) -> PathBuf { self.project_root(workspace_path).join("config") @@ -430,19 +555,19 @@ impl PathManager { self.project_root(workspace_path).join("plugins") } - /// Get project snapshots directory: ~/.openbitfun/projects//snapshots/ + /// Get project snapshots directory: ~/.openbitfun/projects/<24-hex-key>/snapshots/ pub fn project_snapshots_dir(&self, workspace_path: &Path) -> PathBuf { self.project_runtime_root(workspace_path).join("snapshots") } - /// Get project sessions directory: ~/.openbitfun/projects//sessions/ + /// Get project sessions directory: ~/.openbitfun/projects/<24-hex-key>/sessions/ pub fn project_sessions_dir(&self, workspace_path: &Path) -> PathBuf { self.project_runtime_root(workspace_path).join("sessions") } - /// Get project plans directory: ~/.openbitfun/projects//plans/ + /// Get project plans directory: {project}/.openbitfun/plans/ pub fn project_plans_dir(&self, workspace_path: &Path) -> PathBuf { - self.project_runtime_root(workspace_path).join("plans") + self.project_root(workspace_path).join("plans") } /// Get the user-owned trust store for a workspace's product plugins. @@ -455,45 +580,26 @@ impl PathManager { .join("trust.json") } + /// Calculate the pre-24-hex slug used only to locate legacy directories. fn project_runtime_slug(&self, workspace_path: &Path) -> String { - let requested_path = workspace_path.to_path_buf(); - if let Some(slug) = self.cached_project_runtime_slug(&requested_path) { - return slug; - } - let canonical_path = - dunce::canonicalize(workspace_path).unwrap_or_else(|_| requested_path.clone()); - if canonical_path != requested_path { - if let Some(slug) = self.cached_project_runtime_slug(&canonical_path) { - self.store_project_runtime_slug(&requested_path, &slug); - return slug; - } - } - - let canonical = canonical_path.to_string_lossy().to_string(); - let slug = Self::build_project_runtime_slug(&canonical); - - self.store_project_runtime_slug(&canonical_path, &slug); - if canonical_path != requested_path { - self.store_project_runtime_slug(&requested_path, &slug); - } - - slug + dunce::canonicalize(workspace_path).unwrap_or_else(|_| workspace_path.to_path_buf()); + Self::build_project_runtime_slug(&canonical_path.to_string_lossy()) } - fn cached_project_runtime_slug(&self, workspace_path: &Path) -> Option { - self.project_runtime_slug_cache + fn cached_project_runtime_key(&self, workspace_path: &Path) -> Option { + self.project_runtime_key_cache .lock() - .expect("project runtime slug cache poisoned") + .expect("project runtime key cache poisoned") .get(workspace_path) .cloned() } - fn store_project_runtime_slug(&self, workspace_path: &Path, slug: &str) { - self.project_runtime_slug_cache + fn store_project_runtime_key(&self, workspace_path: &Path, key: &str) { + self.project_runtime_key_cache .lock() - .expect("project runtime slug cache poisoned") - .insert(workspace_path.to_path_buf(), slug.to_string()); + .expect("project runtime key cache poisoned") + .insert(workspace_path.to_path_buf(), key.to_string()); } pub(crate) fn build_project_runtime_slug(canonical: &str) -> String { @@ -575,7 +681,7 @@ impl Default for PathManager { Self { user_root: std::env::temp_dir().join("openbitfun"), product_home_override: Self::get_product_home_override(), - project_runtime_slug_cache: Arc::new(Mutex::new(HashMap::new())), + project_runtime_key_cache: Arc::new(Mutex::new(HashMap::new())), } } } @@ -592,7 +698,7 @@ impl PathManager { Self { user_root, product_home_override: Some(base.join("home").join(".openbitfun")), - project_runtime_slug_cache: Arc::new(Mutex::new(HashMap::new())), + project_runtime_key_cache: Arc::new(Mutex::new(HashMap::new())), } } } @@ -775,7 +881,7 @@ mod tests { } #[test] - fn project_runtime_root_uses_human_readable_workspace_slug() { + fn project_runtime_root_uses_compact_hex_key() { let pm = PathManager::default(); let runtime_root = pm.project_runtime_root(Path::new(r"E:\Projects\OpenBitFun\Source")); let slug = runtime_root @@ -783,7 +889,8 @@ mod tests { .and_then(|value| value.to_str()) .expect("runtime root should have terminal component"); - assert!(slug.starts_with("e--projects-openbitfun-source")); + assert_eq!(slug.len(), 24); + assert!(slug.chars().all(|ch| ch.is_ascii_hexdigit())); assert_eq!(runtime_root.parent(), Some(pm.projects_root().as_path())); } @@ -814,12 +921,12 @@ mod tests { } #[test] - fn plugin_trust_path_distinguishes_workspace_slug_collisions() { + fn runtime_key_distinguishes_workspace_slug_collisions() { let pm = PathManager::default(); let first = Path::new("workspace-a"); let second = Path::new("workspace_a"); - assert_eq!( + assert_ne!( pm.project_runtime_root(first), pm.project_runtime_root(second) ); @@ -827,6 +934,86 @@ mod tests { pm.project_plugin_trust_file(first), pm.project_plugin_trust_file(second) ); + + let chinese = Path::new("workspace-δΈ­ζ–‡"); + let emoji = Path::new("workspace-πŸ˜€"); + assert_ne!( + pm.project_runtime_root(chinese), + pm.project_runtime_root(emoji) + ); + } + + #[test] + fn project_runtime_root_lazily_migrates_legacy_directory() { + let base = std::env::temp_dir().join(format!( + "openbitfun-runtime-migration-{}", + uuid::Uuid::new_v4() + )); + let workspace = base.join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace should exist"); + let pm = PathManager::with_user_root_for_tests(base.join("user")); + let legacy = pm.legacy_project_runtime_root(&workspace); + std::fs::create_dir_all(legacy.join("sessions")).expect("legacy runtime should exist"); + std::fs::create_dir_all(legacy.join("plans")).expect("legacy plans should exist"); + std::fs::write( + legacy.join("plans").join("legacy.plan.md"), + b"legacy plans marker", + ) + .expect("legacy plan marker should be written"); + + let runtime = pm.project_runtime_root(&workspace); + + assert_eq!( + runtime + .file_name() + .and_then(|name| name.to_str()) + .map(str::len), + Some(24) + ); + assert!(runtime.join("plans").join("legacy.plan.md").exists()); + assert!(!legacy.exists()); + } + + #[test] + fn remote_runtime_root_uses_compact_key_and_migrates_legacy_directory() { + let base = std::env::temp_dir().join(format!( + "openbitfun-remote-runtime-migration-{}", + uuid::Uuid::new_v4() + )); + let pm = PathManager::with_user_root_for_tests(base.join("user")); + let host = "Example.COM"; + let remote_root = "/root/repo"; + let legacy = openbitfun_services_core::workspace_identity::remote_workspace_runtime_root( + pm.remote_ssh_mirror_root_dir(), + host, + remote_root, + ); + std::fs::create_dir_all(legacy.join("sessions")).expect("legacy runtime should exist"); + std::fs::write(legacy.join("sessions").join("marker"), b"legacy") + .expect("legacy marker should be written"); + + let runtime = pm.remote_workspace_runtime_root(host, remote_root); + + assert_eq!( + runtime + .file_name() + .and_then(|name| name.to_str()) + .map(str::len), + Some(24) + ); + assert!(runtime.join("sessions").join("marker").exists()); + assert!(!legacy.exists()); + } + + #[test] + fn project_plans_live_under_project_local_product_directory() { + let pm = PathManager::default(); + let workspace = Path::new("workspace"); + + assert_eq!( + pm.project_plans_dir(workspace), + workspace.join(".openbitfun").join("plans") + ); } #[test] diff --git a/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs b/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs index 70530f32b2..8e412e25f3 100644 --- a/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs +++ b/src/crates/assembly/core/src/service/remote_ssh/workspace_state.rs @@ -75,22 +75,16 @@ pub async fn resolve_workspace_session_identity( None } -/// Local directory where persisted sessions for this remote workspace root are stored. +/// Runtime directory for this remote workspace root. pub fn remote_workspace_runtime_root(ssh_host: &str, remote_root_norm: &str) -> PathBuf { - openbitfun_services_integrations::remote_ssh::remote_workspace_runtime_root( - get_path_manager_arc().remote_ssh_mirror_root_dir(), - ssh_host, - remote_root_norm, - ) + get_path_manager_arc().remote_workspace_runtime_root(ssh_host, remote_root_norm) } -/// Local directory where persisted sessions for this remote workspace root are stored. +/// Runtime sessions directory for this remote workspace root. pub fn remote_workspace_session_mirror_dir(ssh_host: &str, remote_root_norm: &str) -> PathBuf { - openbitfun_services_integrations::remote_ssh::remote_workspace_session_mirror_dir( - get_path_manager_arc().remote_ssh_mirror_root_dir(), - ssh_host, - remote_root_norm, - ) + WorkspaceRuntimeService::new(get_path_manager_arc()) + .context_for_remote_workspace(ssh_host, remote_root_norm) + .sessions_dir } /// Canonical local root [`PathBuf`] plus normalized string form (single `canonicalize` call). @@ -296,7 +290,7 @@ impl RemoteWorkspaceStateManager { // ── Session storage ──────────────────────────────────────────── - /// Local mirror directory for persisted sessions (`~/.openbitfun/remote_ssh/.../sessions`). + /// Runtime directory for persisted sessions (`~/.openbitfun/projects/<24-hex>/sessions`). pub fn get_remote_session_mirror_path( &self, ssh_host: &str, @@ -306,8 +300,7 @@ impl RemoteWorkspaceStateManager { } /// Map a workspace path to the final on-disk sessions directory. - /// Local roots map to `~/.openbitfun/projects//sessions`; - /// remote roots map to the local SSH mirror sessions dir. + /// Local and remote roots map to their compact runtime-key sessions dir. pub async fn get_effective_session_path( &self, workspace_path: &str, diff --git a/src/crates/assembly/core/src/service/remote_ssh_compat.rs b/src/crates/assembly/core/src/service/remote_ssh_compat.rs index bdb33ae569..a57d8e5c83 100644 --- a/src/crates/assembly/core/src/service/remote_ssh_compat.rs +++ b/src/crates/assembly/core/src/service/remote_ssh_compat.rs @@ -46,19 +46,16 @@ pub mod workspace_state { } pub fn remote_workspace_runtime_root(ssh_host: &str, remote_root_norm: &str) -> PathBuf { - openbitfun_services_core::workspace_identity::remote_workspace_runtime_root( - crate::infrastructure::get_path_manager_arc().remote_ssh_mirror_root_dir(), - ssh_host, - remote_root_norm, - ) + crate::infrastructure::get_path_manager_arc() + .remote_workspace_runtime_root(ssh_host, remote_root_norm) } pub fn remote_workspace_session_mirror_dir(ssh_host: &str, remote_root_norm: &str) -> PathBuf { - openbitfun_services_core::workspace_identity::remote_workspace_session_mirror_dir( - crate::infrastructure::get_path_manager_arc().remote_ssh_mirror_root_dir(), - ssh_host, - remote_root_norm, + crate::service::workspace_runtime::WorkspaceRuntimeService::new( + crate::infrastructure::get_path_manager_arc(), ) + .context_for_remote_workspace(ssh_host, remote_root_norm) + .sessions_dir } pub fn unresolved_remote_session_storage_dir( diff --git a/src/crates/assembly/core/src/service/workspace/legacy_compat.rs b/src/crates/assembly/core/src/service/workspace/legacy_compat.rs index 9bf85aff4a..4d358239ba 100644 --- a/src/crates/assembly/core/src/service/workspace/legacy_compat.rs +++ b/src/crates/assembly/core/src/service/workspace/legacy_compat.rs @@ -716,7 +716,7 @@ mod tests { assert_eq!(resolution.requested_workspace_path, record.root_path); assert!(resolution .effective_storage_path - .starts_with(paths.remote_ssh_mirror_root_dir())); + .starts_with(paths.projects_root())); assert_ne!(resolution.effective_storage_path, record.root_path); assert!(CoreSessionStorePort::with_path_manager_for_tests(paths) .resolve_workspace_storage(&logical_root) diff --git a/src/crates/assembly/core/src/service/workspace_runtime/service.rs b/src/crates/assembly/core/src/service/workspace_runtime/service.rs index 361a17fb45..b0b7050f0c 100644 --- a/src/crates/assembly/core/src/service/workspace_runtime/service.rs +++ b/src/crates/assembly/core/src/service/workspace_runtime/service.rs @@ -7,10 +7,7 @@ use crate::agentic::WorkspaceBinding; use crate::infrastructure::{get_path_manager_arc, PathManager}; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; use log::debug; -use openbitfun_services_core::workspace_identity::{ - normalize_remote_workspace_path, remote_root_to_mirror_subpath, - sanitize_ssh_hostname_for_mirror, -}; +use openbitfun_services_core::workspace_identity::normalize_remote_workspace_path; use serde::Serialize; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -230,10 +227,7 @@ impl WorkspaceRuntimeService { fn remote_workspace_runtime_root(&self, ssh_host: &str, remote_root_norm: &str) -> PathBuf { self.path_manager - .product_home_dir() - .join("remote_ssh") - .join(sanitize_ssh_hostname_for_mirror(ssh_host)) - .join(remote_root_to_mirror_subpath(remote_root_norm)) + .remote_workspace_runtime_root(ssh_host, remote_root_norm) } } diff --git a/src/crates/assembly/core/src/service/workspace_runtime/types.rs b/src/crates/assembly/core/src/service/workspace_runtime/types.rs index 07bf55f723..3092b454a3 100644 --- a/src/crates/assembly/core/src/service/workspace_runtime/types.rs +++ b/src/crates/assembly/core/src/service/workspace_runtime/types.rs @@ -34,7 +34,6 @@ pub struct WorkspaceRuntimeContext { pub snapshot_metadata_dir: PathBuf, pub snapshot_baselines_dir: PathBuf, pub snapshot_operations_dir: PathBuf, - pub plans_dir: PathBuf, pub locks_dir: PathBuf, pub config_dir: PathBuf, pub isolation_status_file: PathBuf, @@ -54,7 +53,6 @@ impl WorkspaceRuntimeContext { snapshot_metadata_dir: snapshots_dir.join("metadata"), snapshot_baselines_dir: snapshots_dir.join("baselines"), snapshot_operations_dir: snapshots_dir.join("operations"), - plans_dir: runtime_root.join("plans"), locks_dir: runtime_root.join("locks"), isolation_status_file: config_dir.join("isolation_status.json"), layout_state_file: config_dir.join("runtime_layout_state.json"), @@ -74,7 +72,6 @@ impl WorkspaceRuntimeContext { self.snapshot_metadata_dir.as_path(), self.snapshot_baselines_dir.as_path(), self.snapshot_operations_dir.as_path(), - self.plans_dir.as_path(), self.locks_dir.as_path(), self.config_dir.as_path(), ] diff --git a/src/crates/services/services-core/src/workspace_identity.rs b/src/crates/services/services-core/src/workspace_identity.rs index 616dbc0f08..931c615a41 100644 --- a/src/crates/services/services-core/src/workspace_identity.rs +++ b/src/crates/services/services-core/src/workspace_identity.rs @@ -287,6 +287,28 @@ fn hash_host_and_root(host: &str, root_norm: &str) -> String { hex_encode(&hasher.finalize()[..16]) } +/// Build the compact runtime-directory key for a local workspace. +/// +/// Runtime keys intentionally omit a product or workspace-kind prefix from +/// the directory name. The domain marker remains part of the hashed input so +/// local and remote identities cannot accidentally share a key. +pub fn local_workspace_runtime_key(canonical_root_norm: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"runtime-local\0"); + hasher.update(canonical_root_norm.as_bytes()); + hex_encode(&hasher.finalize()[..12]) +} + +/// Build the compact runtime-directory key for a remote workspace. +pub fn remote_workspace_runtime_key(ssh_host: &str, remote_root_norm: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(b"runtime-remote\0"); + hasher.update(ssh_host.trim().to_lowercase().as_bytes()); + hasher.update(b"\0"); + hasher.update(remote_root_norm.as_bytes()); + hex_encode(&hasher.finalize()[..12]) +} + /// Stable storage id for a local workspace (`localhost` + canonical absolute root). pub fn local_workspace_stable_storage_id(canonical_root_norm: &str) -> String { format!(