use self::{ breadcrumbs::ContainingObject, model::{ actions::ObjectActions, generic_string_model::{ GenericStringModel, GenericStringObjectId, Serializer, StringModel, }, persistence::CloudModel, }, }; use crate::server::cloud_objects::update_manager::InitiatedBy; use crate::{ ai::cloud_agent_config::CloudAgentConfigModel, ai::cloud_environments::CloudAmbientAgentEnvironmentModel, ai::{ ambient_agents::scheduled::CloudScheduledAmbientAgentModel, document::ai_document_model::AIDocumentId, execution_profiles::CloudAIExecutionProfileModel, facts::CloudAIFactModel, mcp::{templatable::CloudTemplatableMCPServerModel, CloudMCPServerModel}, }, appearance::Appearance, auth::UserUid, channel::ChannelState, drive::{ folders::{CloudFolderModel, FolderId}, items::WarpDriveItem, CloudObjectTypeAndId, OpenGalaxyDriveObjectArgs, OpenGalaxyDriveObjectSettings, }, env_vars::CloudEnvVarCollectionModel, notebooks::{CloudNotebookModel, NotebookId}, persistence::ModelEvent, server::{ ids::{ ClientId, HashableId, HashedSqliteId, ObjectUid, ServerId, ServerIdAndType, SyncId, ToServerId, }, server_api::object::ObjectClient, sync_queue::{QueueItem, SerializedModel}, }, settings::cloud_preferences::CloudPreferenceModel, util::time_format::format_approx_duration_from_now_utc, workflows::{ workflow_enum::CloudWorkflowEnumModel, CloudWorkflow, CloudWorkflowModel, WorkflowId, WorkflowSource, }, workspaces::{ user_profiles::{UserProfileWithUID, UserProfiles}, user_workspaces::UserWorkspaces, }, }; use anyhow::Result; use async_trait::async_trait; use chrono::{Duration, Utc}; use derivative::Derivative; use galaxy_core::{channel::Channel, features::FeatureFlag}; use galaxy_graphql::{ queries::get_updated_cloud_objects::UpdatedObjectInput, scalars::time::ServerTimestamp, }; use galaxyui::{AppContext, SingletonEntity}; use lazy_static::lazy_static; use regex::Regex; use std::{ any::Any, collections::{HashMap, HashSet}, fmt::Debug, sync::Arc, }; use url::Url; pub mod breadcrumbs; pub mod grab_edit_access_modal; pub mod model; pub mod toast_message; pub use galaxy_server_client::cloud_object::*; /// A CloudObject represents /// therefore shareable and editable (i.e. Notebooks and Workflows). In order /// to support collaborative editing of these objects, they must each store local /// revision numbers to ensure a stable way of accepting and rejecting edits. /// /// Note that this trait must be object-safe and non-generic. The reason for this /// is that (a) we need to be able to store instances of it as trait objects in /// CloudModel and (b) we need to be able to support mixed collections of different /// instances of it (e.g. in the map of id -> CloudObject in CloudModel). /// /// There are two closely related types to this: /// 1) GenericCloudObject: This is the concrete generic implementation of CloudObject that /// holds onto a model of type CloudModelType and an id of type SyncId. /// 2) CloudModelType: This is a trait that defines the model type for a CloudObject - /// this is what implementors of new cloud types typically have to implement. /// /// These types are tightly coupled. In an ideal world, rust would allow a mechanism /// for us having a single interface that new model types could implement that could /// be generic on id and model types, but as far as I (zach) can tell, that's not currently /// possible. /// /// The typical usage pattern for these types is to use dyn CloudObject whenever you /// don't need access to a model or id, and to downcast to a GenericCloudObject whenever you do. /// /// This implies that, for now, *all* CloudObjects must implement GenericCloudObject. /// /// Additionally, they must support the "grab the baton" UX for editing, where any /// user can grab edit access of an object, revoking it from anyone else currently /// editing. /// /// For more info on revisions: https://docs.google.com/document/d/1SGtX_5AiSJmUxXCRk5NzGTzrC_XrxQRsio-KZOec_ng/edit /// And grab the baton: https://docs.google.com/document/d/1LgGaz8bB40AONTzC0ZFOw5Kg0SD8_RM10V_nyt3zOvY/edit#heading=h.tcup5oqi82p4 pub trait CloudObject: Debug { /// Returns the name of this model type (e.g. Workflow, Folder, Notebook) fn model_type_name(&self) -> &'static str; /// Returns the uid for this object. fn uid(&self) -> ObjectUid; /// Returns the [`SyncId`] that currently identifies this object. fn sync_id(&self) -> SyncId; /// Returns the id used to index into sqlite, this is the object's UID with its type /// prefixed, such as "Workflow-{UID}" fn hashed_sqlite_id(&self) -> HashedSqliteId; /// Returns the CloudObjectMetadata struct associated with this object. fn metadata(&self) -> &CloudObjectMetadata; /// Returns a mutable reference to the CloudObjectMetadata struct associated with this object. fn metadata_mut(&mut self) -> &mut CloudObjectMetadata; /// Returns the CloudObjectPermissions struct associated with this object. fn permissions(&self) -> &CloudObjectPermissions; /// Returnsa mutable reference to the CloudObjectPermissions struct associated with this object. fn permissions_mut(&mut self) -> &mut CloudObjectPermissions; /// Returns the ObjectType i.e. 'Workflow' or 'Notebook' fn object_type(&self) -> ObjectType; /// Returns the CloudObjectTypeAndId for this object. fn cloud_object_type_and_id(&self) -> CloudObjectTypeAndId; /// Sets the server id on this object. fn set_server_id(&mut self, server_id: ServerId); /// Returns whether this object can be moved to the given space. fn can_move_to_space(&self, _space: Space, _app: &AppContext) -> bool { true } // Whether to clear this object from the local SQLite DB on a unique key conflict. fn should_clear_on_unique_key_conflict(&self) -> bool { false } /// Whether to show a warning if this object is unsaved at quit time /// (which typically blocks the user from quitting) fn warn_if_unsaved_at_quit(&self) -> bool { true } /// Returns the "upsert" event for inserting / updating this object in the SQLite DB. fn upsert_event(&self) -> ModelEvent; // Returns the name of the object. fn display_name(&self) -> String; /// Returns an optional UpdatedObjectInput to use during initial load, where /// the object's timestamps are sent to the server for comparison fn versions(&self, app: &AppContext) -> Option; /// Returns an optional sync queue item of this object that would allow it to /// created properly on the server. Returns None if it's already been created /// server-side. fn create_object_queue_item( &self, entrypoint: CloudObjectEventEntrypoint, initiated_by: InitiatedBy, ) -> Option; /// Returns a sync queue item of this object that would allow it to be updated /// properly on the server. Takes an optional revision_ts to set as the revision /// in the sync queue item. fn update_object_queue_item(&self, revision_ts: Option) -> QueueItem; /// Returns whether this model type should render as a warp drive item. fn renders_in_warp_drive(&self) -> bool; /// Returns whether this model type should show update toasts in the UI. fn should_show_activity_toasts(&self) -> bool { true } /// Creates a new Warp Drive item for this object. Returns None if this /// object is not rendered in Warp Drive. fn to_warp_drive_item(&self, appearance: &Appearance) -> Option>; /// Returns the web link of this object. Will return none if we do not support web links /// for this particular object (i.e. if it's not yet sync'd to the server, or if we don't /// yet support linking to that object type). /// /// The format of an objects link follows the pattern: /// {channel}/drive/{object-type}/{object-name}-{uid}. For more information on this, /// see the linkable objects PRD (https://docs.google.com/document/d/1VQZ4sgLs4M9r2NDYyecfOalLlPmcf2fd_rDdqG35Zd8/edit) /// or tech doc (https://docs.google.com/document/d/1_TK19mRcD_0eLwbr5uFRabacIzfKocfahjEvoRcs5ko/edit) fn object_link(&self) -> Option; /// The space containing this object. /// /// If the object is shared with the current user, the space will reflect that, not the /// object's actual owner. fn space(&self, app: &AppContext) -> Space { UserWorkspaces::as_ref(app).owner_to_space(self.permissions().owner, app) } /// Whether or not this object can be "left". For shared objects, this removes all ACLs for the /// current user. Only top-level items in the shared space can be left. fn can_leave(&self, app: &AppContext) -> bool { if self.space(app) == Space::Shared { self.metadata() .folder_id .is_none_or(|parent| CloudModel::as_ref(app).get_folder(&parent).is_none()) } else { false } } /// Returns the name of the containing "object" for this object. /// This could be a folder, or in the case of top-level objects, /// the name of the space it belongs to. fn containing_object_name(&self, app: &AppContext) -> String { self.containing_objects_path(app) .into_iter() .next_back() .expect("Object should have at least one ancestor") .name } // Returns the path of all the containing "objects" for this object. // This could include folders or spaces. fn containing_objects_path(&self, app: &AppContext) -> Vec { let space = self.space(app); match self.metadata().folder_id { Some(folder_id) => { let cloud_model = CloudModel::as_ref(app); if let Some(folder) = cloud_model.get_folder_by_uid(&folder_id.uid()) { let mut path = vec![]; let ancestors = folder.containing_objects_path(app); path.extend(ancestors); path.push(folder.into()); path } else { // if for whatever reason the folder id is messed up, // just default to showing the top-level space it wound up in vec![space.into_containing_object(app)] } } None => vec![space.into_containing_object(app)], } } fn breadcrumbs(&self, app: &AppContext) -> String { self.containing_objects_path(app) .into_iter() .map(|object| object.name) .collect::>() .join(" / ") } /// Returns whether this CloudObject is in the given space fn is_in_space(&self, space: Space, app: &AppContext) -> bool { self.space(app) == space } fn is_welcome_object(&self) -> bool { self.metadata().is_welcome_object } /// Returns the direct location of the object. If the object /// is not in a folder, this will be the object's space. Otherwise, it will /// be the folder the object is placed in directly, even if that folder is nested. fn location(&self, cloud_model: &CloudModel, app: &AppContext) -> CloudObjectLocation { if let Some(folder_id) = self.metadata().folder_id { if cloud_model.get_folder(&folder_id).is_some() { return CloudObjectLocation::Folder(folder_id); } } CloudObjectLocation::Space(self.space(app)) } /// Return true is this object or any of its ancestors are trashed. Also returns true /// if a cycle is detected. fn is_trashed(&self, cloud_model: &CloudModel) -> bool { self.is_trashed_internal(cloud_model, &mut HashSet::new()) } /// Helper function for is_trashed. fn is_trashed_internal( &self, cloud_model: &CloudModel, ancestors: &mut HashSet, ) -> bool { // Base case: If the object is trashed, return true. if self.metadata().trashed_ts.is_some() { return true; } // Else: return true if the object's parent is trashed. Return false if the object has no parent. match self.metadata().folder_id.map(|parent_id| parent_id.uid()) { Some(hashed_parent_id) => { // We need to check for cycles to avoid causing a stack overflow. If a cycle is detected, return that the object is trashed. if ancestors.contains(&hashed_parent_id) { return true; } ancestors.insert(hashed_parent_id.clone()); match cloud_model.get_by_uid(&hashed_parent_id) { Some(parent) => parent.is_trashed_internal(cloud_model, ancestors), None => { // If the object has a parent, but the parent is not in CloudModel, assume // the object is shared, but not its parent. For backwards compatibility, // if sharing is disabled, default to trashed rather than untrashed. !FeatureFlag::SharedWithMe.is_enabled() } } } None => false, } } /// Returns whether this object has conflicting changes with the server. fn has_conflicting_changes(&self) -> bool; /// Returns the revision of the conflicting object, if any. /// This is used for object-safe access to conflict information. fn conflicting_object_revision(&self) -> Option; /// Clears the conflict status back to NoConflicts. fn clear_conflict_status(&mut self); /// Updates the object to deal with any conflict status. fn replace_object_with_conflict(&mut self); /// Sets the content sync status of this object to `InFlight` (if it wasn't already) and /// increments the number of in flight requests tracked in the `InFlight` enum. fn increment_in_flight_request_count(&mut self) { let new_reqs = match &self.metadata().pending_changes_statuses.content_sync_status { CloudObjectSyncStatus::InFlight(reqs) => reqs.0 + 1, _ => 1, }; self.set_pending_content_changes_status(CloudObjectSyncStatus::InFlight( NumInFlightRequests(new_reqs), )) } /// Decrements the number of in flight requests tracked in this object's `InFlight` enum. If /// that number becomes 0, it's no longer in flight, so it will be set to `status_if_no_reqs`. /// Returns true if the object is no longer in flight. fn decrement_in_flight_request_count( &mut self, status_if_no_reqs: CloudObjectSyncStatus, ) -> bool { match &self.metadata().pending_changes_statuses.content_sync_status { CloudObjectSyncStatus::InFlight(reqs) => { if reqs.0 - 1 == 0 { self.set_pending_content_changes_status(status_if_no_reqs); return true; } else { self.set_pending_content_changes_status(CloudObjectSyncStatus::InFlight( NumInFlightRequests(reqs.0 - 1), )); return false; } } _ => log::error!( "called decrement_in_flight_request_count with a non-`InFlight` cloud status" ), } true } /// Sets the content change status on this object's metadat fn set_pending_content_changes_status( &mut self, pending_content_changes_status: CloudObjectSyncStatus, ) { self.metadata_mut() .pending_changes_statuses .content_sync_status = pending_content_changes_status; } /// Whether or not this object can be exported. fn can_export(&self) -> bool; /// Returns this object as a ref to the Any type. Needed for typecasts. fn as_any(&self) -> &dyn Any; /// Returns this object as a mut ref to Any type. Needed for typecasts. fn as_any_mut(&mut self) -> &mut dyn Any; /// Returns the trait object as a concrete type reference by downcasting it. /// Returns None if the downcast fails. fn as_model_type(cloud_object: &dyn CloudObject) -> Option<&GenericCloudObject> where Self: Sized, K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType> + 'static, { cloud_object .as_any() .downcast_ref::>() } /// Returns the trait object as a concrete mutable type reference by downcasting it. /// Returns None if the downcast fails. fn as_model_type_mut( cloud_object: &mut dyn CloudObject, ) -> Option<&mut GenericCloudObject> where Self: Sized, K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType> + 'static, { cloud_object .as_any_mut() .downcast_mut::>() } /// Returns a cloned boxed version of this cloud object. /// Note that we can't force the CloudObject trait to derive from Cloned /// directly because that would make the trait not object safe. This /// is a workaround. fn clone_box(&self) -> Box; } /// Defines a common trait for cloud models to implement. /// The "model" is the domain specific piece of data for a cloud object, /// e.g. it contains the notebook, workflow, or folder specific data, but has /// no logic around metadata, permissions, or sync status. /// /// See the comments for CloudObject to understand the relationship between /// this trait, CloudObject and GenericCloudObject. They are tightly coupled. /// /// When building new model types (e.g. for settings or launch configs) we should just /// have to implement this trait, and not the entire CloudObject trait. #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] pub trait CloudModelType: Debug + Clone + Send + Sync { /// The associated CloudObject type for this model (e.g. CloudNotebook, CloudWorkflow, etc) type CloudObjectType: CloudObject + 'static; // TODO: @ianhodge - remove for sync ID refactor. type IdType: HashableId + ToServerId + Debug + Into + Clone + 'static; /// Returns the name of this model type (e.g. Workflow, Folder, Notebook) fn model_type_name(&self) -> &'static str; /// Returns the CloudObjectTypeAndId for this object. fn cloud_object_type_and_id(&self, id: SyncId) -> CloudObjectTypeAndId; /// Returns the ObjectType for this model. fn object_type(&self) -> ObjectType; /// Returns whether this model type should render as a warp drive item. fn renders_in_warp_drive(&self) -> bool; /// Returns whether this model type should show update toasts in the UI. fn should_show_activity_toasts(&self) -> bool { true } /// Whether to show a warning if this model is unsaved at quit time /// (which typically blocks the user from quitting) fn warn_if_unsaved_at_quit(&self) -> bool { true } /// Creates a new warp drive item for this model type. Returns None /// if this object does not render in Warp Drive. fn to_warp_drive_item( &self, id: SyncId, appearance: &Appearance, object: &Self::CloudObjectType, ) -> Option>; /// Returns the display name for this model (e.g. to show in the Warp Drive index) fn display_name(&self) -> String; /// Sets the display name to show in the Warp Drive Index. Setting the name /// is not currently supported by all object types, hence the default empty /// implementation. fn set_display_name(&mut self, _name: &str) {} /// Returns the upsert event for putting this model into the SQLite database. fn upsert_event(&self, object: &Self::CloudObjectType) -> ModelEvent; /// Returns a bulk upsert event for putting a list of this model into the SQLite database. fn bulk_upsert_event(objects: &[Self::CloudObjectType]) -> ModelEvent; /// Returns the sync queue item for creating this model on the server. fn create_object_queue_item( &self, object: &Self::CloudObjectType, entrypoint: CloudObjectEventEntrypoint, initiated_by: InitiatedBy, ) -> Option; /// Returns the sync queue item for updating this model on the server. /// Takes an optional revision timestamp to set in the queue item. fn update_object_queue_item( &self, revision_ts: Option, object: &Self::CloudObjectType, ) -> QueueItem; /// Returns a serialized model. fn serialized(&self) -> SerializedModel; /// Sends a request to the server to create this model. async fn send_create_request( object_client: Arc, request: CreateObjectRequest, ) -> Result; /// Sends a request to the server to update this model. async fn send_update_request( &self, object_client: Arc, server_id: ServerId, revision: Option, ) -> Result>>; /// Returns whether this model type supports being moved to the given space. fn can_move_to_space(&self, _current_space: Space, _new_space: Space) -> bool { true } /// Returns whether this model type should clear on a unique key conflict. fn should_clear_on_unique_key_conflict(&self) -> bool { false } /// Returns whether this model type supports web links fn supports_linking(&self) -> bool { true } /// Returns whether this model type should be updated after a server conflict. /// Note that for now the only model type that this is relevant for is Notebooks, /// where we show a banner in case of conflicts and ask users to manually take action. /// For other types we typically just want to replace the local object with the server /// revision, which doesn't go through this code path. fn should_update_after_server_conflict(&self) -> bool; /// Returns a new instance from a server update, or None if the update should be ignored. fn new_from_server_update(&self, server_cloud_object: &ServerCloudObject) -> Option; /// Whether this model type can be exported. fn can_export(&self) -> bool { false } } lazy_static! { static ref SPACE_DETECT_RE: Regex = Regex::new(r"\s+").expect("Expect regex to be valid"); static ref SAFE_URL_CHAR_RE: Regex = Regex::new(r"[^a-zA-Z0-9\s-]").expect("Expect regex to be valid"); } /// A generic implementation of cloud objects that can be used for any model and id types. /// /// For instance, rather than directly implementing the CloudObject trait, CloudObjects can /// implement GenericCloudObject where K is their id type and M is their model type. /// /// For example, CloudNotebook becomes: /// /// pub type CloudNotebook = GenericCloudObject /// /// The advantage of using the generic model is you get common implementations /// of CloudObject methods like ```versions``` for free. /// /// See the comments for CloudObject to understand the relationship between /// this trait, CloudObject and CloudModelType. They are tightly coupled. #[derive(Clone, Debug)] pub struct GenericCloudObject where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType + 'static, { pub id: SyncId, pub metadata: CloudObjectMetadata, pub permissions: CloudObjectPermissions, /// Tracks whether this object has a conflict with the server version. /// This is runtime state (not persisted) - conflicts are always NoConflicts when loaded from SQLite. pub conflict_status: ConflictStatus>, // Intentionally not public to prevent users of this class from holding // onto references to the model outside of this struct. // // This is an Arc in order to support clone-on-write semantics for the model. // By wrapping the model in an Arc, clones become cheap, and we can avoid // doing deep clones of the model whenever the containing object is cloned. // // Callers who want to update the model need to call set_model to update the // entire model atomically. model: Arc, } impl CloudObject for GenericCloudObject where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType> + 'static, { fn model_type_name(&self) -> &'static str { self.model.model_type_name() } fn uid(&self) -> ObjectUid { self.id.uid() } fn hashed_sqlite_id(&self) -> HashedSqliteId { self.id.sqlite_uid_hash(self.object_type().into()) } fn sync_id(&self) -> SyncId { self.id } fn should_show_activity_toasts(&self) -> bool { self.model.should_show_activity_toasts() } fn warn_if_unsaved_at_quit(&self) -> bool { self.model.warn_if_unsaved_at_quit() } fn metadata(&self) -> &CloudObjectMetadata { &self.metadata } fn metadata_mut(&mut self) -> &mut CloudObjectMetadata { &mut self.metadata } fn permissions(&self) -> &CloudObjectPermissions { &self.permissions } fn permissions_mut(&mut self) -> &mut CloudObjectPermissions { &mut self.permissions } fn object_type(&self) -> ObjectType { self.model.object_type() } fn cloud_object_type_and_id(&self) -> CloudObjectTypeAndId { self.model.cloud_object_type_and_id(self.id) } fn should_clear_on_unique_key_conflict(&self) -> bool { self.model.should_clear_on_unique_key_conflict() } fn can_move_to_space(&self, space: Space, app: &AppContext) -> bool { self.model.can_move_to_space(self.space(app), space) } fn has_conflicting_changes(&self) -> bool { self.conflict_status.has_conflicts() } fn conflicting_object_revision(&self) -> Option { match &self.conflict_status { ConflictStatus::ConflictingChanges { object } => Some(object.metadata.revision.clone()), ConflictStatus::NoConflicts => None, } } fn clear_conflict_status(&mut self) { self.conflict_status = ConflictStatus::NoConflicts; } fn replace_object_with_conflict(&mut self) { let mut new_conflict = ConflictStatus::NoConflicts; std::mem::swap(&mut new_conflict, &mut self.conflict_status); self.set_pending_content_changes_status(CloudObjectSyncStatus::NoLocalChanges); if let ConflictStatus::ConflictingChanges { object } = new_conflict { if self.model.should_update_after_server_conflict() { // Update metadata revision from the server object. self.metadata.update_revision_from_server(&object.metadata); // Update the model from the server. self.model = object.model.clone().into(); // Update conflict status - this may create a new conflict if there are pending changes. if self.metadata.has_pending_content_changes() { self.conflict_status = ConflictStatus::ConflictingChanges { object }; } else { self.conflict_status = ConflictStatus::NoConflicts; } } } } fn set_server_id(&mut self, server_id: ServerId) { self.id = SyncId::ServerId(server_id); } fn object_link(&self) -> Option { if !self.model.supports_linking() { return None; } let display_name = self.model.display_name(); // First remove all the url unsafe chars let name_without_unsafe_chars = SAFE_URL_CHAR_RE.replace_all(display_name.trim(), ""); // Then turn all the spaces into dashes let link_safe_name = SPACE_DETECT_RE.replace_all(&name_without_unsafe_chars, "-"); match &self.id { SyncId::ClientId(_) => None, SyncId::ServerId(id) => { let object_type = self.object_type(); let object_type_for_link = if self .as_any() .downcast_ref::() .is_some_and(|w| w.model().data.is_agent_mode_workflow()) { "prompt".to_string() } else { object_type.to_string() }; let mut link = format!( "{}/drive/{}/{}-{}", ChannelState::server_root_url(), object_type_for_link, link_safe_name, id.uid() ); // If this is a preview build, ensure the link routes to a preview build. if matches!(ChannelState::channel(), Channel::Preview) { link.push_str("?preview=true"); } Some(link) } } } fn upsert_event(&self) -> ModelEvent { self.model.upsert_event(self) } fn display_name(&self) -> String { self.model.display_name() } fn versions(&self, app: &AppContext) -> Option { match (self.id, self.metadata.revision.as_ref()) { (SyncId::ServerId(id), Some(revision)) => { let actions_ts = ObjectActions::as_ref(app) .get_latest_processed_at_ts(&self.id.uid()) .map(|t| t.into()); Some(UpdatedObjectInput { uid: id.into(), revision_ts: revision.timestamp(), metadata_ts: self.metadata.metadata_last_updated_ts, permissions_ts: self.permissions.permissions_last_updated_ts, actions_ts, }) } _ => None, } } fn create_object_queue_item( &self, entrypoint: CloudObjectEventEntrypoint, initiated_by: InitiatedBy, ) -> Option { self.model .create_object_queue_item(self, entrypoint, initiated_by) } fn update_object_queue_item(&self, revision_ts: Option) -> QueueItem { self.model.update_object_queue_item(revision_ts, self) } fn renders_in_warp_drive(&self) -> bool { self.model.renders_in_warp_drive() } fn to_warp_drive_item(&self, appearance: &Appearance) -> Option> { self.model.to_warp_drive_item(self.id, appearance, self) } fn can_export(&self) -> bool { self.model.can_export() } fn as_any(&self) -> &dyn Any { self } fn as_any_mut(&mut self) -> &mut dyn Any { self } fn clone_box(&self) -> Box { Box::new(self.clone()) } } impl GenericCloudObject where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType> + 'static, { /// Gets a reference to the model held by the object. pub fn model(&self) -> &M { &self.model } /// Returns a shared handle to the model. pub fn shared_model(&self) -> Arc { self.model.clone() } /// Sets a new version of the model on the object, replacing the old version. pub fn set_model(&mut self, model: M) { self.model = model.into(); } /// Returns a bulk upsert event for putting a list of this model into the SQLite database. pub fn bulk_upsert_event(objects: &[Self]) -> ModelEvent { M::bulk_upsert_event(objects) } /// Constructs a new instance of this model with the given id, model, metadata and permissions. pub fn new( id: SyncId, model: M, metadata: CloudObjectMetadata, permissions: CloudObjectPermissions, ) -> Self { Self { id, model: model.into(), metadata, permissions, conflict_status: ConflictStatus::NoConflicts, } } /// Creates a new GenericCloudObject with the given model, owner, and initial folder id. /// This is for the local creation flow, as opposed to creating from a server update. pub fn new_local( model: M, owner: Owner, initial_folder_id: Option, client_id: ClientId, ) -> Self { Self { id: SyncId::ClientId(client_id), model: model.into(), metadata: CloudObjectMetadata { pending_changes_statuses: CloudObjectStatuses { content_sync_status: CloudObjectSyncStatus::InFlight(NumInFlightRequests(1)), has_pending_metadata_change: false, has_pending_permissions_change: false, pending_untrash: false, pending_delete: false, }, folder_id: initial_folder_id, revision: Default::default(), metadata_last_updated_ts: Default::default(), current_editor_uid: Default::default(), trashed_ts: Default::default(), // Objects created from the client are never welcome objects. is_welcome_object: false, creator_uid: None, last_editor_uid: None, last_task_run_ts: None, }, permissions: CloudObjectPermissions { owner, anyone_with_link: None, guests: Default::default(), permissions_last_updated_ts: None, }, conflict_status: ConflictStatus::NoConflicts, } } /// Creates a new `GenericCloudObject` from a `ServerObject`. pub fn new_from_server(server_object: GenericServerObject) -> Self { Self { id: server_object.id, model: server_object.model.into(), metadata: CloudObjectMetadata::new_from_server(server_object.metadata), permissions: CloudObjectPermissions::new_from_server(server_object.permissions), conflict_status: ConflictStatus::NoConflicts, } } /// Marks this object as being in conflict with the provided object. pub fn set_conflicting_object(&mut self, object: Arc>) { self.conflict_status = ConflictStatus::ConflictingChanges { object }; } fn update_from_server_object(&mut self, server_object: GenericServerObject) { // Check if we should create a conflict or apply the update. if self.metadata.has_pending_content_changes() || self.has_conflicting_changes() { // There are pending changes, so this creates a conflict. self.conflict_status = ConflictStatus::ConflictingChanges { object: Arc::new(server_object), }; } else { // No pending changes, apply the server update. self.metadata .update_revision_from_server(&server_object.metadata); self.model = server_object.model.clone().into(); self.conflict_status = ConflictStatus::NoConflicts; } } } /// Extracts the server id and object type from a (caller validated) Drive link. /// Intended use is deriving metadata from links such that Warp objects /// can be opened natively in Warp with no web interaction. pub fn extract_server_id_and_object_type_from_warp_drive_link( url: &Url, ) -> Option { let server_id = url .path_segments() .and_then(|mut segments| segments.next_back()) .and_then(|last_segment| last_segment.split('-').next_back()) .map(|id| id.to_string()); let object_type = url.path_segments().and_then(|mut segments| segments.nth(1)); // Parse the object portion of the path segment (warp.dev/drive/{object}) // into an object type let object_type = match object_type { Some("notebook") => ObjectType::Notebook, Some("workflow") => ObjectType::Workflow, _ => return None, }; let query_string: HashMap<_, _> = url.query_pairs().collect(); let focused_folder_id: Option = query_string .get("focused_folder_id") .and_then(|s| s.to_string().try_into().ok()); let invitee_email: Option = query_string.get("invitee_email").map(|s| s.to_string()); Some(OpenGalaxyDriveObjectArgs { object_type, server_id: match server_id { Some(server_id) => server_id.try_into().ok()?, _ => return None, }, settings: OpenGalaxyDriveObjectSettings { focused_folder_id, invitee_email, }, }) } impl<'a, K, M> From<&'a dyn CloudObject> for Option<&'a GenericCloudObject> where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType> + 'static, { fn from(value: &'a dyn CloudObject) -> Self { as CloudObject>::as_model_type(value) } } impl<'a, K, M> From<&'a Box> for Option<&'a GenericCloudObject> where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType> + 'static, { fn from(value: &'a Box) -> Self { as CloudObject>::as_model_type(value.as_ref()) } } impl<'a, K, M> From<&'a mut Box> for Option<&'a mut GenericCloudObject> where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType> + 'static, { fn from(value: &'a mut Box) -> Self { as CloudObject>::as_model_type_mut(value.as_mut()) } } impl Clone for Box { fn clone(&self) -> Self { self.clone_box() } } #[derive(Clone, Debug, Default)] pub enum ConflictStatus { #[default] NoConflicts, ConflictingChanges { object: Arc, }, } impl ConflictStatus { /// Utility function that allows for a more ergonomic way of figuring out whether there is a /// conflict (for cases where we don't care about the conflict details). pub fn has_conflicts(&self) -> bool { matches!(self, ConflictStatus::ConflictingChanges { .. }) } } /// Represents a unique key for a generic string object. The server enforces that /// no two generic string objects have the same key. #[derive(PartialEq, Eq, Debug, Clone)] pub struct GenericStringObjectUniqueKey { /// The unique key. E.g. for cloud prefs this is the storage_key of the pref pub key: String, /// Whether this key is unique for all generic string objects, or unique per user. pub unique_per: UniquePer, } #[derive(PartialEq, Eq, Debug, Clone)] pub enum UniquePer { User, } impl From<&dyn CloudObject> for ObjectType { fn from(value: &dyn CloudObject) -> Self { value.object_type() } } impl From<&Box> for ObjectType { fn from(value: &Box) -> Self { >::from(value.as_ref()) } } /// Extension trait for CloudObjectMetadata with methods that require AppContext. pub trait CloudObjectMetadataExt { /// Returns a semantic summary of the last edit to the object. For example, "Alice edited 4 weeks ago". /// Returns None if the revision and last_editor are None. fn semantic_editing_history(&self, app: &AppContext) -> Option; /// Returns a semantic summary of the object's creator. For example, "Alice" or "joan@warp.dev". #[cfg_attr(target_family = "wasm", expect(dead_code))] fn semantic_creator(&self, app: &AppContext) -> Option; /// Returns semantic summary of countdown of days until permadeletion. /// Ex: "27 days until permanent deletion" fn semantic_permadeletion_countdown(&self, app: &AppContext) -> Option; } impl CloudObjectMetadataExt for CloudObjectMetadata { fn semantic_editing_history(&self, app: &AppContext) -> Option { let user_profiles = UserProfiles::as_ref(app); // First, the editor. For example, "Joan Didion" or "joan@warp.dev". let editor_string = self .last_editor_uid .as_ref() .and_then(|uid| user_profiles.displayable_identifier_for_uid(UserUid::new(uid))); // Second, the time elapsed since the edit. For example, "just now" or "3 months ago". let time_ago_string = self .revision .clone() .map(|r| format_approx_duration_from_now_utc(r.utc())); let full_string = match (editor_string, time_ago_string) { (Some(name), Some(time_ago)) if name.is_empty() => format!("Edited {time_ago}"), (Some(name), Some(time_ago)) => format!("{name} edited {time_ago}"), (None, Some(time_ago)) => format!("Edited {time_ago}"), (Some(name), None) => format!("Last edited by {name}"), _ => return None, }; Some(full_string) } fn semantic_creator(&self, app: &AppContext) -> Option { // Todo(Jack): add creation ts. let user_profiles = UserProfiles::as_ref(app); self.creator_uid .as_ref() .and_then(|uid| user_profiles.displayable_identifier_for_uid(UserUid::new(uid))) } fn semantic_permadeletion_countdown(&self, app: &AppContext) -> Option { // 2 cases: // 1) Either the object is a root level object. // 2) Or the object is inside folder(s), call recursive function to get trashed_ts of top level folder. if let Some(trashed_ts) = self .trashed_ts .or_else(|| get_top_folder_trashed_ts(self.folder_id, app)) { let deletion_time = trashed_ts.utc() + Duration::days(31); let current_time = Utc::now(); let days_left = deletion_time.signed_duration_since(current_time).num_days(); let full_string = match days_left { 0 | 1 => "1 day until permanent deletion".to_string(), _ => format!("{days_left} days until permanent deletion"), }; Some(full_string) } else { None } } } /// Helper function to retrieve trashed_ts of top level folder given a folder_id of an object. fn get_top_folder_trashed_ts( folder_id: Option, app: &AppContext, ) -> Option { let mut folder_id = folder_id; let cloud_model = CloudModel::as_ref(app); while let Some(current_folder_id) = folder_id { // If the parent folder isn't in CloudModel, short-circuit so we don't loop forever. let folder = cloud_model.get_folder_by_uid(¤t_folder_id.uid())?; if let Some(_parent_folder_id) = folder.metadata.folder_id { folder_id = folder.metadata.folder_id } else { return folder.metadata.trashed_ts; } } None } #[derive(Clone, Debug)] pub enum ObjectPermissionUpdateResult { Success, // TODO: we should return the full permissions here Failure, } #[derive(Clone, Debug)] pub struct ObjectPermissionsUpdateData { /// Updated permissions for the modified object. pub permissions: ServerPermissions, /// Relevant user profiles for the permissions change. This is not *all* profiles that the user /// should have access to. pub profiles: Vec, } #[derive(Clone, Debug)] pub enum ObjectMetadataUpdateResult { Success { metadata: Box }, Failure, } pub enum ObjectDeleteResult { Success { deleted_ids: Vec }, Failure, } /// A cloud object from the server. #[derive(Clone, Debug)] pub enum ServerCloudObject { Notebook(ServerNotebook), Workflow(Box), Folder(ServerFolder), Preference(ServerPreference), EnvVarCollection(ServerEnvVarCollection), WorkflowEnum(ServerWorkflowEnum), AIFact(ServerAIFact), MCPServer(ServerMCPServer), AIExecutionProfile(ServerAIExecutionProfile), TemplatableMCPServer(ServerTemplatableMCPServer), AmbientAgentEnvironment(ServerAmbientAgentEnvironment), ScheduledAmbientAgent(ServerScheduledAmbientAgent), CloudAgentConfig(ServerCloudAgentConfig), } impl ServerCloudObject { pub fn metadata(&self) -> &ServerMetadata { match self { ServerCloudObject::Notebook(notebook) => ¬ebook.metadata, ServerCloudObject::Workflow(workflow) => &workflow.metadata, ServerCloudObject::Folder(folder) => &folder.metadata, ServerCloudObject::Preference(preferences) => &preferences.metadata, ServerCloudObject::EnvVarCollection(env_var_collection) => &env_var_collection.metadata, ServerCloudObject::WorkflowEnum(workflow_enum) => &workflow_enum.metadata, ServerCloudObject::AIFact(aifact) => &aifact.metadata, ServerCloudObject::MCPServer(mcp_server) => &mcp_server.metadata, ServerCloudObject::TemplatableMCPServer(templatable_mcp_server) => { &templatable_mcp_server.metadata } ServerCloudObject::AIExecutionProfile(ai_execution_profile) => { &ai_execution_profile.metadata } ServerCloudObject::AmbientAgentEnvironment(ambient_agent_environment) => { &ambient_agent_environment.metadata } ServerCloudObject::ScheduledAmbientAgent(scheduled_ambient_agent) => { &scheduled_ambient_agent.metadata } ServerCloudObject::CloudAgentConfig(cloud_agent_config) => &cloud_agent_config.metadata, } } pub fn uid(&self) -> ObjectUid { match self { ServerCloudObject::Notebook(notebook) => notebook.id.uid(), ServerCloudObject::Workflow(workflow) => workflow.id.uid(), ServerCloudObject::Folder(folder) => folder.id.uid(), ServerCloudObject::Preference(preferences) => preferences.id.uid(), ServerCloudObject::EnvVarCollection(env_var_collection) => env_var_collection.id.uid(), ServerCloudObject::WorkflowEnum(workflow_enum) => workflow_enum.id.uid(), ServerCloudObject::AIFact(aifact) => aifact.id.uid(), ServerCloudObject::MCPServer(mcp_server) => mcp_server.id.uid(), ServerCloudObject::AIExecutionProfile(ai_execution_profile) => { ai_execution_profile.id.uid() } ServerCloudObject::TemplatableMCPServer(templatable_mcp_server) => { templatable_mcp_server.id.uid() } ServerCloudObject::AmbientAgentEnvironment(ambient_agent_environment) => { ambient_agent_environment.id.uid() } ServerCloudObject::ScheduledAmbientAgent(scheduled_ambient_agent) => { scheduled_ambient_agent.id.uid() } ServerCloudObject::CloudAgentConfig(cloud_agent_config) => cloud_agent_config.id.uid(), } } } impl From<&GenericServerObject> for ServerCloudObject where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType + 'static, { fn from(value: &GenericServerObject) -> Self { if let Some(server_notebook) = value.as_any().downcast_ref::() { ServerCloudObject::Notebook(server_notebook.clone()) } else if let Some(server_workflow) = value.as_any().downcast_ref::() { ServerCloudObject::Workflow(Box::new(server_workflow.clone())) } else if let Some(server_folder) = value.as_any().downcast_ref::() { ServerCloudObject::Folder(server_folder.clone()) } else if let Some(server_preferences) = value.as_any().downcast_ref::() { ServerCloudObject::Preference(server_preferences.clone()) } else if let Some(server_env_var_collection) = value.as_any().downcast_ref::() { ServerCloudObject::EnvVarCollection(server_env_var_collection.clone()) } else if let Some(server_workflow_enum) = value.as_any().downcast_ref::() { ServerCloudObject::WorkflowEnum(server_workflow_enum.clone()) } else if let Some(server_aifact) = value.as_any().downcast_ref::() { ServerCloudObject::AIFact(server_aifact.clone()) } else if let Some(server_mcp_server) = value.as_any().downcast_ref::() { ServerCloudObject::MCPServer(server_mcp_server.clone()) } else if let Some(server_ai_execution_profile) = value.as_any().downcast_ref::() { ServerCloudObject::AIExecutionProfile(server_ai_execution_profile.clone()) } else if let Some(server_templatable_mcp_server) = value.as_any().downcast_ref::() { ServerCloudObject::TemplatableMCPServer(server_templatable_mcp_server.clone()) } else if let Some(server_ambient_agent_environment) = value .as_any() .downcast_ref::( ) { ServerCloudObject::AmbientAgentEnvironment(server_ambient_agent_environment.clone()) } else if let Some(server_scheduled_ambient_agent) = value.as_any().downcast_ref::() { ServerCloudObject::ScheduledAmbientAgent(server_scheduled_ambient_agent.clone()) } else if let Some(server_cloud_agent_config) = value.as_any().downcast_ref::() { ServerCloudObject::CloudAgentConfig(server_cloud_agent_config.clone()) } else { panic!("Unknown server object type"); } } } /// Common trait for server objects that allows us to use them as trait objects /// and downcast to concrete types when needed. pub trait ServerObject: Debug + Send + Sync { /// Returns the object type of this server object fn object_type(&self) -> ObjectType; /// Returns this object as a ref to the Any type. Needed for typecasts. fn as_any(&self) -> &dyn Any; /// Returns the trait object as a concrete type reference by downcasting it. /// Returns None if the downcast fails. fn as_concrete_type( server_object: &dyn ServerObject, ) -> Option<&GenericServerObject> where Self: Sized, K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType + 'static, { server_object .as_any() .downcast_ref::>() } /// Returns a cloned boxed version of this server object. /// Note that we can't force the ServerObject trait to derive from Cloned /// directly because that would make the trait not object safe. This /// is a workaround. fn clone_box(&self) -> Box; } /// An object that maps directly to the data returned from the server /// for a given model and id type. #[derive(Debug, Clone)] pub struct GenericServerObject where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType + 'static, { pub id: SyncId, pub model: M, pub metadata: ServerMetadata, pub permissions: ServerPermissions, } impl<'a, K, M> From<&'a dyn ServerObject> for Option<&'a GenericServerObject> where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType + 'static, { fn from(value: &'a dyn ServerObject) -> Self { as ServerObject>::as_concrete_type(value) } } impl<'a, K, M> From<&'a Box> for Option<&'a GenericServerObject> where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType + 'static, { fn from(value: &'a Box) -> Self { as ServerObject>::as_concrete_type(value.as_ref()) } } impl ServerObject for GenericServerObject where K: HashableId + ToServerId + Debug + Into + Clone + 'static, M: CloudModelType + 'static, { fn object_type(&self) -> ObjectType { self.model.object_type() } fn as_any(&self) -> &dyn Any { self } fn clone_box(&self) -> Box { Box::new(self.clone()) } } pub type ServerPreference = GenericServerObject; pub type ServerFolder = GenericServerObject; pub type ServerWorkflow = GenericServerObject; pub type ServerNotebook = GenericServerObject; pub type ServerEnvVarCollection = GenericServerObject; pub type ServerWorkflowEnum = GenericServerObject; pub type ServerAIFact = GenericServerObject; pub type ServerMCPServer = GenericServerObject; pub type ServerAIExecutionProfile = GenericServerObject; pub type ServerTemplatableMCPServer = GenericServerObject; pub type ServerAmbientAgentEnvironment = GenericServerObject; pub type ServerScheduledAmbientAgent = GenericServerObject; pub type ServerCloudAgentConfig = GenericServerObject; impl GenericServerObject> where T: StringModel< CloudObjectType = GenericCloudObject>, >, S: Serializer, { /// Helper function to create a `ServerObject` that has a GenericStringObjectId from common graphql fields. pub fn try_from_graphql_fields( uid: ServerId, serialized_model: Option, metadata: ServerMetadata, permissions: ServerPermissions, ) -> Result { if let Some(serialized_model) = serialized_model { let model = GenericStringModel::::deserialize_owned(&serialized_model)?; let id = SyncId::ServerId(uid); Ok(Self { id, model, metadata, permissions, }) } else { Err(anyhow::anyhow!( "Missing serialized model in the generic string object value" )) } } } impl ServerFolder { /// Helper function to create a `ServerFolder` from common graphql fields. pub fn try_from_graphql_fields( uid: ServerId, name: Option, metadata: ServerMetadata, permissions: ServerPermissions, is_warp_pack: bool, ) -> Result { match name { Some(name) => Ok(Self { id: SyncId::ServerId(uid), model: CloudFolderModel::new(&name, is_warp_pack), metadata, permissions, }), _ => Err(anyhow::anyhow!("Missing fields in the folder value")), } } } impl ServerNotebook { /// Helper function to create a `ServerNotebook` from common graphql fields. pub fn try_from_graphql_fields( uid: ServerId, title: Option, data: Option, ai_document_id: Option, metadata: ServerMetadata, permissions: ServerPermissions, ) -> Result { let ai_document_id: Option = ai_document_id .map(|id| AIDocumentId::try_from(&id[..])) .transpose()?; match (title, data) { (Some(title), Some(data)) => Ok(Self { id: SyncId::ServerId(uid), model: CloudNotebookModel { title, data, ai_document_id, conversation_id: None, }, metadata, permissions, }), (title, data) => Err(anyhow::anyhow!( "Missing fields in the team notebook value - title: {}, data: {}", title.is_some(), data.is_some() )), } } } impl ServerWorkflow { /// Helper function to create a `ServerWorkflow` from common graphql fields. pub fn try_from_graphql_fields( uid: ServerId, data: String, metadata: ServerMetadata, permissions: ServerPermissions, ) -> Result { let data = serde_json::from_str(data.as_str()); data.map_err(Into::into).map(|workflow| Self { id: SyncId::ServerId(uid), model: CloudWorkflowModel { data: workflow }, metadata, permissions, }) } } #[derive(Default, Clone, Copy, Debug, Eq, Derivative)] #[derivative(PartialEq, Hash)] pub enum Space { /// The current user's personal drive. #[default] Personal, /// A team that the current user belongs to. Team { team_uid: ServerId }, /// An object shared from a drive the user is not a member of. Shared, } impl Space { pub fn name(&self, app: &AppContext) -> String { match self { Space::Personal => "Personal".to_string(), Space::Team { team_uid, .. } => { let user_workspaces = UserWorkspaces::as_ref(app); if let Some(team) = user_workspaces.team_from_uid(*team_uid) { team.name.clone() } else { "Team".to_string() } } Space::Shared => "Shared with me".to_string(), } } } /// Enum for specifying the location of a warp drive object. /// Objects can live in top level spaces, or a specific folder. #[derive(Eq, PartialEq, Copy, Clone, Debug, Hash)] pub enum CloudObjectLocation { Space(Space), Folder(SyncId), Trash, } /// Result of attempting to update a cloud object. #[derive(Debug)] pub enum UpdateCloudObjectResult { /// The update was successful and the object now has the specified revision. Success { revision_and_editor: RevisionAndLastEditor, }, /// The update was rejected because the update was not sent from the current revision in /// storage. The object and revision in storage are returned. Rejected { object: T }, } /// Helper struct that contains all the info needed to create an object /// on the server pub struct CreateObjectRequest { pub serialized_model: Option, pub title: Option, pub owner: Owner, pub client_id: ClientId, pub initial_folder_id: Option, pub entrypoint: CloudObjectEventEntrypoint, } #[derive(PartialEq, Eq, Debug)] pub struct BulkCreateGenericStringObjectsRequest { pub id: ClientId, pub format: GenericStringObjectFormat, pub uniqueness_key: Option, pub serialized_model: SerializedModel, pub initial_folder_id: Option, pub entrypoint: CloudObjectEventEntrypoint, } /// Helper struct that contains all the info needed to fetch changed /// objects from the server #[derive(Default)] pub struct ObjectsToUpdate { pub notebooks: Vec, pub workflows: Vec, pub folders: Vec, pub generic_string_objects: Vec, } impl Clone for ObjectsToUpdate { fn clone(&self) -> Self { Self { notebooks: self .notebooks .iter() .map(copy_updated_object_input) .collect(), workflows: self .workflows .iter() .map(copy_updated_object_input) .collect(), folders: self.folders.iter().map(copy_updated_object_input).collect(), generic_string_objects: self .generic_string_objects .iter() .map(copy_updated_object_input) .collect(), } } } fn copy_updated_object_input(input: &UpdatedObjectInput) -> UpdatedObjectInput { UpdatedObjectInput { uid: input.uid.clone(), actions_ts: input.actions_ts, metadata_ts: input.metadata_ts, permissions_ts: input.permissions_ts, revision_ts: input.revision_ts, } } /// The data returned by the server when an object is created, generic to any object type. #[derive(Debug)] pub struct CreatedCloudObject { pub client_id: ClientId, pub revision_and_editor: RevisionAndLastEditor, pub metadata_ts: ServerTimestamp, pub server_id_and_type: ServerIdAndType, pub creator_uid: Option, pub permissions: ServerPermissions, } /// Result of attempting to create a cloud object. /// Allow large enum variant because success is the most common by far #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub enum CreateCloudObjectResult { /// The object creation was successful Success { created_cloud_object: CreatedCloudObject, }, /// The object creation denied due to an expected user error UserFacingError(String), /// The object creation was rejected because the generic string object had /// already been created by another client. GenericStringObjectUniqueKeyConflict, } /// Result of attempting to bulk create a cloud object. #[derive(Debug)] pub enum BulkCreateCloudObjectResult { /// The bulk object creation was successful Success { created_cloud_objects: Vec, }, /// The bulk object creation was rejected because at least one generic string object had /// already been created by another client. GenericStringObjectUniqueKeyConflict, } /// The creation-specific data returned by the server, which is inserted into CloudModel and persisted /// just once. #[derive(Debug, PartialEq, Clone)] pub struct ServerCreationInfo { pub server_id_and_type: ServerIdAndType, pub creator_uid: Option, pub permissions: ServerPermissions, } impl From for WorkflowSource { fn from(space: Space) -> Self { match space { Space::Personal => WorkflowSource::PersonalCloud, Space::Team { team_uid } => WorkflowSource::Team { team_uid }, // TODO(ben): Model sharing in workflow telemetry. Space::Shared => WorkflowSource::PersonalCloud, } } } impl From for WorkflowSource { fn from(owner: Owner) -> WorkflowSource { match owner { // TODO(ben): Represent shared objects in telemetry. Owner::User { .. } => Self::PersonalCloud, Owner::Team { team_uid } => Self::Team { team_uid }, } } } #[derive(Clone, Debug, PartialEq)] pub struct RevisionAndLastEditor { pub revision: Revision, pub last_editor_uid: Option, }