Skip to main content

agent_workspace_adapter_local/
provider.rs

1use super::LocalWorkspace;
2use agent_workspace_contract::{
3    PreviewLifecycleProvider, ProvisionedWorkspace, StoredPreview, Workspace, WorkspaceConfig,
4    WorkspaceProviderAdapter, WorkspaceProviderRegistry, WorkspaceRecord, WorkspaceResourceUsage,
5};
6use anyhow::{Context, Result};
7use async_trait::async_trait;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11#[derive(Debug, Clone)]
12pub struct LocalWorkspaceProvider {
13    base_root: PathBuf,
14}
15
16impl LocalWorkspaceProvider {
17    pub fn for_local_development(base_root: PathBuf) -> Result<Self> {
18        std::fs::create_dir_all(&base_root)?;
19        Ok(Self {
20            base_root: std::fs::canonicalize(base_root)?,
21        })
22    }
23    fn workspace_root(&self, id: &str) -> Result<PathBuf> {
24        anyhow::ensure!(
25            !id.is_empty()
26                && id.len() <= 128
27                && id
28                    .bytes()
29                    .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')),
30            "invalid workspace id"
31        );
32        Ok(self.base_root.join(id))
33    }
34    fn checked_backend_root(&self, id: &str) -> Result<PathBuf> {
35        let path = PathBuf::from(id);
36        let parent = path
37            .parent()
38            .context("workspace backend root has no parent")?;
39        anyhow::ensure!(
40            std::fs::canonicalize(parent)? == self.base_root,
41            "workspace backend escapes provider root"
42        );
43        Ok(path)
44    }
45}
46
47#[async_trait]
48impl WorkspaceProviderAdapter for LocalWorkspaceProvider {
49    async fn provision(
50        &self,
51        _tenant_id: &str,
52        workspace_id: &str,
53        _config: &WorkspaceConfig,
54        _key: &str,
55    ) -> Result<ProvisionedWorkspace> {
56        let root = self.workspace_root(workspace_id)?;
57        tokio::fs::create_dir_all(&root).await?;
58        let root = tokio::fs::canonicalize(root).await?;
59        Ok(ProvisionedWorkspace {
60            durable_backend_id: root.display().to_string(),
61            root: root.display().to_string(),
62        })
63    }
64    async fn reconnect(&self, id: &str) -> Result<()> {
65        let root = self.checked_backend_root(id)?;
66        anyhow::ensure!(
67            tokio::fs::metadata(root).await?.is_dir(),
68            "workspace backend is unavailable"
69        );
70        Ok(())
71    }
72    async fn suspend(&self, id: &str, _key: &str) -> Result<()> {
73        self.reconnect(id).await
74    }
75    async fn resume(&self, id: &str, _key: &str) -> Result<()> {
76        self.reconnect(id).await
77    }
78    async fn inspect(&self, record: &WorkspaceRecord) -> Result<WorkspaceResourceUsage> {
79        LocalWorkspace::new(self.checked_backend_root(&record.durable_backend_id)?)
80            .resource_usage()
81            .await
82    }
83    async fn delete(&self, id: &str, key: &str) -> Result<()> {
84        let root = self.checked_backend_root(id)?;
85        if !tokio::fs::try_exists(&root).await? {
86            return Ok(());
87        }
88        let trash = self.base_root.join(format!(
89            ".trash-{}",
90            agent_workspace_contract::content_revision(key.as_bytes())
91                .trim_start_matches("sha256:")
92        ));
93        if !tokio::fs::try_exists(&trash).await? {
94            tokio::fs::rename(root, trash).await?;
95        }
96        Ok(())
97    }
98    async fn cancel_command(&self, _id: &str, _command_id: &str) -> Result<()> {
99        anyhow::bail!("local provider command cancellation is handled by LocalHostExecutor")
100    }
101}
102
103#[derive(Debug, Clone)]
104pub struct LocalWorkspaceProviderRegistry {
105    provider: Arc<LocalWorkspaceProvider>,
106}
107impl LocalWorkspaceProviderRegistry {
108    pub fn new(provider: LocalWorkspaceProvider) -> Self {
109        Self {
110            provider: Arc::new(provider),
111        }
112    }
113}
114#[async_trait]
115impl WorkspaceProviderRegistry for LocalWorkspaceProviderRegistry {
116    async fn provider(&self, backend: &str) -> Result<Arc<dyn WorkspaceProviderAdapter>> {
117        anyhow::ensure!(
118            matches!(backend, "local" | "local-test"),
119            "unsupported local workspace backend"
120        );
121        Ok(self.provider.clone())
122    }
123}
124
125#[derive(Debug, Clone, Default)]
126pub struct LocalPreviewProvider;
127#[async_trait]
128impl PreviewLifecycleProvider for LocalPreviewProvider {
129    async fn create_preview(
130        &self,
131        backend_id: &str,
132        request_id: &str,
133        port: u16,
134        ttl_ms: u64,
135    ) -> Result<StoredPreview> {
136        anyhow::ensure!(
137            !backend_id.trim().is_empty(),
138            "workspace backend id is required"
139        );
140        Ok(StoredPreview {
141            id: request_id.into(),
142            tenant_id: String::new(),
143            workspace_id: String::new(),
144            provider_ref: format!("local:{backend_id}:{port}"),
145            url: format!("http://localhost:{port}"),
146            state: "ready".into(),
147            expires_at_ms: ttl_ms,
148            version: 1,
149            request_digest: String::new(),
150        })
151    }
152    async fn stop_preview(&self, provider_ref: &str) -> Result<()> {
153        anyhow::ensure!(
154            provider_ref.starts_with("local:"),
155            "invalid local preview reference"
156        );
157        Ok(())
158    }
159}