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    pub argv: Vec<String>,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub cwd: Option<String>,
188    #[serde(default)]
189    pub env: BTreeMap<String, SecretRef>,
190    #[serde(default)]
191    pub shell: bool,
192    pub timeout_ms: u64,
193    pub memory_bytes: u64,
194    pub cpu_millis: u64,
195    pub max_processes: u32,
196    /// Whether provider-side egress is denied or permitted for this command.
197    /// Providers must reject unsupported policy rather than silently ignore it.
198    #[serde(default)]
199    pub network: CommandNetworkPolicy,
200    /// Maximum additional writable bytes for the command sandbox.
201    #[serde(default = "default_command_disk_bytes")]
202    pub disk_bytes: u64,
203    pub max_output_bytes: usize,
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
207#[serde(rename_all = "snake_case")]
208pub enum CommandNetworkPolicy {
209    #[default]
210    Deny,
211    Allow,
212}
213
214fn default_command_disk_bytes() -> u64 {
215    1024 * 1024 * 1024
216}
217
218#[async_trait]
219pub trait WorkspaceControlRepository: Send + Sync + Debug {
220    async fn put_workspace(&self, workspace: &WorkspaceRecord) -> Result<()>;
221    async fn get_workspace(&self, tenant_id: &str, id: &str) -> Result<Option<WorkspaceRecord>>;
222    async fn list_workspaces(
223        &self,
224        tenant_id: &str,
225        project_id: &str,
226        after: Option<&str>,
227        limit: usize,
228    ) -> Result<WorkspacePage>;
229    async fn list_workspaces_global(
230        &self,
231        after: Option<(&str, &str)>,
232        limit: usize,
233    ) -> Result<WorkspacePage>;
234    async fn put_workspace_operation(
235        &self,
236        workspace: &WorkspaceRecord,
237        operation: &WorkspaceOperation,
238        payload_json: &str,
239        idempotency_key: &str,
240        request_digest: &str,
241    ) -> Result<WorkspaceOperation>;
242    async fn compare_and_swap_workspace(
243        &self,
244        tenant_id: &str,
245        id: &str,
246        expected_version: u64,
247        lifecycle: WorkspaceLifecycle,
248        now_ms: u64,
249    ) -> Result<WorkspaceRecord>;
250    async fn claim_workspace_lease(
251        &self,
252        tenant_id: &str,
253        id: &str,
254        owner: &str,
255        now_ms: u64,
256        lease_ms: u64,
257    ) -> Result<WorkspaceLease>;
258    async fn release_workspace_lease(
259        &self,
260        tenant_id: &str,
261        id: &str,
262        owner: &str,
263        fencing_token: u64,
264        now_ms: u64,
265    ) -> Result<()>;
266    async fn fenced_workspace_update(
267        &self,
268        tenant_id: &str,
269        id: &str,
270        owner: &str,
271        fencing_token: u64,
272        lifecycle: WorkspaceLifecycle,
273        now_ms: u64,
274    ) -> Result<WorkspaceRecord>;
275    async fn fenced_replace_workspace(
276        &self,
277        workspace: &WorkspaceRecord,
278        owner: &str,
279        fencing_token: u64,
280        now_ms: u64,
281    ) -> Result<WorkspaceRecord>;
282    async fn put_operation(&self, operation: &WorkspaceOperation) -> Result<()>;
283    async fn put_operation_with_input(
284        &self,
285        operation: &WorkspaceOperation,
286        payload_json: &str,
287    ) -> Result<()>;
288    async fn put_idempotent_operation(
289        &self,
290        operation: &WorkspaceOperation,
291        payload_json: &str,
292        idempotency_key: &str,
293        request_digest: &str,
294    ) -> Result<WorkspaceOperation>;
295    async fn get_operation(&self, operation_id: &str) -> Result<Option<WorkspaceOperation>>;
296    async fn claim_operation(
297        &self,
298        operation_id: &str,
299        owner: &str,
300        now_ms: u64,
301        lease_ms: u64,
302    ) -> Result<WorkspaceOperation>;
303    async fn renew_operation_lease(
304        &self,
305        operation_id: &str,
306        owner: &str,
307        fencing_token: u64,
308        now_ms: u64,
309        lease_ms: u64,
310    ) -> Result<WorkspaceOperation>;
311    async fn transition_operation(
312        &self,
313        transition: &OperationTransition,
314    ) -> Result<WorkspaceOperation>;
315    async fn request_operation_cancel(
316        &self,
317        tenant_id: &str,
318        operation_id: &str,
319        expected_version: u64,
320        now_ms: u64,
321    ) -> Result<WorkspaceOperation>;
322    async fn put_operation_input(&self, operation_id: &str, payload_json: &str) -> Result<()>;
323    async fn get_operation_input(&self, operation_id: &str) -> Result<Option<String>>;
324    async fn list_claimable_operations(
325        &self,
326        tenant_id: &str,
327        now_ms: u64,
328        limit: usize,
329    ) -> Result<Vec<WorkspaceOperation>>;
330    async fn list_claimable_operations_global(
331        &self,
332        now_ms: u64,
333        limit: usize,
334    ) -> Result<Vec<WorkspaceOperation>>;
335    async fn put_change_set(&self, value: &StoredChangeSet) -> Result<()>;
336    async fn get_change_set(&self, tenant_id: &str, id: &str) -> Result<Option<StoredChangeSet>>;
337    async fn put_preview(&self, value: &StoredPreview) -> Result<()>;
338    async fn get_preview(&self, tenant_id: &str, id: &str) -> Result<Option<StoredPreview>>;
339    async fn stop_preview(
340        &self,
341        tenant_id: &str,
342        id: &str,
343        expected_version: u64,
344    ) -> Result<StoredPreview>;
345    async fn retain_tenant(&self, tenant_id: &str, now_ms: u64, max_rows: usize) -> Result<usize>;
346    async fn record_worker_heartbeat(&self, worker_id: &str, now_ms: u64) -> Result<()> {
347        let _ = (worker_id, now_ms);
348        Ok(())
349    }
350    async fn check_readiness(
351        &self,
352        worker_id: &str,
353        now_ms: u64,
354        max_worker_staleness_ms: u64,
355        max_operation_backlog: usize,
356    ) -> Result<()> {
357        let _ = (
358            worker_id,
359            now_ms,
360            max_worker_staleness_ms,
361            max_operation_backlog,
362        );
363        Ok(())
364    }
365}
366
367#[async_trait]
368pub trait SecretResolver: Send + Sync + Debug {
369    /// Secret bytes must remain inside the trusted adapter/executor boundary.
370    async fn resolve(&self, tenant_id: &str, secret: &SecretRef) -> Result<Vec<u8>>;
371}
372
373#[async_trait]
374pub trait WorkspaceProviderAdapter: Send + Sync + Debug {
375    async fn provision(
376        &self,
377        tenant_id: &str,
378        workspace_id: &str,
379        config: &WorkspaceConfig,
380        idempotency_key: &str,
381    ) -> Result<ProvisionedWorkspace>;
382    async fn reconnect(&self, durable_backend_id: &str) -> Result<()>;
383    async fn suspend(&self, durable_backend_id: &str, idempotency_key: &str) -> Result<()>;
384    async fn resume(&self, durable_backend_id: &str, idempotency_key: &str) -> Result<()>;
385    async fn inspect(&self, workspace: &WorkspaceRecord) -> Result<WorkspaceResourceUsage>;
386    async fn delete(&self, durable_backend_id: &str, idempotency_key: &str) -> Result<()>;
387    async fn cancel_command(&self, durable_backend_id: &str, command_id: &str) -> Result<()>;
388}
389
390#[async_trait]
391pub trait WorkspaceProviderRegistry: Send + Sync + Debug {
392    async fn provider(&self, backend: &str) -> Result<Arc<dyn WorkspaceProviderAdapter>>;
393}
394
395#[async_trait]
396pub trait WorkspaceCommandExecutor: Send + Sync + Debug {
397    async fn execute(
398        &self,
399        tenant_id: &str,
400        workspace_id: &str,
401        command_id: &str,
402        command: &CommandSpec,
403    ) -> Result<CmdOutput>;
404    async fn cancel(&self, tenant_id: &str, command_id: &str) -> Result<()>;
405}