Skip to main content

agent_workspace_contract/
control.rs

1use super::*;
2use std::collections::BTreeMap;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum WorkspaceLifecycle {
7    Provisioning,
8    Ready,
9    Suspended,
10    Deleting,
11    Deleted,
12    Error,
13}
14
15pub const WORKSPACES_PATH: &str = "/internal/v1/workspaces";
16pub const WORKSPACE_PATH: &str = "/internal/v1/workspaces/{workspaceId}";
17pub const WORKSPACE_SUSPEND_PATH: &str = "/internal/v1/workspaces/{workspaceId}/suspend";
18pub const WORKSPACE_RESUME_PATH: &str = "/internal/v1/workspaces/{workspaceId}/resume";
19pub const WORKSPACE_RECONCILE_PATH: &str = "/internal/v1/workspaces/{workspaceId}/reconcile";
20pub const WORKSPACE_USAGE_PATH: &str = "/internal/v1/workspaces/{workspaceId}/usage";
21pub const WORKSPACE_OPERATION_CANCEL_PATH: &str =
22    "/internal/v1/workspace-operations/{operationId}/cancel";
23pub const WORKSPACE_MAX_PAGE_SIZE: usize = 200;
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields, rename_all = "camelCase")]
27pub struct CreateWorkspaceRequest {
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub id: Option<String>,
30    pub project_id: String,
31    pub config: WorkspaceConfig,
32    #[serde(default)]
33    pub labels: BTreeMap<String, String>,
34    #[serde(default)]
35    pub relations: agent_registry_contract::ResourceRelations,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields, rename_all = "camelCase")]
40pub struct WorkspaceActionRequest {
41    pub expected_version: u64,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub reason: Option<String>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct WorkspacePage {
49    pub items: Vec<WorkspaceRecord>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub next_cursor: Option<String>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "camelCase")]
56pub struct ProvisionedWorkspace {
57    pub durable_backend_id: String,
58    pub root: String,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub struct WorkspaceLease {
64    pub owner: String,
65    pub deadline_ms: u64,
66    pub fencing_token: u64,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct WorkspaceRecord {
72    pub id: String,
73    pub tenant_id: String,
74    pub project_id: String,
75    pub backend: String,
76    pub durable_backend_id: String,
77    pub root: String,
78    #[serde(default)]
79    pub config: WorkspaceConfig,
80    pub lifecycle: WorkspaceLifecycle,
81    pub version: u64,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub lease: Option<WorkspaceLease>,
84    pub created_at_ms: u64,
85    pub updated_at_ms: u64,
86    #[serde(default)]
87    pub labels: BTreeMap<String, String>,
88    #[serde(default)]
89    pub relations: agent_registry_contract::ResourceRelations,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94pub enum OperationState {
95    Queued,
96    Running,
97    Succeeded,
98    Failed,
99    Canceling,
100    Canceled,
101}
102
103impl OperationState {
104    pub fn terminal(self) -> bool {
105        matches!(self, Self::Succeeded | Self::Failed | Self::Canceled)
106    }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct WorkspaceOperation {
112    pub id: String,
113    pub tenant_id: String,
114    pub workspace_id: String,
115    pub kind: String,
116    pub state: OperationState,
117    pub phase: String,
118    pub attempt: u32,
119    pub version: u64,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub lease: Option<WorkspaceLease>,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub error_code: Option<String>,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub result_resource_id: Option<String>,
126    pub created_at_ms: u64,
127    pub updated_at_ms: u64,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub next_poll_after_ms: Option<u64>,
130}
131
132impl WorkspaceOperation {
133    pub fn refresh_poll_hint(&mut self) {
134        self.next_poll_after_ms = match self.state {
135            OperationState::Queued => Some(250),
136            OperationState::Running | OperationState::Canceling => Some(500),
137            OperationState::Succeeded | OperationState::Failed | OperationState::Canceled => None,
138        };
139    }
140}
141
142#[derive(Debug, Clone)]
143pub struct OperationTransition {
144    pub operation_id: String,
145    pub owner: String,
146    pub fencing_token: u64,
147    pub expected_version: u64,
148    pub state: OperationState,
149    pub phase: String,
150    pub error_code: Option<String>,
151    pub result_resource_id: Option<String>,
152    pub now_ms: u64,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct StoredChangeSet {
158    pub tenant_id: String,
159    pub workspace_id: String,
160    pub change_set: WorkspaceChangeSet,
161    pub base_version: u64,
162    pub workspace_version: u64,
163    pub expires_at_ms: u64,
164    pub request_digest: String,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct StoredPreview {
170    pub id: String,
171    pub tenant_id: String,
172    pub workspace_id: String,
173    pub provider_ref: String,
174    pub url: String,
175    pub state: String,
176    pub expires_at_ms: u64,
177    pub version: u64,
178    #[serde(default)]
179    pub request_digest: String,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct CommandSpec {
185    /// Context commands skip shared Skill env files and shell initialization.
186    #[serde(default, skip_serializing_if = "command_env_inherited")]
187    pub isolated_env: bool,
188    pub argv: Vec<String>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub cwd: Option<String>,
191    /// Plaintext process environment injected at execute time.
192    /// Prefer `env_refs` for secrets; both are merged, with `env_refs` resolved
193    /// server-side at execute time so GET/list responses never contain decrypted
194    /// secret values.
195    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
196    pub env: BTreeMap<String, String>,
197    /// Secret references (`secret://…`) resolved by the workspace secret store
198    /// at execute time and merged into the process environment. The resolved
199    /// plaintext is injected into the sandbox process only; it is not persisted
200    /// on the command record.
201    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
202    pub env_refs: BTreeMap<String, agent_secret_contract::SecretRef>,
203    #[serde(default)]
204    pub shell: bool,
205    pub timeout_ms: u64,
206    pub memory_bytes: u64,
207    pub cpu_millis: u64,
208    pub max_processes: u32,
209    /// Whether provider-side egress is denied or permitted for this command.
210    /// Providers must reject unsupported policy rather than silently ignore it.
211    #[serde(default)]
212    pub network: CommandNetworkPolicy,
213    /// Maximum additional writable bytes for the command sandbox.
214    #[serde(default = "default_command_disk_bytes")]
215    pub disk_bytes: u64,
216    pub max_output_bytes: usize,
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
220#[serde(rename_all = "snake_case")]
221pub enum CommandNetworkPolicy {
222    #[default]
223    Deny,
224    Allow,
225}
226
227fn default_command_disk_bytes() -> u64 {
228    1024 * 1024 * 1024
229}
230
231#[async_trait]
232pub trait WorkspaceControlRepository: Send + Sync + Debug {
233    async fn put_workspace(&self, workspace: &WorkspaceRecord) -> Result<()>;
234    async fn get_workspace(&self, tenant_id: &str, id: &str) -> Result<Option<WorkspaceRecord>>;
235    async fn list_workspaces(
236        &self,
237        tenant_id: &str,
238        project_id: &str,
239        after: Option<&str>,
240        limit: usize,
241    ) -> Result<WorkspacePage>;
242    async fn list_workspaces_global(
243        &self,
244        after: Option<(&str, &str)>,
245        limit: usize,
246    ) -> Result<WorkspacePage>;
247    async fn put_workspace_operation(
248        &self,
249        workspace: &WorkspaceRecord,
250        operation: &WorkspaceOperation,
251        payload_json: &str,
252        idempotency_key: &str,
253        request_digest: &str,
254    ) -> Result<WorkspaceOperation>;
255    async fn compare_and_swap_workspace(
256        &self,
257        tenant_id: &str,
258        id: &str,
259        expected_version: u64,
260        lifecycle: WorkspaceLifecycle,
261        now_ms: u64,
262    ) -> Result<WorkspaceRecord>;
263    async fn claim_workspace_lease(
264        &self,
265        tenant_id: &str,
266        id: &str,
267        owner: &str,
268        now_ms: u64,
269        lease_ms: u64,
270    ) -> Result<WorkspaceLease>;
271    async fn release_workspace_lease(
272        &self,
273        tenant_id: &str,
274        id: &str,
275        owner: &str,
276        fencing_token: u64,
277        now_ms: u64,
278    ) -> Result<()>;
279    async fn fenced_workspace_update(
280        &self,
281        tenant_id: &str,
282        id: &str,
283        owner: &str,
284        fencing_token: u64,
285        lifecycle: WorkspaceLifecycle,
286        now_ms: u64,
287    ) -> Result<WorkspaceRecord>;
288    async fn fenced_replace_workspace(
289        &self,
290        workspace: &WorkspaceRecord,
291        owner: &str,
292        fencing_token: u64,
293        now_ms: u64,
294    ) -> Result<WorkspaceRecord>;
295    async fn put_operation(&self, operation: &WorkspaceOperation) -> Result<()>;
296    async fn put_operation_with_input(
297        &self,
298        operation: &WorkspaceOperation,
299        payload_json: &str,
300    ) -> Result<()>;
301    async fn put_idempotent_operation(
302        &self,
303        operation: &WorkspaceOperation,
304        payload_json: &str,
305        idempotency_key: &str,
306        request_digest: &str,
307    ) -> Result<WorkspaceOperation>;
308    async fn get_operation(&self, operation_id: &str) -> Result<Option<WorkspaceOperation>>;
309    async fn claim_operation(
310        &self,
311        operation_id: &str,
312        owner: &str,
313        now_ms: u64,
314        lease_ms: u64,
315    ) -> Result<WorkspaceOperation>;
316    async fn renew_operation_lease(
317        &self,
318        operation_id: &str,
319        owner: &str,
320        fencing_token: u64,
321        now_ms: u64,
322        lease_ms: u64,
323    ) -> Result<WorkspaceOperation>;
324    async fn transition_operation(
325        &self,
326        transition: &OperationTransition,
327    ) -> Result<WorkspaceOperation>;
328    async fn request_operation_cancel(
329        &self,
330        tenant_id: &str,
331        operation_id: &str,
332        expected_version: u64,
333        now_ms: u64,
334    ) -> Result<WorkspaceOperation>;
335    async fn put_operation_input(&self, operation_id: &str, payload_json: &str) -> Result<()>;
336    async fn get_operation_input(&self, operation_id: &str) -> Result<Option<String>>;
337    async fn list_claimable_operations(
338        &self,
339        tenant_id: &str,
340        now_ms: u64,
341        limit: usize,
342    ) -> Result<Vec<WorkspaceOperation>>;
343    async fn list_claimable_operations_global(
344        &self,
345        now_ms: u64,
346        limit: usize,
347    ) -> Result<Vec<WorkspaceOperation>>;
348    async fn put_change_set(&self, value: &StoredChangeSet) -> Result<()>;
349    async fn get_change_set(&self, tenant_id: &str, id: &str) -> Result<Option<StoredChangeSet>>;
350    async fn put_preview(&self, value: &StoredPreview) -> Result<()>;
351    async fn get_preview(&self, tenant_id: &str, id: &str) -> Result<Option<StoredPreview>>;
352    async fn stop_preview(
353        &self,
354        tenant_id: &str,
355        id: &str,
356        expected_version: u64,
357    ) -> Result<StoredPreview>;
358    async fn retain_tenant(&self, tenant_id: &str, now_ms: u64, max_rows: usize) -> Result<usize>;
359    async fn record_worker_heartbeat(&self, worker_id: &str, now_ms: u64) -> Result<()> {
360        let _ = (worker_id, now_ms);
361        Ok(())
362    }
363    async fn check_readiness(
364        &self,
365        worker_id: &str,
366        now_ms: u64,
367        max_worker_staleness_ms: u64,
368        max_operation_backlog: usize,
369    ) -> Result<()> {
370        let _ = (
371            worker_id,
372            now_ms,
373            max_worker_staleness_ms,
374            max_operation_backlog,
375        );
376        Ok(())
377    }
378}
379
380#[async_trait]
381pub trait SecretResolver: Send + Sync + Debug {
382    /// Secret bytes must remain inside the trusted adapter/executor boundary.
383    async fn resolve(&self, tenant_id: &str, secret: &SecretRef) -> Result<Vec<u8>>;
384}
385
386#[async_trait]
387pub trait WorkspaceProviderAdapter: Send + Sync + Debug {
388    async fn provision(
389        &self,
390        tenant_id: &str,
391        workspace_id: &str,
392        config: &WorkspaceConfig,
393        idempotency_key: &str,
394    ) -> Result<ProvisionedWorkspace>;
395    async fn reconnect(&self, durable_backend_id: &str) -> Result<()>;
396    async fn suspend(&self, durable_backend_id: &str, idempotency_key: &str) -> Result<()>;
397    async fn resume(&self, durable_backend_id: &str, idempotency_key: &str) -> Result<()>;
398    async fn inspect(&self, workspace: &WorkspaceRecord) -> Result<WorkspaceResourceUsage>;
399    async fn delete(&self, durable_backend_id: &str, idempotency_key: &str) -> Result<()>;
400    async fn cancel_command(&self, durable_backend_id: &str, command_id: &str) -> Result<()>;
401}
402
403#[async_trait]
404pub trait WorkspaceProviderRegistry: Send + Sync + Debug {
405    async fn provider(&self, backend: &str) -> Result<Arc<dyn WorkspaceProviderAdapter>>;
406}
407
408#[async_trait]
409pub trait WorkspaceCommandExecutor: Send + Sync + Debug {
410    async fn execute(
411        &self,
412        tenant_id: &str,
413        workspace_id: &str,
414        command_id: &str,
415        command: &CommandSpec,
416    ) -> Result<CmdOutput>;
417    async fn cancel(&self, tenant_id: &str, command_id: &str) -> Result<()>;
418}
419
420fn command_env_inherited(value: &bool) -> bool {
421    !*value
422}