Skip to main content

agent_workspace_contract/environment/
ports.rs

1//! Persistence ports and typed metadata deltas for Environment aggregates.
2
3use super::*;
4use crate::{CommandSpec, PortUrl, ProviderPortTarget};
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(deny_unknown_fields, rename_all = "camelCase")]
8pub enum EnvironmentIdempotencyState {
9    Pending,
10    Completed,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(deny_unknown_fields, rename_all = "camelCase")]
15pub struct EnvironmentIdempotencyRecord {
16    pub id: String,
17    pub scope: ResourceScope,
18    pub action: String,
19    /// SHA-256 of the caller-provided key; the key itself is never persisted.
20    pub key_hash: String,
21    pub request_hash: String,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub resource_id: Option<String>,
24    pub state: EnvironmentIdempotencyState,
25    /// Runtime instance currently allowed to enter Provider I/O.
26    pub claim_owner: String,
27    /// A dead instance can be taken over only after this lease expires.
28    pub claim_expires_at_ms: u64,
29    pub attempt: u32,
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub result_json: Option<String>,
32    pub started_at_ms: u64,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub completed_at_ms: Option<u64>,
35}
36
37/// Durable metadata snapshot for one tenant/project scope.
38///
39/// Provider bytes and credentials are deliberately absent. Repositories may
40/// store each collection in a dedicated table while exposing one CAS unit to
41/// the application service so fencing and revision changes commit together.
42#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields, rename_all = "camelCase")]
44pub struct EnvironmentMetadataSnapshot {
45    #[serde(default)]
46    pub user_computers: Vec<crate::UserComputerBinding>,
47    #[serde(default)]
48    pub conversation_spaces: Vec<crate::ConversationSpaceBinding>,
49    #[serde(default)]
50    pub workspace_bindings: Vec<crate::WorkspacePolicyBinding>,
51    #[serde(default)]
52    pub workspace_policies: Vec<crate::WorkspacePolicyRecord>,
53    pub version: u64,
54    #[serde(default)]
55    pub computers: Vec<ComputerRecord>,
56    #[serde(default)]
57    pub spaces: Vec<SpaceRecord>,
58    #[serde(default)]
59    pub sandboxes: Vec<SandboxRecord>,
60    #[serde(default)]
61    pub managed_workloads: Vec<ManagedWorkload>,
62    #[serde(default)]
63    pub snapshots: Vec<SnapshotRecord>,
64    #[serde(default)]
65    pub templates: Vec<TemplateVersionRecord>,
66    #[serde(default)]
67    pub grants: Vec<SpaceGrant>,
68    #[serde(default)]
69    pub sessions: Vec<SpaceCollaborationSession>,
70    #[serde(default)]
71    pub tickets: Vec<SpaceAccessTicketRecord>,
72    #[serde(default)]
73    pub leases: Vec<SpaceWriteLease>,
74    #[serde(default)]
75    pub fencing_tokens: BTreeMap<String, u64>,
76    #[serde(default)]
77    pub delegations: Vec<DelegationRecord>,
78    #[serde(default)]
79    pub change_sets: Vec<SpaceChangeSetRecord>,
80    #[serde(default)]
81    pub idempotency: Vec<EnvironmentIdempotencyRecord>,
82}
83
84/// Incremental metadata changes for one aggregate collection.
85///
86/// Upserts and deletes are committed under the enclosing scope-version CAS;
87/// unchanged aggregates never cross the repository boundary.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct EnvironmentMetadataCollectionPatch<T> {
90    pub upserts: Vec<T>,
91    pub deletes: Vec<String>,
92}
93
94impl<T> Default for EnvironmentMetadataCollectionPatch<T> {
95    fn default() -> Self {
96        Self {
97            upserts: Vec::new(),
98            deletes: Vec::new(),
99        }
100    }
101}
102
103#[derive(Debug, Clone, Default, PartialEq, Eq)]
104pub struct EnvironmentFencingTokenPatch {
105    pub upserts: BTreeMap<String, u64>,
106    pub deletes: Vec<String>,
107}
108
109/// Typed delta for one tenant/project Environment aggregate set.
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct EnvironmentMetadataPatch {
112    pub user_computers: EnvironmentMetadataCollectionPatch<crate::UserComputerBinding>,
113    pub conversation_spaces: EnvironmentMetadataCollectionPatch<crate::ConversationSpaceBinding>,
114    pub workspace_bindings: EnvironmentMetadataCollectionPatch<crate::WorkspacePolicyBinding>,
115    pub workspace_policies: EnvironmentMetadataCollectionPatch<crate::WorkspacePolicyRecord>,
116    pub version: u64,
117    pub computers: EnvironmentMetadataCollectionPatch<ComputerRecord>,
118    pub spaces: EnvironmentMetadataCollectionPatch<SpaceRecord>,
119    pub sandboxes: EnvironmentMetadataCollectionPatch<SandboxRecord>,
120    pub managed_workloads: EnvironmentMetadataCollectionPatch<ManagedWorkload>,
121    pub snapshots: EnvironmentMetadataCollectionPatch<SnapshotRecord>,
122    pub templates: EnvironmentMetadataCollectionPatch<TemplateVersionRecord>,
123    pub grants: EnvironmentMetadataCollectionPatch<SpaceGrant>,
124    pub sessions: EnvironmentMetadataCollectionPatch<SpaceCollaborationSession>,
125    pub tickets: EnvironmentMetadataCollectionPatch<SpaceAccessTicketRecord>,
126    pub leases: EnvironmentMetadataCollectionPatch<SpaceWriteLease>,
127    pub fencing_tokens: EnvironmentFencingTokenPatch,
128    pub delegations: EnvironmentMetadataCollectionPatch<DelegationRecord>,
129    pub change_sets: EnvironmentMetadataCollectionPatch<SpaceChangeSetRecord>,
130    pub idempotency: EnvironmentMetadataCollectionPatch<EnvironmentIdempotencyRecord>,
131}
132
133impl EnvironmentMetadataPatch {
134    pub fn between(
135        before: &EnvironmentMetadataSnapshot,
136        after: &EnvironmentMetadataSnapshot,
137    ) -> Self {
138        Self {
139            version: after.version,
140            user_computers: collection_patch(&before.user_computers, &after.user_computers, |v| {
141                v.id.clone()
142            }),
143            conversation_spaces: collection_patch(
144                &before.conversation_spaces,
145                &after.conversation_spaces,
146                |v| v.id.clone(),
147            ),
148            workspace_bindings: collection_patch(
149                &before.workspace_bindings,
150                &after.workspace_bindings,
151                |value| value.id.clone(),
152            ),
153            workspace_policies: collection_patch(
154                &before.workspace_policies,
155                &after.workspace_policies,
156                |value| value.id.clone(),
157            ),
158            computers: collection_patch(&before.computers, &after.computers, |value| {
159                value.id.clone()
160            }),
161            spaces: collection_patch(&before.spaces, &after.spaces, |value| value.id.clone()),
162            sandboxes: collection_patch(&before.sandboxes, &after.sandboxes, |value| {
163                value.id.clone()
164            }),
165            managed_workloads: collection_patch(
166                &before.managed_workloads,
167                &after.managed_workloads,
168                |value| value.id.clone(),
169            ),
170            snapshots: collection_patch(&before.snapshots, &after.snapshots, |value| {
171                value.id.clone()
172            }),
173            templates: collection_patch(&before.templates, &after.templates, |value| {
174                format!("{}\0{}", value.template_id, value.version)
175            }),
176            grants: collection_patch(&before.grants, &after.grants, |value| value.id.clone()),
177            sessions: collection_patch(&before.sessions, &after.sessions, |value| value.id.clone()),
178            tickets: collection_patch(&before.tickets, &after.tickets, |value| value.id.clone()),
179            leases: collection_patch(&before.leases, &after.leases, |value| value.id.clone()),
180            fencing_tokens: map_patch(&before.fencing_tokens, &after.fencing_tokens),
181            delegations: collection_patch(&before.delegations, &after.delegations, |value| {
182                value.id.clone()
183            }),
184            change_sets: collection_patch(&before.change_sets, &after.change_sets, |value| {
185                value.id.clone()
186            }),
187            idempotency: collection_patch(&before.idempotency, &after.idempotency, |value| {
188                value.id.clone()
189            }),
190        }
191    }
192
193    pub fn changed_aggregate_count(&self) -> usize {
194        let collections = [
195            patch_size(&self.workspace_bindings),
196            patch_size(&self.user_computers),
197            patch_size(&self.conversation_spaces),
198            patch_size(&self.workspace_policies),
199            patch_size(&self.computers),
200            patch_size(&self.spaces),
201            patch_size(&self.sandboxes),
202            patch_size(&self.managed_workloads),
203            patch_size(&self.snapshots),
204            patch_size(&self.templates),
205            patch_size(&self.grants),
206            patch_size(&self.sessions),
207            patch_size(&self.tickets),
208            patch_size(&self.leases),
209            patch_size(&self.delegations),
210            patch_size(&self.change_sets),
211            patch_size(&self.idempotency),
212        ];
213        collections.into_iter().sum::<usize>()
214            + self.fencing_tokens.upserts.len()
215            + self.fencing_tokens.deletes.len()
216    }
217}
218
219fn collection_patch<T: Clone + PartialEq>(
220    before: &[T],
221    after: &[T],
222    id: impl Fn(&T) -> String,
223) -> EnvironmentMetadataCollectionPatch<T> {
224    let previous = before
225        .iter()
226        .map(|value| (id(value), value))
227        .collect::<BTreeMap<_, _>>();
228    let current = after
229        .iter()
230        .map(|value| (id(value), value))
231        .collect::<BTreeMap<_, _>>();
232    EnvironmentMetadataCollectionPatch {
233        upserts: current
234            .iter()
235            .filter(|(key, value)| previous.get(*key).is_none_or(|old| *old != **value))
236            .map(|(_, value)| (*value).clone())
237            .collect(),
238        deletes: previous
239            .keys()
240            .filter(|key| !current.contains_key(*key))
241            .cloned()
242            .collect(),
243    }
244}
245
246fn map_patch(
247    before: &BTreeMap<String, u64>,
248    after: &BTreeMap<String, u64>,
249) -> EnvironmentFencingTokenPatch {
250    EnvironmentFencingTokenPatch {
251        upserts: after
252            .iter()
253            .filter(|(key, value)| before.get(*key).is_none_or(|old| old != *value))
254            .map(|(key, value)| (key.clone(), *value))
255            .collect(),
256        deletes: before
257            .keys()
258            .filter(|key| !after.contains_key(*key))
259            .cloned()
260            .collect(),
261    }
262}
263
264fn patch_size<T>(patch: &EnvironmentMetadataCollectionPatch<T>) -> usize {
265    patch.upserts.len() + patch.deletes.len()
266}
267
268/// Existence checks reveal no foreign aggregate or owner metadata.
269#[derive(Debug, Clone, Copy)]
270pub enum EnvironmentResourceKind {
271    Computer,
272    Space,
273}
274
275#[async_trait]
276pub trait EnvironmentMetadataRepository: Send + Sync + Debug {
277    async fn resource_exists_outside_scope(
278        &self,
279        kind: EnvironmentResourceKind,
280        id: &str,
281        scope: &ResourceScope,
282    ) -> Result<bool>;
283    async fn load(&self, scope: &ResourceScope) -> Result<EnvironmentMetadataSnapshot>;
284
285    /// Stable tenant/project pagination for background lifecycle reconciliation.
286    async fn list_scopes(
287        &self,
288        after: Option<&ResourceScope>,
289        limit: usize,
290    ) -> Result<Vec<ResourceScope>>;
291
292    /// Atomically replace metadata when the stored scope version equals
293    /// `expected_version`. Returns false on a concurrent writer conflict.
294    async fn compare_and_swap(
295        &self,
296        scope: &ResourceScope,
297        expected_version: u64,
298        snapshot: &EnvironmentMetadataSnapshot,
299    ) -> Result<bool>;
300
301    /// Atomically apply only changed aggregates when the stored scope version
302    /// equals `expected_version`.
303    async fn compare_and_patch(
304        &self,
305        scope: &ResourceScope,
306        expected_version: u64,
307        patch: &EnvironmentMetadataPatch,
308    ) -> Result<bool>;
309}
310
311/// Command metadata and output are stored independently from the Environment
312/// aggregate snapshot. This keeps output appends O(chunks) instead of copying
313/// every resource in a tenant/project scope.
314#[async_trait]
315pub trait EnvironmentCommandRepository: Send + Sync + Debug {
316    async fn store_computer_command(
317        &self,
318        request: StoreComputerCommandRequest,
319    ) -> Result<StoreComputerCommandOutcome>;
320
321    async fn get_computer_command(
322        &self,
323        scope: &ResourceScope,
324        computer_id: &str,
325        command_id: &str,
326    ) -> Result<Option<ComputerCommandResource>>;
327
328    async fn transition_computer_command(
329        &self,
330        request: ComputerCommandTransition,
331    ) -> Result<ComputerCommandResource>;
332
333    async fn complete_computer_command(
334        &self,
335        request: CompleteComputerCommandRequest,
336    ) -> Result<ComputerCommandResource>;
337
338    async fn computer_command_output(
339        &self,
340        scope: &ResourceScope,
341        computer_id: &str,
342        command_id: &str,
343        after: u64,
344        limit: usize,
345    ) -> Result<ComputerCommandOutputPage>;
346}
347
348/// Production Environment persistence owns both aggregate metadata and the
349/// independently indexed command log.
350pub trait EnvironmentRepository:
351    EnvironmentMetadataRepository + EnvironmentCommandRepository
352{
353}
354impl<T> EnvironmentRepository for T where
355    T: EnvironmentMetadataRepository + EnvironmentCommandRepository
356{
357}
358
359#[async_trait]
360pub trait ComputerProvider: Send + Sync + Debug {
361    fn kind(&self) -> &str;
362    fn capabilities(&self) -> ProviderCapabilities;
363    fn validate_create(&self, _request: &ProviderComputerRequest) -> Result<()> {
364        Ok(())
365    }
366    async fn create_computer(
367        &self,
368        scope: &ResourceScope,
369        request: &ProviderComputerRequest,
370        idempotency_key: &str,
371    ) -> Result<ProviderResourceRef>;
372    async fn delete_computer(&self, external_id: &str, idempotency_key: &str) -> Result<()>;
373    async fn port_url(&self, computer_external_id: &str, port: u16) -> Result<PortUrl> {
374        let _ = (computer_external_id, port);
375        Err(EnvironmentDomainError::new(
376            EnvironmentErrorCode::CapabilityUnsupported,
377            "computer does not support preview URLs",
378        )
379        .into())
380    }
381    async fn port_target(
382        &self,
383        computer_external_id: &str,
384        port: u16,
385        path: &str,
386        query: Option<&str>,
387    ) -> Result<ProviderPortTarget> {
388        let _ = (computer_external_id, port, path, query);
389        Err(EnvironmentDomainError::new(
390            EnvironmentErrorCode::CapabilityUnsupported,
391            "computer does not support proxied preview traffic",
392        )
393        .into())
394    }
395    async fn create_space_directory(
396        &self,
397        computer_external_id: &str,
398        space_directory: &str,
399        request: &ProviderSpaceRequest,
400        idempotency_key: &str,
401    ) -> Result<()> {
402        let _ = (
403            computer_external_id,
404            space_directory,
405            request,
406            idempotency_key,
407        );
408        Err(EnvironmentDomainError::new(
409            EnvironmentErrorCode::CapabilityUnsupported,
410            "computer does not support Space directories",
411        )
412        .into())
413    }
414    async fn delete_space_directory(
415        &self,
416        computer_external_id: &str,
417        space_directory: &str,
418        idempotency_key: &str,
419    ) -> Result<()> {
420        let _ = (computer_external_id, space_directory, idempotency_key);
421        Err(EnvironmentDomainError::new(
422            EnvironmentErrorCode::CapabilityUnsupported,
423            "computer does not support Space directories",
424        )
425        .into())
426    }
427    async fn read_file(
428        &self,
429        computer_external_id: &str,
430        space_directory: &str,
431        path: &str,
432    ) -> Result<ProviderFileContent> {
433        let _ = (computer_external_id, space_directory, path);
434        Err(EnvironmentDomainError::new(
435            EnvironmentErrorCode::CapabilityUnsupported,
436            "computer does not support file reads",
437        )
438        .into())
439    }
440    async fn write_file(
441        &self,
442        computer_external_id: &str,
443        space_directory: &str,
444        path: &str,
445        content: &[u8],
446        expected_provider_revision: Option<&str>,
447        idempotency_key: &str,
448    ) -> Result<ProviderWriteResult> {
449        let _ = (
450            computer_external_id,
451            space_directory,
452            path,
453            content,
454            expected_provider_revision,
455            idempotency_key,
456        );
457        Err(EnvironmentDomainError::new(
458            EnvironmentErrorCode::CapabilityUnsupported,
459            "computer does not support file writes",
460        )
461        .into())
462    }
463    async fn delete_file(
464        &self,
465        computer_external_id: &str,
466        space_directory: &str,
467        path: &str,
468        idempotency_key: &str,
469    ) -> Result<()> {
470        let _ = (computer_external_id, space_directory, path, idempotency_key);
471        Err(EnvironmentDomainError::new(
472            EnvironmentErrorCode::CapabilityUnsupported,
473            "computer does not support file deletes",
474        )
475        .into())
476    }
477    async fn list_files(
478        &self,
479        computer_external_id: &str,
480        space_directory: &str,
481        path: &str,
482    ) -> Result<Vec<ProviderFileEntry>> {
483        let _ = (computer_external_id, space_directory, path);
484        Err(EnvironmentDomainError::new(
485            EnvironmentErrorCode::CapabilityUnsupported,
486            "computer does not support directory listing",
487        )
488        .into())
489    }
490    async fn source_archive(
491        &self,
492        computer_external_id: &str,
493        space_directory: &str,
494    ) -> Result<ProviderSourceArchive> {
495        let _ = (computer_external_id, space_directory);
496        Err(EnvironmentDomainError::new(
497            EnvironmentErrorCode::CapabilityUnsupported,
498            "computer does not support source archives",
499        )
500        .into())
501    }
502    async fn create_change_set(
503        &self,
504        computer_external_id: &str,
505        source_space_directory: &str,
506        target_space_directory: &str,
507        idempotency_key: &str,
508    ) -> Result<String> {
509        let _ = (
510            computer_external_id,
511            source_space_directory,
512            target_space_directory,
513            idempotency_key,
514        );
515        Err(EnvironmentDomainError::new(
516            EnvironmentErrorCode::CapabilityUnsupported,
517            "computer does not support change sets",
518        )
519        .into())
520    }
521    async fn apply_change_set(
522        &self,
523        computer_external_id: &str,
524        source_space_directory: &str,
525        target_space_directory: &str,
526        provider_change_ref: &str,
527        idempotency_key: &str,
528    ) -> Result<ProviderWriteResult> {
529        let _ = (
530            computer_external_id,
531            source_space_directory,
532            target_space_directory,
533            provider_change_ref,
534            idempotency_key,
535        );
536        Err(EnvironmentDomainError::new(
537            EnvironmentErrorCode::CapabilityUnsupported,
538            "computer does not support change sets",
539        )
540        .into())
541    }
542    async fn exec_command(
543        &self,
544        computer_external_id: &str,
545        space_directory: &str,
546        command: &CommandSpec,
547    ) -> Result<ProviderCommandOutput> {
548        let _ = (computer_external_id, space_directory, command);
549        Err(EnvironmentDomainError::new(
550            EnvironmentErrorCode::CapabilityUnsupported,
551            "computer does not support managed commands",
552        )
553        .into())
554    }
555    /// Set mode/owner/group on a Space path through the Provider file API.
556    /// Prefer this over user-level `chmod`/`chown` when Provider file writes create
557    /// root-owned files that the sandbox user cannot mutate.
558    async fn set_space_path_permissions(
559        &self,
560        computer_external_id: &str,
561        space_directory: &str,
562        path: &str,
563        mode: &str,
564        owner: Option<&str>,
565        group: Option<&str>,
566    ) -> Result<()> {
567        let _ = (
568            computer_external_id,
569            space_directory,
570            path,
571            mode,
572            owner,
573            group,
574        );
575        Ok(())
576    }
577    async fn exec_managed_command(
578        &self,
579        computer_external_id: &str,
580        space_directory: &str,
581        command_id: &str,
582        command: &CommandSpec,
583    ) -> Result<ProviderCommandOutput> {
584        let _ = command_id;
585        self.exec_command(computer_external_id, space_directory, command)
586            .await
587    }
588    async fn resume_managed_command(
589        &self,
590        computer_external_id: &str,
591        space_directory: &str,
592        command_id: &str,
593        command: &CommandSpec,
594    ) -> Result<Option<ProviderCommandOutput>> {
595        let _ = (computer_external_id, space_directory, command_id, command);
596        Ok(None)
597    }
598    async fn retry_managed_command(
599        &self,
600        computer_external_id: &str,
601        space_directory: &str,
602        command_id: &str,
603        command: &CommandSpec,
604    ) -> Result<Option<ProviderCommandOutput>> {
605        self.resume_managed_command(computer_external_id, space_directory, command_id, command)
606            .await
607    }
608    async fn cancel_command(
609        &self,
610        computer_external_id: &str,
611        space_directory: &str,
612        command_id: &str,
613    ) -> Result<()> {
614        let _ = (computer_external_id, space_directory, command_id);
615        Ok(())
616    }
617}
618
619#[async_trait]
620pub trait SandboxProvider: Send + Sync + Debug {
621    fn kind(&self) -> &str;
622    fn capabilities(&self) -> ProviderCapabilities;
623    fn validate_create(&self, _request: &ProviderSandboxRequest) -> Result<()> {
624        Ok(())
625    }
626    async fn create_sandbox(
627        &self,
628        scope: &ResourceScope,
629        request: &ProviderSandboxRequest,
630        idempotency_key: &str,
631    ) -> Result<ProviderResourceRef>;
632    async fn stop_sandbox(&self, external_id: &str, idempotency_key: &str) -> Result<()>;
633    async fn write_file(
634        &self,
635        external_id: &str,
636        path: &str,
637        content: &[u8],
638        expected_provider_revision: Option<&str>,
639        idempotency_key: &str,
640    ) -> Result<ProviderWriteResult> {
641        let _ = (
642            external_id,
643            path,
644            content,
645            expected_provider_revision,
646            idempotency_key,
647        );
648        Err(EnvironmentDomainError::new(
649            EnvironmentErrorCode::CapabilityUnsupported,
650            "sandbox does not support file writes",
651        )
652        .into())
653    }
654    async fn read_file(&self, external_id: &str, path: &str) -> Result<ProviderFileContent> {
655        let _ = (external_id, path);
656        Err(EnvironmentDomainError::new(
657            EnvironmentErrorCode::CapabilityUnsupported,
658            "sandbox does not support file reads",
659        )
660        .into())
661    }
662    async fn exec_command(
663        &self,
664        external_id: &str,
665        command: &CommandSpec,
666    ) -> Result<ProviderCommandOutput> {
667        let _ = (external_id, command);
668        Err(EnvironmentDomainError::new(
669            EnvironmentErrorCode::CapabilityUnsupported,
670            "sandbox does not support managed commands",
671        )
672        .into())
673    }
674    /// Set mode/owner/group on a Sandbox path through the Provider file API.
675    async fn set_path_permissions(
676        &self,
677        external_id: &str,
678        path: &str,
679        mode: &str,
680        owner: Option<&str>,
681        group: Option<&str>,
682    ) -> Result<()> {
683        let _ = (external_id, path, mode, owner, group);
684        Ok(())
685    }
686    async fn exec_managed_command(
687        &self,
688        external_id: &str,
689        command_id: &str,
690        command: &CommandSpec,
691    ) -> Result<ProviderCommandOutput> {
692        let _ = command_id;
693        self.exec_command(external_id, command).await
694    }
695    async fn resume_managed_command(
696        &self,
697        external_id: &str,
698        command_id: &str,
699        command: &CommandSpec,
700    ) -> Result<Option<ProviderCommandOutput>> {
701        let _ = (external_id, command_id, command);
702        Ok(None)
703    }
704    async fn retry_managed_command(
705        &self,
706        external_id: &str,
707        command_id: &str,
708        command: &CommandSpec,
709    ) -> Result<Option<ProviderCommandOutput>> {
710        self.resume_managed_command(external_id, command_id, command)
711            .await
712    }
713    async fn cancel_command(&self, external_id: &str, command_id: &str) -> Result<()> {
714        let _ = (external_id, command_id);
715        Ok(())
716    }
717}
718
719#[async_trait]
720pub trait SpaceProvider: Send + Sync + Debug {
721    fn kind(&self) -> &str;
722    fn capabilities(&self) -> ProviderCapabilities;
723    async fn create_space(
724        &self,
725        scope: &ResourceScope,
726        request: &ProviderSpaceRequest,
727        idempotency_key: &str,
728    ) -> Result<ProviderResourceRef>;
729    async fn read_file(&self, external_id: &str, path: &str) -> Result<ProviderFileContent>;
730    async fn write_file(
731        &self,
732        external_id: &str,
733        path: &str,
734        content: &[u8],
735        expected_provider_revision: Option<&str>,
736        idempotency_key: &str,
737    ) -> Result<ProviderWriteResult>;
738    async fn create_change_set(
739        &self,
740        source_external_id: &str,
741        target_external_id: &str,
742        idempotency_key: &str,
743    ) -> Result<String>;
744    async fn apply_change_set(
745        &self,
746        source_external_id: &str,
747        target_external_id: &str,
748        provider_change_ref: &str,
749        idempotency_key: &str,
750    ) -> Result<ProviderWriteResult>;
751    async fn delete_space(&self, external_id: &str, idempotency_key: &str) -> Result<()>;
752}
753
754#[async_trait]
755pub trait SnapshotProviderPort: Send + Sync + Debug {
756    fn kind(&self) -> &str;
757    async fn capture_snapshot(
758        &self,
759        source: &ProviderSnapshotSource,
760        idempotency_key: &str,
761    ) -> Result<ProviderResourceRef>;
762    async fn delete_snapshot(
763        &self,
764        snapshot_external_id: &str,
765        idempotency_key: &str,
766    ) -> Result<()>;
767}
768
769#[async_trait]
770pub trait TemplateProviderPort: Send + Sync + Debug {
771    fn kind(&self) -> &str;
772    async fn inspect_template(&self, external_id: &str) -> Result<ProviderCapabilities>;
773}
774
775pub trait EnvironmentProviderRegistry: Send + Sync + Debug {
776    fn computer(&self, provider: &str) -> Result<Arc<dyn ComputerProvider>>;
777    fn sandbox(&self, provider: &str) -> Result<Arc<dyn SandboxProvider>>;
778    fn space(&self, provider: &str) -> Result<Arc<dyn SpaceProvider>>;
779    fn snapshot(&self, provider: &str) -> Result<Arc<dyn SnapshotProviderPort>>;
780    fn template(&self, provider: &str) -> Result<Arc<dyn TemplateProviderPort>>;
781}
782
783#[cfg(test)]
784mod patch_tests {
785    use super::*;
786
787    fn record(scope: &ResourceScope, id: &str, attempt: u32) -> EnvironmentIdempotencyRecord {
788        EnvironmentIdempotencyRecord {
789            id: id.into(),
790            scope: scope.clone(),
791            action: "test".into(),
792            key_hash: format!("key-{id}"),
793            request_hash: format!("request-{id}"),
794            resource_id: None,
795            state: EnvironmentIdempotencyState::Pending,
796            claim_owner: "worker".into(),
797            claim_expires_at_ms: 10,
798            attempt,
799            result_json: None,
800            started_at_ms: 1,
801            completed_at_ms: None,
802        }
803    }
804
805    #[test]
806    fn metadata_patch_contains_only_changed_deleted_and_new_aggregates() {
807        let scope = ResourceScope {
808            tenant_id: "tenant".into(),
809            project_id: "project".into(),
810        };
811        let unchanged = record(&scope, "unchanged", 1);
812        let changed = record(&scope, "changed", 1);
813        let deleted = record(&scope, "deleted", 1);
814        let before = EnvironmentMetadataSnapshot {
815            version: 7,
816            idempotency: vec![unchanged.clone(), changed, deleted],
817            fencing_tokens: BTreeMap::from([
818                ("unchanged-space".into(), 1),
819                ("changed-space".into(), 1),
820                ("deleted-space".into(), 1),
821            ]),
822            ..EnvironmentMetadataSnapshot::default()
823        };
824        let changed = record(&scope, "changed", 2);
825        let added = record(&scope, "added", 1);
826        let after = EnvironmentMetadataSnapshot {
827            version: 8,
828            idempotency: vec![unchanged, changed.clone(), added.clone()],
829            fencing_tokens: BTreeMap::from([
830                ("unchanged-space".into(), 1),
831                ("changed-space".into(), 2),
832                ("added-space".into(), 1),
833            ]),
834            ..EnvironmentMetadataSnapshot::default()
835        };
836
837        let patch = EnvironmentMetadataPatch::between(&before, &after);
838
839        assert_eq!(patch.version, 8);
840        assert_eq!(patch.idempotency.upserts, vec![added, changed]);
841        assert_eq!(patch.idempotency.deletes, vec!["deleted"]);
842        assert_eq!(
843            patch.fencing_tokens.upserts,
844            BTreeMap::from([("added-space".into(), 1), ("changed-space".into(), 2)])
845        );
846        assert_eq!(patch.fencing_tokens.deletes, vec!["deleted-space"]);
847        assert_eq!(patch.changed_aggregate_count(), 6);
848    }
849}