1use std::any::{Any, TypeId};
8use std::collections::{BTreeMap, HashMap};
9use std::fmt;
10use std::sync::{Arc, Mutex};
11
12use async_trait::async_trait;
13use everruns_core::session_files::SessionFileSystem;
14use everruns_provider::typed_id::{SessionId, WorkspaceId};
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17use uuid::Uuid;
18
19#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
21#[serde(transparent)]
22pub struct WorkspaceProviderId(String);
23
24impl WorkspaceProviderId {
25 pub fn new(value: impl Into<String>) -> Result<Self, WorkspaceError> {
26 let value = value.into();
27 if value.trim().is_empty() || value.len() > 128 {
28 return Err(WorkspaceError::InvalidRequest(
29 "workspace provider id must contain 1..=128 characters".into(),
30 ));
31 }
32 Ok(Self(value))
33 }
34
35 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38}
39
40impl fmt::Display for WorkspaceProviderId {
41 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42 formatter.write_str(&self.0)
43 }
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
48#[serde(transparent)]
49pub struct WorkspaceHeadId(Uuid);
50
51impl WorkspaceHeadId {
52 pub fn new() -> Self {
53 Self(Uuid::new_v4())
54 }
55
56 pub const fn from_uuid(value: Uuid) -> Self {
57 Self(value)
58 }
59
60 pub const fn uuid(self) -> Uuid {
61 self.0
62 }
63}
64
65impl Default for WorkspaceHeadId {
66 fn default() -> Self {
67 Self::new()
68 }
69}
70
71impl fmt::Display for WorkspaceHeadId {
72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73 fmt::Display::fmt(&self.0, formatter)
74 }
75}
76
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82pub struct WorkspaceBinding {
83 pub provider_id: WorkspaceProviderId,
84 pub workspace_id: WorkspaceId,
85 pub head_id: WorkspaceHeadId,
86 pub access: WorkspaceHeadAccess,
87 #[serde(default)]
88 pub payload: Vec<u8>,
89}
90
91impl WorkspaceBinding {
92 pub const MAX_PAYLOAD_BYTES: usize = 64 * 1024;
94
95 pub fn validate(&self) -> Result<(), WorkspaceError> {
96 if self.payload.len() > Self::MAX_PAYLOAD_BYTES {
97 return Err(WorkspaceError::InvalidRequest(
98 "workspace binding payload is too large".into(),
99 ));
100 }
101 Ok(())
102 }
103}
104
105#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum WorkspaceHeadAccess {
109 #[default]
110 Isolated,
111 Shared,
112}
113
114#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct WorkspaceDescriptor {
117 pub id: WorkspaceId,
118 pub name: String,
119 pub metadata: BTreeMap<String, String>,
120}
121
122#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct WorkspaceHeadDescriptor {
125 pub id: WorkspaceHeadId,
126 pub name: String,
127 pub base: Option<String>,
128 pub access: WorkspaceHeadAccess,
129 pub metadata: BTreeMap<String, String>,
130}
131
132pub struct WorkspaceHeadResource {
134 pub workspace: WorkspaceDescriptor,
135 pub head: WorkspaceHeadDescriptor,
136 pub binding: WorkspaceBinding,
137 pub file_system: Arc<dyn SessionFileSystem>,
138}
139
140#[derive(Clone, Debug, PartialEq, Eq)]
142pub struct WorkspaceHeadRequest {
143 pub name: String,
144 pub base: Option<String>,
145 pub access: WorkspaceHeadAccess,
146}
147
148#[derive(Clone, Debug, PartialEq, Eq)]
150pub struct WorkspaceCheckpoint {
151 pub revision: String,
152 pub metadata: BTreeMap<String, String>,
153}
154
155#[derive(Clone, Debug, Default, PartialEq, Eq)]
157pub struct WorkspaceHeadStatus {
158 pub dirty: bool,
159 pub conflicted: bool,
160 pub archived: bool,
161 pub metadata: BTreeMap<String, String>,
162}
163
164#[derive(Clone, Debug, Default, PartialEq, Eq)]
166pub struct WorkspaceDiff {
167 pub changed: bool,
168 pub conflicted: bool,
169 pub metadata: BTreeMap<String, String>,
170}
171
172#[derive(Clone, Debug, Error, PartialEq, Eq)]
174#[non_exhaustive]
175pub enum WorkspaceError {
176 #[error("invalid workspace request: {0}")]
177 InvalidRequest(String),
178 #[error("workspace provider is unavailable: {0}")]
179 ProviderUnavailable(String),
180 #[error("workspace or head was not found")]
181 NotFound,
182 #[error("workspace head is archived")]
183 Archived,
184 #[error("workspace head has a conflicting update")]
185 Conflict,
186 #[error("workspace binding does not match the requested provider, workspace, or head")]
187 BindingMismatch,
188 #[error("workspace provider failed: {0}")]
189 Provider(String),
190}
191
192#[async_trait]
198pub trait WorkspaceProvider: Send + Sync {
199 fn id(&self) -> WorkspaceProviderId;
200
201 async fn open_workspace(&self, locator: &str) -> Result<WorkspaceDescriptor, WorkspaceError>;
202
203 async fn open_workspace_from_binding(
205 &self,
206 binding: &WorkspaceBinding,
207 ) -> Result<WorkspaceDescriptor, WorkspaceError>;
208
209 async fn create_head(
210 &self,
211 workspace: &WorkspaceDescriptor,
212 request: WorkspaceHeadRequest,
213 ) -> Result<WorkspaceHeadResource, WorkspaceError>;
214
215 async fn reopen_head(
216 &self,
217 binding: &WorkspaceBinding,
218 ) -> Result<WorkspaceHeadResource, WorkspaceError>;
219
220 async fn checkpoint(
221 &self,
222 binding: &WorkspaceBinding,
223 ) -> Result<WorkspaceCheckpoint, WorkspaceError>;
224
225 async fn status(
226 &self,
227 binding: &WorkspaceBinding,
228 ) -> Result<WorkspaceHeadStatus, WorkspaceError>;
229
230 async fn diff(&self, binding: &WorkspaceBinding) -> Result<WorkspaceDiff, WorkspaceError>;
231
232 async fn archive(&self, binding: &WorkspaceBinding) -> Result<(), WorkspaceError>;
234
235 async fn destroy(&self, binding: &WorkspaceBinding) -> Result<(), WorkspaceError>;
241}
242
243#[derive(Clone)]
245pub struct Workspace {
246 provider: Arc<dyn WorkspaceProvider>,
247 descriptor: WorkspaceDescriptor,
248}
249
250impl Workspace {
251 pub fn from_descriptor(
252 provider: Arc<dyn WorkspaceProvider>,
253 descriptor: WorkspaceDescriptor,
254 ) -> Self {
255 Self {
256 provider,
257 descriptor,
258 }
259 }
260
261 pub async fn open(
262 provider: Arc<dyn WorkspaceProvider>,
263 locator: impl AsRef<str>,
264 ) -> Result<Self, WorkspaceError> {
265 let descriptor = provider.open_workspace(locator.as_ref()).await?;
266 Ok(Self {
267 provider,
268 descriptor,
269 })
270 }
271
272 pub fn id(&self) -> WorkspaceId {
273 self.descriptor.id
274 }
275
276 pub fn name(&self) -> &str {
277 &self.descriptor.name
278 }
279
280 pub fn metadata(&self) -> &BTreeMap<String, String> {
281 &self.descriptor.metadata
282 }
283
284 pub fn head(&self, name: impl Into<String>) -> WorkspaceHeadBuilder {
285 WorkspaceHeadBuilder {
286 workspace: self.clone(),
287 name: name.into(),
288 base: None,
289 access: WorkspaceHeadAccess::Isolated,
290 }
291 }
292
293 pub async fn reopen(
294 &self,
295 binding: &WorkspaceBinding,
296 ) -> Result<WorkspaceHead, WorkspaceError> {
297 if binding.provider_id != self.provider.id() || binding.workspace_id != self.id() {
298 return Err(WorkspaceError::BindingMismatch);
299 }
300 let resource = self.provider.reopen_head(binding).await?;
301 self.attach(resource, Some(binding))
302 }
303
304 fn attach(
305 &self,
306 resource: WorkspaceHeadResource,
307 expected: Option<&WorkspaceBinding>,
308 ) -> Result<WorkspaceHead, WorkspaceError> {
309 resource.binding.validate()?;
310 if resource.workspace.id != self.id()
311 || resource.binding.provider_id != self.provider.id()
312 || resource.binding.workspace_id != self.id()
313 || resource.binding.head_id != resource.head.id
314 || resource.binding.access != resource.head.access
315 || expected.is_some_and(|expected| expected != &resource.binding)
316 {
317 return Err(WorkspaceError::BindingMismatch);
318 }
319 Ok(WorkspaceHead {
320 provider: self.provider.clone(),
321 workspace: resource.workspace,
322 descriptor: resource.head,
323 binding: resource.binding,
324 file_system: resource.file_system,
325 })
326 }
327}
328
329impl fmt::Debug for Workspace {
330 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
331 formatter
332 .debug_struct("Workspace")
333 .field("provider", &self.provider.id())
334 .field("descriptor", &self.descriptor)
335 .finish()
336 }
337}
338
339pub struct WorkspaceHeadBuilder {
341 workspace: Workspace,
342 name: String,
343 base: Option<String>,
344 access: WorkspaceHeadAccess,
345}
346
347impl WorkspaceHeadBuilder {
348 pub fn from_revision(mut self, revision: impl Into<String>) -> Self {
349 self.base = Some(revision.into());
350 self
351 }
352
353 pub fn shared(mut self) -> Self {
355 self.access = WorkspaceHeadAccess::Shared;
356 self
357 }
358
359 pub async fn create(self) -> Result<WorkspaceHead, WorkspaceError> {
360 if self.name.trim().is_empty() || self.name.len() > 256 {
361 return Err(WorkspaceError::InvalidRequest(
362 "workspace head name must contain 1..=256 characters".into(),
363 ));
364 }
365 let resource = self
366 .workspace
367 .provider
368 .create_head(
369 &self.workspace.descriptor,
370 WorkspaceHeadRequest {
371 name: self.name,
372 base: self.base,
373 access: self.access,
374 },
375 )
376 .await?;
377 self.workspace.attach(resource, None)
378 }
379}
380
381#[derive(Clone)]
383pub struct WorkspaceHead {
384 provider: Arc<dyn WorkspaceProvider>,
385 workspace: WorkspaceDescriptor,
386 descriptor: WorkspaceHeadDescriptor,
387 binding: WorkspaceBinding,
388 file_system: Arc<dyn SessionFileSystem>,
389}
390
391impl WorkspaceHead {
392 pub fn provider(&self) -> Arc<dyn WorkspaceProvider> {
396 self.provider.clone()
397 }
398
399 pub fn workspace_id(&self) -> WorkspaceId {
400 self.workspace.id
401 }
402
403 pub fn id(&self) -> WorkspaceHeadId {
404 self.descriptor.id
405 }
406
407 pub fn name(&self) -> &str {
408 &self.descriptor.name
409 }
410
411 pub fn base(&self) -> Option<&str> {
412 self.descriptor.base.as_deref()
413 }
414
415 pub fn access(&self) -> WorkspaceHeadAccess {
416 self.descriptor.access
417 }
418
419 pub fn binding(&self) -> &WorkspaceBinding {
420 &self.binding
421 }
422
423 pub fn file_system(&self) -> Arc<dyn SessionFileSystem> {
424 self.file_system.clone()
425 }
426
427 pub async fn checkpoint(&self) -> Result<WorkspaceCheckpoint, WorkspaceError> {
428 self.provider.checkpoint(&self.binding).await
429 }
430
431 pub async fn status(&self) -> Result<WorkspaceHeadStatus, WorkspaceError> {
432 self.provider.status(&self.binding).await
433 }
434
435 pub async fn diff(&self) -> Result<WorkspaceDiff, WorkspaceError> {
436 self.provider.diff(&self.binding).await
437 }
438
439 pub async fn archive(&self) -> Result<(), WorkspaceError> {
440 self.provider.archive(&self.binding).await
441 }
442
443 pub async fn destroy(self) -> Result<(), WorkspaceError> {
444 self.provider.destroy(&self.binding).await
445 }
446
447 pub async fn fork(&self, name: impl Into<String>) -> Result<WorkspaceHead, WorkspaceError> {
449 let checkpoint = self.checkpoint().await?;
450 Workspace {
451 provider: self.provider.clone(),
452 descriptor: self.workspace.clone(),
453 }
454 .head(name)
455 .from_revision(checkpoint.revision)
456 .create()
457 .await
458 }
459}
460
461impl fmt::Debug for WorkspaceHead {
462 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
463 formatter
464 .debug_struct("WorkspaceHead")
465 .field("provider", &self.provider.id())
466 .field("workspace", &self.workspace)
467 .field("descriptor", &self.descriptor)
468 .field("binding", &self.binding)
469 .finish_non_exhaustive()
470 }
471}
472
473#[derive(Clone)]
476pub struct Environment {
477 head: WorkspaceHead,
478 extensions: Arc<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>,
479}
480
481impl Environment {
482 pub fn new(head: WorkspaceHead) -> Self {
484 Self {
485 head,
486 extensions: Arc::new(HashMap::new()),
487 }
488 }
489
490 pub fn builder() -> EnvironmentBuilder {
491 EnvironmentBuilder::default()
492 }
493
494 pub fn workspace_head(&self) -> &WorkspaceHead {
495 &self.head
496 }
497
498 pub fn extension<T: Any + Send + Sync>(&self) -> Option<Arc<T>> {
499 self.extensions
500 .get(&TypeId::of::<T>())
501 .and_then(|value| value.clone().downcast().ok())
502 }
503}
504
505impl fmt::Debug for Environment {
506 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
507 formatter
508 .debug_struct("Environment")
509 .field("head", &self.head)
510 .field("extension_count", &self.extensions.len())
511 .finish()
512 }
513}
514
515#[derive(Default)]
516pub struct EnvironmentBuilder {
517 head: Option<WorkspaceHead>,
518 extensions: HashMap<TypeId, Arc<dyn Any + Send + Sync>>,
519}
520
521impl EnvironmentBuilder {
522 pub fn workspace(mut self, head: WorkspaceHead) -> Self {
523 self.head = Some(head);
524 self
525 }
526
527 pub fn extension<T: Any + Send + Sync>(mut self, value: Arc<T>) -> Self {
528 self.extensions.insert(TypeId::of::<T>(), value);
529 self
530 }
531
532 pub fn workspace_extension<T: Any + Send + Sync>(
538 mut self,
539 create: impl FnOnce(&WorkspaceHead) -> Arc<T>,
540 ) -> Result<Self, WorkspaceError> {
541 let head = self.head.as_ref().ok_or_else(|| {
542 WorkspaceError::InvalidRequest(
543 "workspace must be selected before a workspace extension".into(),
544 )
545 })?;
546 self.extensions.insert(TypeId::of::<T>(), create(head));
547 Ok(self)
548 }
549
550 pub fn build(self) -> Result<Environment, WorkspaceError> {
551 Ok(Environment {
552 head: self.head.ok_or_else(|| {
553 WorkspaceError::InvalidRequest("environment requires a workspace head".into())
554 })?,
555 extensions: Arc::new(self.extensions),
556 })
557 }
558}
559
560#[async_trait]
562pub trait EnvironmentBindingStore: Send + Sync {
563 async fn load(
564 &self,
565 session_id: SessionId,
566 ) -> Result<Option<WorkspaceBinding>, EnvironmentBindingError>;
567
568 async fn bind(
569 &self,
570 session_id: SessionId,
571 binding: &WorkspaceBinding,
572 ) -> Result<(), EnvironmentBindingError>;
573}
574
575#[derive(Clone, Debug, Error, PartialEq, Eq)]
576#[non_exhaustive]
577pub enum EnvironmentBindingError {
578 #[error("session is already bound to a different workspace head")]
579 Conflict,
580 #[error("environment binding store is unavailable")]
581 Unavailable,
582 #[error("persisted environment binding is corrupt")]
583 Corrupt,
584}
585
586#[derive(Default)]
588pub struct InMemoryEnvironmentBindingStore {
589 bindings: Mutex<HashMap<SessionId, WorkspaceBinding>>,
590}
591
592#[async_trait]
593impl EnvironmentBindingStore for InMemoryEnvironmentBindingStore {
594 async fn load(
595 &self,
596 session_id: SessionId,
597 ) -> Result<Option<WorkspaceBinding>, EnvironmentBindingError> {
598 Ok(self
599 .bindings
600 .lock()
601 .map_err(|_| EnvironmentBindingError::Unavailable)?
602 .get(&session_id)
603 .cloned())
604 }
605
606 async fn bind(
607 &self,
608 session_id: SessionId,
609 binding: &WorkspaceBinding,
610 ) -> Result<(), EnvironmentBindingError> {
611 if binding.payload.len() > WorkspaceBinding::MAX_PAYLOAD_BYTES {
612 return Err(EnvironmentBindingError::Corrupt);
613 }
614 let mut bindings = self
615 .bindings
616 .lock()
617 .map_err(|_| EnvironmentBindingError::Unavailable)?;
618 match bindings.get(&session_id) {
619 Some(recorded) if recorded != binding => Err(EnvironmentBindingError::Conflict),
620 Some(_) => Ok(()),
621 None => {
622 let incompatible_claim = bindings.iter().any(|(recorded_session, recorded)| {
623 recorded_session != &session_id
624 && recorded.provider_id == binding.provider_id
625 && recorded.workspace_id == binding.workspace_id
626 && recorded.head_id == binding.head_id
627 && (binding.access == WorkspaceHeadAccess::Isolated
628 || recorded.access == WorkspaceHeadAccess::Isolated)
629 });
630 if incompatible_claim {
631 return Err(EnvironmentBindingError::Conflict);
632 }
633 bindings.insert(session_id, binding.clone());
634 Ok(())
635 }
636 }
637 }
638}