Skip to main content

everruns_host/
workspace.rs

1//! Host-owned workspace, head, and session-environment contracts.
2//!
3//! A workspace is logical lineage. A head is one reopenable mutable view of
4//! that lineage. Physical storage stays provider-owned and is projected into
5//! execution through the existing [`SessionFileSystem`] contract.
6
7use 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/// Stable, provider-defined SPI identifier.
20#[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/// Stable identity of one mutable workspace head.
47#[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/// Provider-owned data sufficient to reopen the exact recorded head.
78///
79/// Callers persist this value opaquely. Providers must not place credentials
80/// in `payload`; local persistence intentionally stores it as plain data.
81#[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    /// Maximum provider payload accepted by Framework persistence.
93    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/// Whether a head is intended for one session or intentionally shared.
106#[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/// Provider-neutral identity and base metadata for a logical workspace.
115#[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/// Provider-neutral identity and base metadata for a head.
123#[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
132/// A provider-produced head resource before the Framework attaches lifecycle.
133pub struct WorkspaceHeadResource {
134    pub workspace: WorkspaceDescriptor,
135    pub head: WorkspaceHeadDescriptor,
136    pub binding: WorkspaceBinding,
137    pub file_system: Arc<dyn SessionFileSystem>,
138}
139
140/// Request to create or fork one head.
141#[derive(Clone, Debug, PartialEq, Eq)]
142pub struct WorkspaceHeadRequest {
143    pub name: String,
144    pub base: Option<String>,
145    pub access: WorkspaceHeadAccess,
146}
147
148/// Provider-neutral checkpoint metadata.
149#[derive(Clone, Debug, PartialEq, Eq)]
150pub struct WorkspaceCheckpoint {
151    pub revision: String,
152    pub metadata: BTreeMap<String, String>,
153}
154
155/// Provider-neutral mutable-head status.
156#[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/// Provider-neutral diff summary for one mutable head.
165#[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/// Errors exposed by workspace providers and lifecycle operations.
173#[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/// Open service-provider interface for physical workspace implementations.
193///
194/// Git worktrees are one implementation. Remote filesystems, containers, and
195/// object-backed snapshots can implement the same trait without registering a
196/// backend enum or exposing physical paths in the universal contract.
197#[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    /// Reopen a logical workspace using only its recorded opaque binding.
204    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    /// Archive a head while retaining its provider-owned contents.
233    async fn archive(&self, binding: &WorkspaceBinding) -> Result<(), WorkspaceError>;
234
235    /// Explicitly destroy provider-owned head storage.
236    ///
237    /// Providers must never call this from `Drop`. Provider-specific durable
238    /// lineage such as a Git branch is retained unless the provider documents
239    /// a separate, explicit deletion operation.
240    async fn destroy(&self, binding: &WorkspaceBinding) -> Result<(), WorkspaceError>;
241}
242
243/// One logical workspace opened through a provider.
244#[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
339/// Builder for explicit isolated or shared heads.
340pub 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    /// Opt into concurrent sessions addressing the same mutable head.
354    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/// One stable, reopenable mutable view of a workspace.
382#[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    /// Provider that owns this head. Applications normally use lifecycle
393    /// methods on the head; the facade uses this handle to make typed resume
394    /// available for the Agent lifetime.
395    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    /// Create a new isolated head from this head's current checkpoint.
448    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/// Session execution resources. Workspace is first; future compute/network
474/// resources attach through the open type-keyed extension seam.
475#[derive(Clone)]
476pub struct Environment {
477    head: WorkspaceHead,
478    extensions: Arc<HashMap<TypeId, Arc<dyn Any + Send + Sync>>>,
479}
480
481impl Environment {
482    /// Create an Environment containing one workspace head and no extensions.
483    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    /// Attach a typed resource constructed from the selected head.
533    ///
534    /// Compute providers use this form when their process, container, or
535    /// remote mount must address the exact same head as Framework file tools.
536    /// Call [`workspace`](Self::workspace) first.
537    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/// Durable compare-and-set store for a session's opaque environment binding.
561#[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/// Process-local binding store used by the default embedded Agent lifecycle.
587#[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}