use super::LocalWorkspace;
use agent_workspace_contract::{
PreviewLifecycleProvider, ProvisionedWorkspace, StoredPreview, Workspace, WorkspaceConfig,
WorkspaceProviderAdapter, WorkspaceProviderRegistry, WorkspaceRecord, WorkspaceResourceUsage,
};
use anyhow::{Context, Result};
use async_trait::async_trait;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct LocalWorkspaceProvider {
base_root: PathBuf,
}
impl LocalWorkspaceProvider {
pub fn for_local_development(base_root: PathBuf) -> Result<Self> {
std::fs::create_dir_all(&base_root)?;
Ok(Self {
base_root: std::fs::canonicalize(base_root)?,
})
}
fn workspace_root(&self, id: &str) -> Result<PathBuf> {
anyhow::ensure!(
!id.is_empty()
&& id.len() <= 128
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')),
"invalid workspace id"
);
Ok(self.base_root.join(id))
}
fn checked_backend_root(&self, id: &str) -> Result<PathBuf> {
let path = PathBuf::from(id);
let parent = path
.parent()
.context("workspace backend root has no parent")?;
anyhow::ensure!(
std::fs::canonicalize(parent)? == self.base_root,
"workspace backend escapes provider root"
);
Ok(path)
}
}
#[async_trait]
impl WorkspaceProviderAdapter for LocalWorkspaceProvider {
async fn provision(
&self,
_tenant_id: &str,
workspace_id: &str,
_config: &WorkspaceConfig,
_key: &str,
) -> Result<ProvisionedWorkspace> {
let root = self.workspace_root(workspace_id)?;
tokio::fs::create_dir_all(&root).await?;
let root = tokio::fs::canonicalize(root).await?;
Ok(ProvisionedWorkspace {
durable_backend_id: root.display().to_string(),
root: root.display().to_string(),
})
}
async fn reconnect(&self, id: &str) -> Result<()> {
let root = self.checked_backend_root(id)?;
anyhow::ensure!(
tokio::fs::metadata(root).await?.is_dir(),
"workspace backend is unavailable"
);
Ok(())
}
async fn suspend(&self, id: &str, _key: &str) -> Result<()> {
self.reconnect(id).await
}
async fn resume(&self, id: &str, _key: &str) -> Result<()> {
self.reconnect(id).await
}
async fn inspect(&self, record: &WorkspaceRecord) -> Result<WorkspaceResourceUsage> {
LocalWorkspace::new(self.checked_backend_root(&record.durable_backend_id)?)
.resource_usage()
.await
}
async fn delete(&self, id: &str, key: &str) -> Result<()> {
let root = self.checked_backend_root(id)?;
if !tokio::fs::try_exists(&root).await? {
return Ok(());
}
let trash = self.base_root.join(format!(
".trash-{}",
agent_workspace_contract::content_revision(key.as_bytes())
.trim_start_matches("sha256:")
));
if !tokio::fs::try_exists(&trash).await? {
tokio::fs::rename(root, trash).await?;
}
Ok(())
}
async fn cancel_command(&self, _id: &str, _command_id: &str) -> Result<()> {
anyhow::bail!("local provider command cancellation is handled by LocalHostExecutor")
}
}
#[derive(Debug, Clone)]
pub struct LocalWorkspaceProviderRegistry {
provider: Arc<LocalWorkspaceProvider>,
}
impl LocalWorkspaceProviderRegistry {
pub fn new(provider: LocalWorkspaceProvider) -> Self {
Self {
provider: Arc::new(provider),
}
}
}
#[async_trait]
impl WorkspaceProviderRegistry for LocalWorkspaceProviderRegistry {
async fn provider(&self, backend: &str) -> Result<Arc<dyn WorkspaceProviderAdapter>> {
anyhow::ensure!(
matches!(backend, "local" | "local-test"),
"unsupported local workspace backend"
);
Ok(self.provider.clone())
}
}
#[derive(Debug, Clone, Default)]
pub struct LocalPreviewProvider;
#[async_trait]
impl PreviewLifecycleProvider for LocalPreviewProvider {
async fn create_preview(
&self,
backend_id: &str,
request_id: &str,
port: u16,
ttl_ms: u64,
) -> Result<StoredPreview> {
anyhow::ensure!(
!backend_id.trim().is_empty(),
"workspace backend id is required"
);
Ok(StoredPreview {
id: request_id.into(),
tenant_id: String::new(),
workspace_id: String::new(),
provider_ref: format!("local:{backend_id}:{port}"),
url: format!("http://localhost:{port}"),
state: "ready".into(),
expires_at_ms: ttl_ms,
version: 1,
request_digest: String::new(),
})
}
async fn stop_preview(&self, provider_ref: &str) -> Result<()> {
anyhow::ensure!(
provider_ref.starts_with("local:"),
"invalid local preview reference"
);
Ok(())
}
}