Skip to main content

agent_workspace_contract/environment/
catalog.rs

1//! Scope-owned Environment Image catalog and provider build contracts.
2
3use super::*;
4
5/// Provider-owned images used when product create APIs omit every Workspace
6/// catalog snapshot/template selector. Scope-owned snapshots are selected only
7/// by an explicit snapshot id.
8pub const E2B_PLATFORM_TEMPLATE_ID: &str = "code-interpreter-v1";
9pub const E2B_BUILTIN_TEMPLATE_IDS: [&str; 3] = [
10    E2B_PLATFORM_TEMPLATE_ID,
11    "context-management-moc",
12    "vpc-sandbox",
13];
14pub const MOULIN_PLATFORM_SNAPSHOT_ID: &str = "python";
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
17#[serde(rename_all = "snake_case")]
18pub enum SnapshotVisibility {
19    #[default]
20    Private,
21    Public,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
25#[serde(tag = "kind", rename_all = "snake_case")]
26pub enum SnapshotBuildSpec {
27    Dockerfile {
28        dockerfile: String,
29    },
30    Template {
31        #[serde(alias = "pipPackages")]
32        pip_packages: String,
33    },
34    Git {
35        #[serde(rename = "gitUrl", alias = "git_url")]
36        git_url: String,
37        #[serde(default, rename = "commitSha", alias = "commit_sha")]
38        commit_sha: String,
39    },
40}
41
42/// Provider identity at the Workspace image-build boundary. Product records
43/// may remain wire-compatible strings, while build adapters cannot receive an
44/// unknown provider.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum WorkspaceBuildProvider {
47    Moulin,
48    E2b,
49    Custom(String),
50}
51
52impl WorkspaceBuildProvider {
53    pub fn parse(value: &str) -> Result<Self> {
54        match value {
55            "moulin" => Ok(Self::Moulin),
56            "e2b" => Ok(Self::E2b),
57            other if !other.trim().is_empty() => Ok(Self::Custom(other.into())),
58            _ => Err(anyhow::anyhow!("Workspace build provider is required")),
59        }
60    }
61
62    pub fn as_str(&self) -> &str {
63        match self {
64            Self::Moulin => "moulin",
65            Self::E2b => "e2b",
66            Self::Custom(value) => value,
67        }
68    }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum MoulinImageBuildRecipe {
73    Dockerfile { dockerfile: String },
74    Git { git_url: String, commit_sha: String },
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum E2bImageBuildRecipe {
79    Dockerfile { dockerfile: String },
80    Template { pip_packages: String },
81    Git { git_url: String, commit_sha: String },
82}
83
84/// Type-driven provider dispatch. Invalid combinations such as a Moulin
85/// template build cannot cross the application-to-adapter port.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum EnvironmentImageBuildRequest {
88    Moulin(MoulinImageBuildRecipe),
89    E2b(E2bImageBuildRecipe),
90    Custom {
91        provider: String,
92        recipe: SnapshotBuildSpec,
93    },
94}
95
96impl EnvironmentImageBuildRequest {
97    pub fn from_parts(provider: &str, spec: &SnapshotBuildSpec) -> Result<Self> {
98        match (WorkspaceBuildProvider::parse(provider)?, spec) {
99            (WorkspaceBuildProvider::Moulin, SnapshotBuildSpec::Dockerfile { dockerfile }) => {
100                Ok(Self::Moulin(MoulinImageBuildRecipe::Dockerfile {
101                    dockerfile: dockerfile.clone(),
102                }))
103            }
104            (
105                WorkspaceBuildProvider::Moulin,
106                SnapshotBuildSpec::Git {
107                    git_url,
108                    commit_sha,
109                },
110            ) => Ok(Self::Moulin(MoulinImageBuildRecipe::Git {
111                git_url: git_url.clone(),
112                commit_sha: commit_sha.clone(),
113            })),
114            (WorkspaceBuildProvider::E2b, SnapshotBuildSpec::Dockerfile { dockerfile }) => {
115                Ok(Self::E2b(E2bImageBuildRecipe::Dockerfile {
116                    dockerfile: dockerfile.clone(),
117                }))
118            }
119            (WorkspaceBuildProvider::E2b, SnapshotBuildSpec::Template { pip_packages }) => {
120                Ok(Self::E2b(E2bImageBuildRecipe::Template {
121                    pip_packages: pip_packages.clone(),
122                }))
123            }
124            (
125                WorkspaceBuildProvider::E2b,
126                SnapshotBuildSpec::Git {
127                    git_url,
128                    commit_sha,
129                },
130            ) => Ok(Self::E2b(E2bImageBuildRecipe::Git {
131                git_url: git_url.clone(),
132                commit_sha: commit_sha.clone(),
133            })),
134            (WorkspaceBuildProvider::Moulin, SnapshotBuildSpec::Template { .. }) => Err(
135                anyhow::anyhow!("Moulin does not support E2B template image recipes"),
136            ),
137            (WorkspaceBuildProvider::Custom(provider), recipe) => Ok(Self::Custom {
138                provider,
139                recipe: recipe.clone(),
140            }),
141        }
142    }
143
144    pub fn provider(&self) -> WorkspaceBuildProvider {
145        match self {
146            Self::Moulin(_) => WorkspaceBuildProvider::Moulin,
147            Self::E2b(_) => WorkspaceBuildProvider::E2b,
148            Self::Custom { provider, .. } => WorkspaceBuildProvider::Custom(provider.clone()),
149        }
150    }
151
152    pub fn provider_name(&self) -> &str {
153        match self {
154            Self::Moulin(_) => "moulin",
155            Self::E2b(_) => "e2b",
156            Self::Custom { provider, .. } => provider,
157        }
158    }
159
160    pub fn snapshot_spec(&self) -> SnapshotBuildSpec {
161        match self {
162            Self::Moulin(MoulinImageBuildRecipe::Dockerfile { dockerfile })
163            | Self::E2b(E2bImageBuildRecipe::Dockerfile { dockerfile }) => {
164                SnapshotBuildSpec::Dockerfile {
165                    dockerfile: dockerfile.clone(),
166                }
167            }
168            Self::E2b(E2bImageBuildRecipe::Template { pip_packages }) => {
169                SnapshotBuildSpec::Template {
170                    pip_packages: pip_packages.clone(),
171                }
172            }
173            Self::Moulin(MoulinImageBuildRecipe::Git {
174                git_url,
175                commit_sha,
176            })
177            | Self::E2b(E2bImageBuildRecipe::Git {
178                git_url,
179                commit_sha,
180            }) => SnapshotBuildSpec::Git {
181                git_url: git_url.clone(),
182                commit_sha: commit_sha.clone(),
183            },
184            Self::Custom { recipe, .. } => recipe.clone(),
185        }
186    }
187}
188
189/// Product-facing snapshot recipe size limits. The build adapters impose
190/// additional limits on fetched repository contents and generated archives.
191pub const SNAPSHOT_DOCKERFILE_MAX_BYTES: usize = 1024 * 1024;
192pub const SNAPSHOT_TEMPLATE_PACKAGES_MAX_BYTES: usize = 64 * 1024;
193
194#[derive(Debug, Clone, Default, PartialEq, Eq)]
195pub struct EnvironmentCatalogConfig {
196    pub default_snapshot: Option<crate::DefaultExecutionSnapshot>,
197    pub default_provider: Option<String>,
198    pub default_resources: SandboxResources,
199    pub default_builds: BTreeMap<String, SnapshotBuildSpec>,
200}
201
202#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(deny_unknown_fields, rename_all = "camelCase")]
204pub struct CreateSnapshotResourceRequest {
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub alias: Option<String>,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub provider: Option<String>,
209    #[serde(default)]
210    pub visibility: SnapshotVisibility,
211    #[serde(default)]
212    pub owners: Vec<OwnerRef>,
213    #[serde(default)]
214    pub builtin: bool,
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub build: Option<SnapshotBuildSpec>,
217}
218
219#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(deny_unknown_fields, rename_all = "camelCase")]
221pub struct BuildBuiltinSnapshotRequest {
222    pub provider: String,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub git_url: Option<String>,
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub commit_sha: Option<String>,
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub dockerfile: Option<String>,
229}
230
231pub const ADMIN_BUILD_BUILTIN_SNAPSHOTS_PATH: &str = "/admin/api/workspace-snapshots:build-builtin";
232pub const ADMIN_SNAPSHOTS_PATH: &str = "/admin/api/workspace-snapshots";
233pub const ADMIN_SNAPSHOT_PATH: &str = "/admin/api/workspace-snapshots/{snapshotId}";
234pub const CATALOG_SNAPSHOT_PATH: &str = "/internal/v1/workspace/snapshots:catalog/{snapshotId}";
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(deny_unknown_fields, rename_all = "camelCase")]
238pub struct ProviderBuildHandle {
239    pub build_id: String,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub resource_external_id: Option<String>,
242}
243
244/// Provider build observation captured by background reconciliation. Logs are
245/// bounded by the adapter/runtime and never contain provider credentials.
246#[derive(Debug, Clone, Default, PartialEq, Eq)]
247pub struct EnvironmentBuildObservation {
248    pub stage: Option<String>,
249    pub logs: Vec<String>,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub enum EnvironmentBuildStatus {
254    Building(ProviderBuildHandle),
255    BuildingObserved {
256        handle: ProviderBuildHandle,
257        observation: EnvironmentBuildObservation,
258    },
259    Ready(ProviderResourceRef),
260    ReadyWithMetadata {
261        provider_ref: ProviderResourceRef,
262        digest: Option<String>,
263        size_bytes: Option<u64>,
264    },
265    ReadyObserved {
266        provider_ref: ProviderResourceRef,
267        digest: Option<String>,
268        size_bytes: Option<u64>,
269        observation: EnvironmentBuildObservation,
270    },
271    Failed(String),
272    FailedObserved {
273        error: String,
274        observation: EnvironmentBuildObservation,
275    },
276}
277
278/// Build-only port. Implementations must not create Computer or Sandbox instances.
279#[async_trait]
280pub trait EnvironmentBuildPort: Send + Sync + Debug {
281    async fn submit_environment_image(
282        &self,
283        request: &EnvironmentImageBuildRequest,
284        idempotency_key: &str,
285    ) -> Result<EnvironmentBuildStatus>;
286
287    async fn poll_environment_image(
288        &self,
289        _provider: WorkspaceBuildProvider,
290        _handle: &ProviderBuildHandle,
291    ) -> Result<EnvironmentBuildStatus> {
292        Err(anyhow::anyhow!(
293            "build provider does not support asynchronous status observation"
294        ))
295    }
296}