use anyhow::{Context, Result, bail};
use everruns_core::session_path::to_session_path;
use everruns_runtime::RealDiskFileStore;
use std::ops::Deref;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex, RwLock};
pub struct WorkspaceHost {
active_root: Arc<RwLock<PathBuf>>,
disk: Arc<RealDiskFileStore>,
applied_root: Mutex<PathBuf>,
}
impl WorkspaceHost {
pub fn new(active_root: Arc<RwLock<PathBuf>>, initial: PathBuf) -> everruns_core::Result<Self> {
Ok(Self {
disk: Arc::new(RealDiskFileStore::new(initial.clone())?),
applied_root: Mutex::new(initial),
active_root,
})
}
pub fn disk(&self) -> Arc<RealDiskFileStore> {
self.disk.clone()
}
pub fn sync(&self) -> everruns_core::Result<()> {
use everruns_core::error::AgentLoopError;
let current = self
.active_root
.read()
.map_err(|_| AgentLoopError::config("workspace lock poisoned"))?
.clone();
let mut applied = self
.applied_root
.lock()
.map_err(|_| AgentLoopError::config("workspace root lock poisoned"))?;
if *applied != current {
if current.is_dir() {
self.disk.set_host_root(current.clone())?;
}
*applied = current;
}
Ok(())
}
pub fn host_root(&self) -> everruns_core::Result<PathBuf> {
self.sync()?;
Ok(self.disk.root())
}
pub fn spawn_cwd(&self) -> Result<PathBuf, String> {
let current = self
.active_root
.read()
.map(|guard| guard.clone())
.map_err(|_| "workspace lock poisoned".to_string())?;
if !current.is_dir() {
return Err(format!(
"workspace directory does not exist: {}",
current.display()
));
}
self.sync().map_err(|e| e.to_string())?;
Ok(self.disk.root())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostPath(PathBuf);
impl HostPath {
pub fn as_path(&self) -> &Path {
&self.0
}
pub fn into_path_buf(self) -> PathBuf {
self.0
}
}
impl Deref for HostPath {
type Target = Path;
fn deref(&self) -> &Path {
&self.0
}
}
impl AsRef<Path> for HostPath {
fn as_ref(&self) -> &Path {
&self.0
}
}
pub fn resolve_host_scope(root: &Path, path: Option<&str>) -> Result<HostPath> {
let Some(path) = path else {
return Ok(HostPath(root.to_path_buf()));
};
let trimmed = path.trim();
let candidate = Path::new(trimmed);
if candidate.is_absolute()
&& let Ok(canonical) = candidate.canonicalize()
{
if canonical.starts_with(root) {
return Ok(HostPath(canonical));
}
bail!("`path` must stay inside the workspace");
}
let session = to_session_path(trimmed);
resolve_relative_scope(root, session.trim_start_matches('/'), path).map(HostPath)
}
fn resolve_relative_scope(root: &Path, relative: &str, original: &str) -> Result<PathBuf> {
if relative.is_empty() || relative == "." {
return Ok(root.to_path_buf());
}
let candidate = Path::new(relative);
if candidate.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
bail!("`path` must stay inside the workspace");
}
let scope = root.join(candidate);
let canonical = scope
.canonicalize()
.with_context(|| format!("path not found: {original}"))?;
if !canonical.starts_with(root) {
bail!("`path` must stay inside the workspace");
}
Ok(canonical)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn resolve_host_scope_accepts_host_and_relative_paths() {
let dir = tempfile::tempdir().expect("tempdir");
fs::create_dir_all(dir.path().join("pkg")).expect("pkg dir");
let root = fs::canonicalize(dir.path()).expect("canonical root");
let host_path = root.join("pkg");
let scope = resolve_host_scope(&root, host_path.to_str()).expect("host subpath");
assert_eq!(scope, HostPath(root.join("pkg")));
let root_scope = resolve_host_scope(&root, root.to_str()).expect("host root");
assert_eq!(root_scope, HostPath(root.clone()));
let relative = resolve_host_scope(&root, Some("pkg")).expect("relative subpath");
assert_eq!(relative, HostPath(root.join("pkg")));
}
#[test]
fn resolve_host_scope_spelling_table_is_consistent() {
let dir = tempfile::tempdir().expect("tempdir");
fs::create_dir_all(dir.path().join("pkg")).expect("pkg dir");
let root = fs::canonicalize(dir.path()).expect("canonical root");
let root_spellings = [
None,
Some(".".to_string()),
Some("/workspace".to_string()),
Some("/workspace/".to_string()),
Some("//workspace//".to_string()),
Some(root.to_str().expect("root utf8").to_string()),
];
for spelling in root_spellings {
let scope = resolve_host_scope(&root, spelling.as_deref())
.unwrap_or_else(|e| panic!("root spelling {spelling:?}: {e}"));
assert_eq!(scope.as_path(), root, "root spelling {spelling:?}");
}
let pkg = root.join("pkg");
let pkg_spellings = [
"pkg",
"/pkg", "/workspace/pkg", "//workspace//pkg", pkg.to_str().expect("pkg utf8"), ];
for spelling in pkg_spellings {
let scope = resolve_host_scope(&root, Some(spelling))
.unwrap_or_else(|e| panic!("pkg spelling {spelling:?}: {e}"));
assert_eq!(scope.as_path(), pkg, "pkg spelling {spelling:?}");
}
for escape in ["/workspace/../outside", "../outside", "//workspace//../x"] {
assert!(
resolve_host_scope(&root, Some(escape)).is_err(),
"escape {escape:?} must be rejected"
);
}
let err = resolve_host_scope(&root, Some("/workspacefoo")).expect_err("near miss");
assert!(err.to_string().contains("path not found"));
}
#[test]
fn resolve_host_scope_rejects_escape() {
let dir = tempfile::tempdir().expect("tempdir");
let root = fs::canonicalize(dir.path()).expect("canonical root");
let err = resolve_host_scope(&root, Some("../outside")).expect_err("escape");
assert!(err.to_string().contains("inside the workspace"));
}
#[test]
fn workspace_host_repoints_on_active_root_change() {
let first = tempfile::tempdir().expect("first");
let second = tempfile::tempdir().expect("second");
let active = Arc::new(RwLock::new(first.path().to_path_buf()));
let host = WorkspaceHost::new(active.clone(), first.path().to_path_buf()).expect("host");
assert_eq!(host.host_root().expect("root"), host.disk().root());
*active.write().expect("lock") = second.path().to_path_buf();
host.sync().expect("sync");
assert_eq!(
host.disk().root(),
fs::canonicalize(second.path()).expect("canonical second")
);
}
#[test]
fn spawn_cwd_requires_existing_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let active = Arc::new(RwLock::new(dir.path().to_path_buf()));
let host = WorkspaceHost::new(active, dir.path().to_path_buf()).expect("host");
assert_eq!(host.spawn_cwd().expect("existing dir"), host.disk().root());
let missing = dir.path().join("removed");
*host.active_root.write().expect("lock") = missing.clone();
host.sync().expect("sync");
let err = host.spawn_cwd().expect_err("missing dir");
assert!(err.contains("does not exist"));
}
}