1use 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 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 pub claim_owner: String,
27 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#[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#[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#[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#[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 async fn list_scopes(
287 &self,
288 after: Option<&ResourceScope>,
289 limit: usize,
290 ) -> Result<Vec<ResourceScope>>;
291
292 async fn compare_and_swap(
295 &self,
296 scope: &ResourceScope,
297 expected_version: u64,
298 snapshot: &EnvironmentMetadataSnapshot,
299 ) -> Result<bool>;
300
301 async fn compare_and_patch(
304 &self,
305 scope: &ResourceScope,
306 expected_version: u64,
307 patch: &EnvironmentMetadataPatch,
308 ) -> Result<bool>;
309}
310
311#[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
348pub 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 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 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}