first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,12 +1,11 @@
|
||||
use crate::terminal::model::{
|
||||
block::{Block as ClientBlock, BlockTime},
|
||||
grid::Dimensions as _,
|
||||
ObfuscateSecrets,
|
||||
};
|
||||
use chrono::{DateTime, FixedOffset, Utc};
|
||||
use galaxy_graphql::mutations::share_block::DisplaySetting as GqlDisplaySetting;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::terminal::model::block::{Block as ClientBlock, BlockTime};
|
||||
use crate::terminal::model::grid::Dimensions as _;
|
||||
use crate::terminal::model::ObfuscateSecrets;
|
||||
|
||||
// These are pixel heights of various parts of an embedded block.
|
||||
pub const TITLE_HEIGHT: u32 = 34;
|
||||
pub const HEADER_PADDING: u32 = 30;
|
||||
|
||||
@@ -14,45 +14,36 @@
|
||||
//! that hit one of these panics have likely wired a non-preferences
|
||||
//! code path through the fake by mistake.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_channel::Sender;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use cloud_object_client::ObjectPermissionUpdateResult;
|
||||
use galaxy_graphql::object_permissions::AccessLevel;
|
||||
|
||||
use crate::{
|
||||
cloud_object::{
|
||||
model::{
|
||||
actions::{ObjectActionHistory, ObjectActionType},
|
||||
generic_string_model::GenericStringObjectId,
|
||||
},
|
||||
BulkCreateCloudObjectResult, BulkCreateGenericStringObjectsRequest,
|
||||
CreateCloudObjectResult, CreateObjectRequest, CreatedCloudObject,
|
||||
GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType,
|
||||
ObjectDeleteResult, ObjectIdType, ObjectMetadataUpdateResult, ObjectPermissionUpdateResult,
|
||||
ObjectPermissionsUpdateData, ObjectType, ObjectsToUpdate, Owner, Revision,
|
||||
RevisionAndLastEditor, ServerFolder, ServerMetadata, ServerNotebook, ServerObject,
|
||||
ServerPermissions, ServerPreference, ServerWorkflow, UpdateCloudObjectResult,
|
||||
},
|
||||
drive::{folders::FolderId, sharing::SharingAccessLevel},
|
||||
notebooks::NotebookId,
|
||||
server::{
|
||||
cloud_objects::{
|
||||
listener::ObjectUpdateMessage,
|
||||
update_manager::{GetCloudObjectResponse, InitialLoadResponse},
|
||||
},
|
||||
ids::{ServerId, ServerIdAndType, SyncId},
|
||||
server_api::object::{GuestIdentifier, ObjectClient},
|
||||
sync_queue::SerializedModel,
|
||||
},
|
||||
settings::cloud_preferences::{CloudPreferenceModel, Platform, Preference},
|
||||
workflows::WorkflowId,
|
||||
use crate::cloud_object::model::actions::{ObjectActionHistory, ObjectActionType};
|
||||
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
|
||||
use crate::cloud_object::{
|
||||
BulkCreateCloudObjectResult, BulkCreateGenericStringObjectsRequest, CreateCloudObjectResult,
|
||||
CreateObjectRequest, CreatedCloudObject, GenericStringObjectFormat,
|
||||
GenericStringObjectUniqueKey, JsonObjectType, ObjectDeleteResult, ObjectIdType,
|
||||
ObjectMetadataUpdateResult, ObjectPermissionsUpdateData, ObjectType, ObjectsToUpdate, Owner,
|
||||
Revision, RevisionAndLastEditor, ServerFolder, ServerMetadata, ServerNotebook, ServerObject,
|
||||
ServerPermissions, ServerPreference, ServerWorkflow, UpdateCloudObjectResult,
|
||||
};
|
||||
use crate::drive::folders::FolderId;
|
||||
use crate::drive::sharing::SharingAccessLevel;
|
||||
use crate::notebooks::NotebookId;
|
||||
use crate::server::cloud_objects::listener::ObjectUpdateMessage;
|
||||
use crate::server::cloud_objects::update_manager::{GetCloudObjectResponse, InitialLoadResponse};
|
||||
use crate::server::ids::{ServerId, ServerIdAndType, SyncId};
|
||||
use crate::server::server_api::object::{GuestIdentifier, ObjectClient};
|
||||
use crate::server::sync_queue::SerializedModel;
|
||||
use crate::settings::cloud_preferences::{CloudPreferenceModel, Platform, Preference};
|
||||
use crate::workflows::WorkflowId;
|
||||
|
||||
/// A stateful fake cloud preferences backend.
|
||||
#[derive(Clone, Default)]
|
||||
@@ -153,12 +144,12 @@ impl FakeObjectClient {
|
||||
anyone_link_sharing: None,
|
||||
permissions_last_updated_ts: stored.metadata_ts.into(),
|
||||
};
|
||||
let server_pref = ServerPreference {
|
||||
id: SyncId::ServerId(ServerId::from(*id)),
|
||||
model: stored.model.clone(),
|
||||
let server_pref = ServerPreference::new(
|
||||
SyncId::ServerId(ServerId::from(*id)),
|
||||
stored.model.clone(),
|
||||
metadata,
|
||||
permissions,
|
||||
};
|
||||
);
|
||||
Box::new(server_pref) as Box<dyn ServerObject>
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1,35 +1,21 @@
|
||||
use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
|
||||
use crate::report_error;
|
||||
use crate::server::{ids::ServerId, retry_strategies::LISTENER_RETRY_STRATEGY};
|
||||
use crate::system::{SystemStats, SystemStatsEvent};
|
||||
use crate::workspaces::{
|
||||
user_profiles::UserProfileWithUID,
|
||||
user_workspaces::{UserWorkspaces, UserWorkspacesEvent},
|
||||
};
|
||||
use crate::{
|
||||
cloud_object::{
|
||||
model::{
|
||||
actions::ObjectActionHistory,
|
||||
persistence::{CloudModel, CloudModelEvent},
|
||||
},
|
||||
ServerCloudObject, ServerMetadata, ServerPermissions,
|
||||
},
|
||||
server::server_api::object::ObjectClient,
|
||||
};
|
||||
|
||||
use super::update_manager::UpdateManager;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures_util::stream::AbortHandle;
|
||||
use galaxyui::r#async::Timer;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_channel::Sender;
|
||||
|
||||
use galaxyui::{Entity, ModelContext, RequestState, SingletonEntity};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use cloud_object_client::ObjectUpdateMessage;
|
||||
use futures_util::stream::AbortHandle;
|
||||
use instant::Instant;
|
||||
use warpui::r#async::Timer;
|
||||
use warpui::{Entity, ModelContext, ModelHandle, RequestState, SingletonEntity};
|
||||
|
||||
use super::update_manager::UpdateManager;
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
||||
use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
|
||||
use crate::report_error;
|
||||
use crate::server::retry_strategies::LISTENER_RETRY_STRATEGY;
|
||||
use crate::server::server_api::object::ObjectClient;
|
||||
use crate::system::{SystemStats, SystemStatsEvent};
|
||||
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Between successful websocket connections, we ensured at least this amount of time
|
||||
@@ -86,52 +72,6 @@ pub struct Listener {
|
||||
pending_refresh_abort_handle: Option<AbortHandle>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
pub enum ObjectUpdateMessage {
|
||||
ObjectMetadataChanged {
|
||||
metadata: ServerMetadata,
|
||||
},
|
||||
ObjectPermissionsChanged,
|
||||
// TODO(CLD-2425): Replace `ObjectPermissionsChanged` with this.
|
||||
ObjectPermissionsChangedV2 {
|
||||
object_uid: ServerId,
|
||||
permissions: ServerPermissions,
|
||||
user_profiles: Vec<UserProfileWithUID>,
|
||||
},
|
||||
ObjectContentChanged {
|
||||
server_object: Box<ServerCloudObject>,
|
||||
last_editor: Option<UserProfileWithUID>,
|
||||
},
|
||||
ObjectDeleted {
|
||||
object_uid: ServerId,
|
||||
},
|
||||
ObjectActionOccurred {
|
||||
history: ObjectActionHistory,
|
||||
},
|
||||
TeamMembershipsChanged,
|
||||
AmbientTaskUpdated {
|
||||
task_id: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ObjectUpdateMessage {
|
||||
fn as_str(&self) -> &'static str {
|
||||
use ObjectUpdateMessage::*;
|
||||
match self {
|
||||
ObjectMetadataChanged { .. } => "ObjectMetadataChanged",
|
||||
ObjectPermissionsChanged => "ObjectPermissionsChanged",
|
||||
ObjectPermissionsChangedV2 { .. } => "ObjectPermissionsChanged (V2)",
|
||||
ObjectContentChanged { .. } => "ObjectContentChanged",
|
||||
ObjectDeleted { .. } => "ObjectDeleted",
|
||||
ObjectActionOccurred { .. } => "ObjectActionOccurred",
|
||||
TeamMembershipsChanged => "TeamMembershipsChanged",
|
||||
AmbientTaskUpdated { .. } => "AmbientTaskUpdated",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Listener {
|
||||
pub fn new(cloud_objects_client: Arc<dyn ObjectClient>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
let (subscription_ready_tx, subscription_ready_rx) = async_channel::unbounded();
|
||||
@@ -197,6 +137,7 @@ impl Listener {
|
||||
// If the user is part of a team, we should start subscribing for updates.
|
||||
fn handle_user_workspaces_event(
|
||||
&mut self,
|
||||
_: ModelHandle<UserWorkspaces>,
|
||||
event: &UserWorkspacesEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
@@ -214,7 +155,12 @@ impl Listener {
|
||||
}
|
||||
|
||||
// If the user has access to >= 1 cloud objects, we should subscribe for updates.
|
||||
fn handle_cloud_model_event(&mut self, _event: &CloudModelEvent, ctx: &mut ModelContext<Self>) {
|
||||
fn handle_cloud_model_event(
|
||||
&mut self,
|
||||
_: ModelHandle<CloudModel>,
|
||||
_event: &CloudModelEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self.has_non_welcome_cloud_objects(ctx) {
|
||||
self.start_listener(ctx);
|
||||
}
|
||||
@@ -225,7 +171,12 @@ impl Listener {
|
||||
// To get around this, we manually abort the future (effectively closing the websocket)
|
||||
// when the CPU goes to sleep and restart it when it's awakened.
|
||||
// https://linear.app/warpdotdev/issue/CLD-172/websocket-hangs-when-closed-during-cpu-sleep
|
||||
fn handle_cpu_event(&mut self, event: &SystemStatsEvent, ctx: &mut ModelContext<Self>) {
|
||||
fn handle_cpu_event(
|
||||
&mut self,
|
||||
_: ModelHandle<SystemStats>,
|
||||
event: &SystemStatsEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
SystemStatsEvent::CpuWasAwakened => {
|
||||
if let Some(abort_handle) = self.current_subscription_abort_handle.take() {
|
||||
@@ -252,6 +203,7 @@ impl Listener {
|
||||
|
||||
fn handle_network_status_changed_event(
|
||||
&mut self,
|
||||
_: ModelHandle<NetworkStatus>,
|
||||
event: &NetworkStatusEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
mpsc::{sync_channel, Receiver},
|
||||
Arc,
|
||||
},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{sync_channel, Receiver};
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxy_core::execution_mode::{AppExecutionMode, ExecutionMode};
|
||||
use galaxyui::{App, ModelHandle, SingletonEntity};
|
||||
use cloud_object_client::{MockObjectClient, ObjectClient};
|
||||
use settings::manager::SettingsManager;
|
||||
|
||||
use crate::{
|
||||
auth::{auth_manager::AuthManager, AuthStateProvider},
|
||||
cloud_object::model::{
|
||||
actions::ObjectActions,
|
||||
persistence::{CloudModel, CloudModelEvent},
|
||||
},
|
||||
network::NetworkStatus,
|
||||
persistence::ModelEvent,
|
||||
server::{
|
||||
server_api::{
|
||||
object::{MockObjectClient, ObjectClient},
|
||||
ServerApiProvider,
|
||||
},
|
||||
sync_queue::SyncQueue,
|
||||
telemetry::context_provider::AppTelemetryContextProvider,
|
||||
},
|
||||
settings::{PrivacySettings, WarpDrivePrivacySettings},
|
||||
workspaces::{
|
||||
team_tester::TeamTesterStatus, update_manager::TeamUpdateManager,
|
||||
user_profiles::UserProfiles, user_workspaces::UserWorkspaces,
|
||||
},
|
||||
};
|
||||
|
||||
use super::update_manager::UpdateManager;
|
||||
use crate::auth::auth_manager::AuthManager;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::actions::ObjectActions;
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::server::telemetry::context_provider::AppTelemetryContextProvider;
|
||||
use crate::settings::{PrivacySettings, WarpDrivePrivacySettings};
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::update_manager::TeamUpdateManager;
|
||||
use crate::workspaces::user_profiles::UserProfiles;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
/// The size of the bounded channel that we use to queue persistence/sqlite-related events.
|
||||
const CHANNEL_SIZE: usize = 128;
|
||||
|
||||
@@ -1,75 +1,13 @@
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::mcp::templatable::{CloudTemplatableMCPServerModel, TemplatableMCPServer};
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::conversation::AIConversationId,
|
||||
ambient_agents::scheduled::{CloudScheduledAmbientAgentModel, ScheduledAmbientAgent},
|
||||
blocklist::BlocklistAIHistoryModel,
|
||||
cloud_environments::{AmbientAgentEnvironment, CloudAmbientAgentEnvironmentModel},
|
||||
execution_profiles::{
|
||||
profiles::AIExecutionProfilesModel, AIExecutionProfile, CloudAIExecutionProfileModel,
|
||||
},
|
||||
facts::{AIFact, CloudAIFactModel},
|
||||
},
|
||||
auth::{auth_manager::AuthManager, AuthStateProvider},
|
||||
cloud_object::{
|
||||
model::{
|
||||
actions::{ObjectAction, ObjectActionHistory, ObjectActionType, ObjectActions},
|
||||
generic_string_model::{
|
||||
GenericStringModel, GenericStringObjectId, Serializer, StringModel,
|
||||
},
|
||||
persistence::{CloudModel, CloudModelEvent, UpdateSource},
|
||||
view::{CloudViewModel, Editor, EditorState},
|
||||
},
|
||||
CloudLinkSharing, CloudModelType, CloudObject, CloudObjectEventEntrypoint,
|
||||
CloudObjectLocation, CloudObjectSyncStatus, CreateCloudObjectResult, CreateObjectRequest,
|
||||
GenericCloudObject, GenericServerObject, GenericStringObjectFormat, JsonObjectType,
|
||||
NumInFlightRequests, ObjectDeleteResult, ObjectIdType, ObjectMetadataUpdateResult,
|
||||
ObjectPermissionsUpdateData, ObjectType, Owner, Revision, RevisionAndLastEditor,
|
||||
ServerAIExecutionProfile, ServerAIFact, ServerAmbientAgentEnvironment,
|
||||
ServerCloudAgentConfig, ServerCloudObject, ServerEnvVarCollection, ServerFolder,
|
||||
ServerMCPServer, ServerMetadata, ServerNotebook, ServerObject, ServerPermissions,
|
||||
ServerPreference, ServerScheduledAmbientAgent, ServerTemplatableMCPServer, ServerWorkflow,
|
||||
ServerWorkflowEnum, Space, UpdateCloudObjectResult,
|
||||
},
|
||||
drive::{
|
||||
folders::{CloudFolderModel, FolderId},
|
||||
sharing::SharingAccessLevel,
|
||||
CloudObjectTypeAndId,
|
||||
},
|
||||
env_vars::{CloudEnvVarCollectionModel, EnvVarCollection},
|
||||
network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind},
|
||||
notebooks::{CloudNotebookModel, NotebookId},
|
||||
persistence::ModelEvent,
|
||||
server::{
|
||||
ids::{
|
||||
parse_sqlite_id_to_uid, ClientId, HashableId, HashedSqliteId, ObjectUid, ServerId,
|
||||
SyncId, ToServerId,
|
||||
},
|
||||
retry_strategies::{
|
||||
OUT_OF_BAND_REQUEST_RETRY_STRATEGY, PERIODIC_POLL, PERIODIC_POLL_RETRY_STRATEGY,
|
||||
},
|
||||
server_api::object::{GuestIdentifier, ObjectClient},
|
||||
sync_queue::{
|
||||
CreationFailureReason, GenericStringObjectToCreate, QueueItem, SyncQueue,
|
||||
SyncQueueEvent,
|
||||
},
|
||||
},
|
||||
settings::cloud_preferences::Preference,
|
||||
util::sync::Condition,
|
||||
workflows::{
|
||||
workflow::Workflow,
|
||||
workflow_enum::{CloudWorkflowEnum, CloudWorkflowEnumModel, WorkflowEnum},
|
||||
CloudWorkflowModel, WorkflowId,
|
||||
},
|
||||
workspaces::{
|
||||
team_tester::{TeamTesterStatus, TeamTesterStatusEvent},
|
||||
update_manager::TeamUpdateManager,
|
||||
user_profiles::{UserProfileWithUID, UserProfiles},
|
||||
user_workspaces::UserWorkspaces,
|
||||
},
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
#[cfg(test)]
|
||||
pub use cloud_object_client::GetCloudObjectResponse;
|
||||
pub use cloud_object_client::InitialLoadResponse;
|
||||
use futures::channel::oneshot::{self, Receiver};
|
||||
use futures::stream::AbortHandle;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
@@ -82,12 +20,78 @@ use galaxyui::{Entity, ModelContext, RequestState, RetryOption, SingletonEntity}
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::future::Future;
|
||||
use std::sync::{mpsc::SyncSender, Arc};
|
||||
use std::time::Duration;
|
||||
use galaxy_core::report_error;
|
||||
use galaxy_util::sync::Condition;
|
||||
use galaxyui::{
|
||||
duration_with_jitter, AppContext, Entity, ModelContext, ModelHandle, RequestState, RetryOption,
|
||||
SingletonEntity,
|
||||
};
|
||||
|
||||
use super::listener::ObjectUpdateMessage;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::ambient_agents::scheduled::{
|
||||
CloudScheduledAmbientAgentModel, ScheduledAmbientAgent,
|
||||
};
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::ai::cloud_environments::{AmbientAgentEnvironment, CloudAmbientAgentEnvironmentModel};
|
||||
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
use crate::ai::execution_profiles::{AIExecutionProfile, CloudAIExecutionProfileModel};
|
||||
use crate::ai::facts::{AIFact, CloudAIFactModel};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::mcp::templatable::{CloudTemplatableMCPServerModel, TemplatableMCPServer};
|
||||
use crate::auth::auth_manager::AuthManager;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::actions::{
|
||||
ObjectAction, ObjectActionHistory, ObjectActionType, ObjectActions,
|
||||
};
|
||||
use crate::cloud_object::model::generic_string_model::{
|
||||
GenericStringModel, GenericStringObjectId, Serializer, StringModel,
|
||||
};
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent, UpdateSource};
|
||||
use crate::cloud_object::model::view::{CloudViewModel, Editor, EditorState};
|
||||
use crate::cloud_object::{
|
||||
CloudLinkSharing, CloudModelType, CloudObject, CloudObjectEventEntrypoint, CloudObjectLocation,
|
||||
CloudObjectSyncStatus, CreateCloudObjectResult, CreateObjectRequest, GenericCloudObject,
|
||||
GenericServerObject, GenericStringObjectFormat, JsonObjectType, NumInFlightRequests,
|
||||
ObjectDeleteResult, ObjectIdType, ObjectMetadataUpdateResult, ObjectPermissionsUpdateData,
|
||||
ObjectType, Owner, Revision, RevisionAndLastEditor, ServerAIExecutionProfile, ServerAIFact,
|
||||
ServerAmbientAgentEnvironment, ServerCloudAgentConfig, ServerCloudObject,
|
||||
ServerEnvVarCollection, ServerMCPServer, ServerMetadata, ServerPermissions, ServerPreference,
|
||||
ServerScheduledAmbientAgent, ServerTemplatableMCPServer, ServerWorkflowEnum, Space,
|
||||
UpdateCloudObjectResult,
|
||||
};
|
||||
use crate::drive::drive_helpers::{
|
||||
is_feature_gated_anonymous_user_past_env_var_limit,
|
||||
is_feature_gated_anonymous_user_past_notebook_limit,
|
||||
is_feature_gated_anonymous_user_past_workflow_limit,
|
||||
};
|
||||
use crate::drive::folders::{CloudFolderModel, FolderId};
|
||||
use crate::drive::sharing::SharingAccessLevel;
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::env_vars::{CloudEnvVarCollectionModel, EnvVarCollection};
|
||||
use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
|
||||
use crate::notebooks::{CloudNotebookModel, NotebookId};
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::server::ids::{
|
||||
parse_sqlite_id_to_uid, ClientId, HashableId, HashedSqliteId, ObjectUid, ServerId, SyncId,
|
||||
ToServerId,
|
||||
};
|
||||
use crate::server::retry_strategies::{
|
||||
OUT_OF_BAND_REQUEST_RETRY_STRATEGY, PERIODIC_POLL, PERIODIC_POLL_RETRY_STRATEGY,
|
||||
};
|
||||
use crate::server::server_api::object::{GuestIdentifier, ObjectClient};
|
||||
use crate::server::sync_queue::{
|
||||
CreationFailureReason, GenericStringObjectToCreate, QueueItem, SyncQueue, SyncQueueEvent,
|
||||
};
|
||||
use crate::settings::cloud_preferences::Preference;
|
||||
use crate::workflows::workflow::Workflow;
|
||||
use crate::workflows::workflow_enum::{CloudWorkflowEnum, CloudWorkflowEnumModel, WorkflowEnum};
|
||||
use crate::workflows::{CloudWorkflowModel, WorkflowId};
|
||||
use crate::workspaces::team_tester::{TeamTesterStatus, TeamTesterStatusEvent};
|
||||
use crate::workspaces::update_manager::TeamUpdateManager;
|
||||
use crate::workspaces::user_profiles::{UserProfileWithUID, UserProfiles};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
lazy_static! {
|
||||
/// For online-only operations, we want to quickly determine if the operation can succeed,
|
||||
@@ -135,10 +139,19 @@ pub struct ObjectOperationResult {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum UpdateManagerEvent {
|
||||
ObjectOperationComplete { result: ObjectOperationResult },
|
||||
CloudPreferencesUpdated { updated: Vec<Preference> },
|
||||
MCPGalleryUpdated { templates: Vec<MCPGalleryTemplate> },
|
||||
AmbientTaskUpdated { timestamp: DateTime<Utc> },
|
||||
ObjectOperationComplete {
|
||||
result: ObjectOperationResult,
|
||||
},
|
||||
CloudPreferencesUpdated {
|
||||
updated: Vec<Preference>,
|
||||
},
|
||||
MCPGalleryUpdated {
|
||||
templates: Vec<MCPGalleryTemplate>,
|
||||
},
|
||||
AmbientTaskUpdated {
|
||||
task_id: AmbientAgentTaskId,
|
||||
timestamp: DateTime<Utc>,
|
||||
},
|
||||
}
|
||||
|
||||
/// An enum for choosing the behavior of the fetch_single_cloud_object function.
|
||||
@@ -159,27 +172,6 @@ pub enum InitiatedBy {
|
||||
User,
|
||||
System,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct InitialLoadResponse {
|
||||
pub updated_notebooks: Vec<ServerNotebook>,
|
||||
pub deleted_notebooks: Vec<NotebookId>,
|
||||
pub updated_workflows: Vec<ServerWorkflow>,
|
||||
pub deleted_workflows: Vec<WorkflowId>,
|
||||
pub updated_folders: Vec<ServerFolder>,
|
||||
pub deleted_folders: Vec<FolderId>,
|
||||
pub updated_generic_string_objects:
|
||||
HashMap<GenericStringObjectFormat, Vec<Box<dyn ServerObject>>>,
|
||||
pub deleted_generic_string_objects: Vec<GenericStringObjectId>,
|
||||
pub user_profiles: Vec<UserProfileWithUID>,
|
||||
pub action_histories: Vec<ObjectActionHistory>,
|
||||
pub mcp_gallery: Vec<MCPGalleryTemplate>,
|
||||
}
|
||||
|
||||
pub struct GetCloudObjectResponse {
|
||||
pub object: ServerCloudObject,
|
||||
pub descendants: Vec<ServerCloudObject>,
|
||||
pub action_histories: Vec<ObjectActionHistory>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct GenericStringObjectInput<T, S>
|
||||
@@ -218,7 +210,7 @@ impl UpdateManager {
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let network_status = NetworkStatus::handle(ctx);
|
||||
ctx.subscribe_to_model(&network_status, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&network_status, |me, _, event, ctx| {
|
||||
me.handle_network_status_changed(event, ctx);
|
||||
});
|
||||
|
||||
@@ -226,7 +218,7 @@ impl UpdateManager {
|
||||
ctx.subscribe_to_model(&team_tester_status, Self::handle_team_tester_status_changed);
|
||||
|
||||
let sync_queue = SyncQueue::handle(ctx);
|
||||
ctx.subscribe_to_model(&sync_queue, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&sync_queue, |me, _, event, ctx| {
|
||||
me.handle_model_event(event, ctx);
|
||||
});
|
||||
|
||||
@@ -316,6 +308,7 @@ impl UpdateManager {
|
||||
|
||||
fn handle_team_tester_status_changed(
|
||||
&mut self,
|
||||
_: ModelHandle<TeamTesterStatus>,
|
||||
event: &TeamTesterStatusEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
@@ -422,7 +415,7 @@ impl UpdateManager {
|
||||
}
|
||||
});
|
||||
|
||||
// Delete the actions on the client ID. Once we get a server ID for an object, we start dequeing any pending object actions and those
|
||||
// Delete the actions on the client ID. Once we get a server ID for an object, we start dequeuing any pending object actions and those
|
||||
// directly populate the ObjectActions model with the server ID, so we don't need to worry about any conversion or anything like that.
|
||||
ObjectActions::handle(ctx).update(ctx, |object_actions, ctx| {
|
||||
object_actions.delete_actions_for_object(&client_id.to_string(), ctx);
|
||||
@@ -1118,11 +1111,20 @@ impl UpdateManager {
|
||||
|
||||
fn handle_ambient_task_changed(
|
||||
&mut self,
|
||||
_task_id: String,
|
||||
task_id: String,
|
||||
timestamp: DateTime<Utc>,
|
||||
ctx: &mut ModelContext<UpdateManager>,
|
||||
) {
|
||||
ctx.emit(UpdateManagerEvent::AmbientTaskUpdated { timestamp });
|
||||
let task_id = match task_id.parse::<AmbientAgentTaskId>() {
|
||||
Ok(task_id) => task_id,
|
||||
Err(err) => {
|
||||
report_error!(anyhow::Error::from(err).context(format!(
|
||||
"AmbientTaskUpdated has unparseable task_id: {task_id}"
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
ctx.emit(UpdateManagerEvent::AmbientTaskUpdated { task_id, timestamp });
|
||||
}
|
||||
|
||||
/// Fetches environment "last used" timestamps from the server and merges them
|
||||
@@ -1178,7 +1180,12 @@ impl UpdateManager {
|
||||
)
|
||||
});
|
||||
|
||||
GenericCloudObject::<K, M>::bulk_upsert_event(&objects_without_pending_changes)
|
||||
M::bulk_upsert_event(
|
||||
objects_without_pending_changes
|
||||
.iter()
|
||||
.map(|object| object.upsert_params(object.object_type()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Generic handler deleting all objects of a given model type from the server (e.g. all updated/deleted notebooks or workflows).
|
||||
@@ -2877,7 +2884,7 @@ impl UpdateManager {
|
||||
let cloud_model = CloudModel::as_ref(ctx);
|
||||
let object: Option<&CloudWorkflowEnum> = cloud_model.get_object_of_type(enum_id);
|
||||
let Some(object) = object else {
|
||||
log::error!("Could not find referenced worfklow enum to copy over to the new space, skipping");
|
||||
log::error!("Could not find referenced workflow enum to copy over to the new space, skipping");
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -3216,6 +3223,25 @@ impl UpdateManager {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub fn create_ambient_agent_environment_online(
|
||||
&mut self,
|
||||
ambient_agent_environment: AmbientAgentEnvironment,
|
||||
client_id: ClientId,
|
||||
owner: Owner,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> impl Future<Output = anyhow::Result<ServerId>> {
|
||||
self.create_object_online(
|
||||
CloudAmbientAgentEnvironmentModel::new(ambient_agent_environment),
|
||||
owner,
|
||||
client_id,
|
||||
Default::default(),
|
||||
false,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub fn create_scheduled_ambient_agent_online(
|
||||
&mut self,
|
||||
@@ -3320,10 +3346,10 @@ impl UpdateManager {
|
||||
.count()
|
||||
});
|
||||
if AuthStateProvider::handle(ctx).read(ctx, |auth_state_provider, _ctx| {
|
||||
auth_state_provider
|
||||
.get()
|
||||
.is_anonymous_user_past_object_limit(ObjectType::Notebook, count + 1)
|
||||
.unwrap_or_default()
|
||||
is_feature_gated_anonymous_user_past_notebook_limit(
|
||||
auth_state_provider.get(),
|
||||
count + 1,
|
||||
)
|
||||
}) {
|
||||
AuthManager::handle(ctx).update(ctx, |auth_manager: &mut AuthManager, ctx| {
|
||||
auth_manager.anonymous_user_hit_drive_object_limit(ctx);
|
||||
@@ -3391,10 +3417,10 @@ impl UpdateManager {
|
||||
.count()
|
||||
});
|
||||
if AuthStateProvider::handle(ctx).read(ctx, |auth_state_provider, _ctx| {
|
||||
auth_state_provider
|
||||
.get()
|
||||
.is_anonymous_user_past_object_limit(ObjectType::Workflow, count + 1)
|
||||
.unwrap_or_default()
|
||||
is_feature_gated_anonymous_user_past_workflow_limit(
|
||||
auth_state_provider.get(),
|
||||
count + 1,
|
||||
)
|
||||
}) {
|
||||
AuthManager::handle(ctx).update(ctx, |auth_manager: &mut AuthManager, ctx| {
|
||||
auth_manager.anonymous_user_hit_drive_object_limit(ctx);
|
||||
@@ -3456,14 +3482,8 @@ impl UpdateManager {
|
||||
.active_non_welcome_env_var_collections_in_space(Space::Personal, ctx)
|
||||
.count()
|
||||
});
|
||||
let env_var_collection_type = ObjectType::GenericStringObject(
|
||||
GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection),
|
||||
);
|
||||
if AuthStateProvider::handle(ctx).read(ctx, |auth_state_provider, _ctx| {
|
||||
auth_state_provider
|
||||
.get()
|
||||
.is_anonymous_user_past_object_limit(env_var_collection_type, count + 1)
|
||||
.unwrap_or_default()
|
||||
is_feature_gated_anonymous_user_past_env_var_limit(auth_state_provider.get(), count + 1)
|
||||
}) {
|
||||
AuthManager::handle(ctx).update(ctx, |auth_manager: &mut AuthManager, ctx| {
|
||||
auth_manager.anonymous_user_hit_drive_object_limit(ctx);
|
||||
@@ -3571,7 +3591,10 @@ impl UpdateManager {
|
||||
|
||||
// Update sqlite with a single bulk request
|
||||
self.save_to_db(vec![GenericStringModel::<T, S>::bulk_upsert_event(
|
||||
&objects,
|
||||
objects
|
||||
.iter()
|
||||
.map(|object| object.upsert_params(object.object_type()))
|
||||
.collect(),
|
||||
)]);
|
||||
|
||||
// Populate sync queue with a single bulk request
|
||||
@@ -4825,5 +4848,5 @@ impl Entity for UpdateManager {
|
||||
impl SingletonEntity for UpdateManager {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "update_manager_test.rs"]
|
||||
#[path = "update_manager_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
+189
-194
@@ -1,69 +1,62 @@
|
||||
use std::{collections::HashMap, sync::Arc, time::Duration};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use cloud_object_client::MockObjectClient;
|
||||
use cloud_object_models::JsonSerializer;
|
||||
use futures_lite::future;
|
||||
use settings::{RespectUserSyncSetting, SyncToCloud};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_graphql::{object_permissions::AccessLevel, scalars::time::ServerTimestamp};
|
||||
use galaxy_graphql::object_permissions::AccessLevel;
|
||||
use galaxy_graphql::scalars::time::ServerTimestamp;
|
||||
use galaxyui::{App, ModelHandle, SingletonEntity};
|
||||
use settings::SyncToCloud;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::server::server_api::object::MockObjectClient;
|
||||
use crate::{
|
||||
ai::cloud_environments::{
|
||||
AmbientAgentEnvironment, CloudAmbientAgentEnvironment, CloudAmbientAgentEnvironmentModel,
|
||||
},
|
||||
auth::{user::TEST_USER_UID, UserUid},
|
||||
cloud_object::{
|
||||
model::{
|
||||
actions::{
|
||||
ObjectAction, ObjectActionHistory, ObjectActionSubtype, ObjectActionType,
|
||||
ObjectActions,
|
||||
},
|
||||
generic_string_model::GenericStringObjectId,
|
||||
json_model::JsonSerializer,
|
||||
persistence::{CloudModel, CloudModelEvent, UpdateSource},
|
||||
},
|
||||
BulkCreateCloudObjectResult, CloudModelType, CloudObjectEventEntrypoint, CloudObjectGuest,
|
||||
CloudObjectLocation, ConflictStatus, CreateCloudObjectResult, CreatedCloudObject,
|
||||
GenericCloudObject, GenericStringObjectFormat, JsonObjectType, ObjectDeleteResult,
|
||||
ObjectIdType, ObjectMetadataUpdateResult, ObjectPermissionsUpdateData, ObjectType, Owner,
|
||||
Revision, RevisionAndLastEditor, ServerCloudObject, ServerFolder, ServerGuestSubject,
|
||||
ServerObject, ServerObjectGuest, ServerPreference, ServerWorkflow, ServerWorkflowEnum,
|
||||
Space, UpdateCloudObjectResult,
|
||||
},
|
||||
drive::{
|
||||
folders::{CloudFolder, CloudFolderModel, FolderId},
|
||||
sharing::{SharingAccessLevel, Subject, UserKind},
|
||||
CloudObjectTypeAndId,
|
||||
},
|
||||
notebooks::{CloudNotebook, CloudNotebookModel, NotebookId},
|
||||
persistence::ModelEvent,
|
||||
server::{
|
||||
cloud_objects::{
|
||||
listener::ObjectUpdateMessage,
|
||||
test_utils::{
|
||||
create_update_manager_struct, initialize_app, mock_server_api, UpdateManagerStruct,
|
||||
},
|
||||
update_manager::{
|
||||
get_duplicate_object_name, FetchSingleObjectOption, GenericStringObjectInput,
|
||||
InitiatedBy, ServerMetadata, ServerNotebook, ServerPermissions,
|
||||
},
|
||||
},
|
||||
ids::{ClientId, HashableId, ObjectUid, ServerId, ServerIdAndType, SyncId, ToServerId},
|
||||
sync_queue::SyncQueue,
|
||||
},
|
||||
settings::{CloudPreferenceModel, Preference},
|
||||
workflows::{
|
||||
workflow::{Argument, ArgumentType, Workflow},
|
||||
workflow_enum::{CloudWorkflowEnum, CloudWorkflowEnumModel, EnumVariants, WorkflowEnum},
|
||||
CloudWorkflow, CloudWorkflowModel, WorkflowId,
|
||||
},
|
||||
workspaces::user_profiles::{UserProfileWithUID, UserProfiles},
|
||||
Assets,
|
||||
};
|
||||
|
||||
use super::{GetCloudObjectResponse, InitialLoadResponse, UpdateManager};
|
||||
use crate::ai::cloud_environments::{
|
||||
AmbientAgentEnvironment, CloudAmbientAgentEnvironment, CloudAmbientAgentEnvironmentModel,
|
||||
};
|
||||
use crate::auth::user::TEST_USER_UID;
|
||||
use crate::auth::UserUid;
|
||||
use crate::cloud_object::model::actions::{
|
||||
ObjectAction, ObjectActionHistory, ObjectActionSubtype, ObjectActionType, ObjectActions,
|
||||
};
|
||||
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
|
||||
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent, UpdateSource};
|
||||
use crate::cloud_object::{
|
||||
BulkCreateCloudObjectResult, CloudModelType, CloudObjectEventEntrypoint, CloudObjectGuest,
|
||||
CloudObjectLocation, ConflictStatus, CreateCloudObjectResult, CreatedCloudObject,
|
||||
GenericCloudObject, GenericStringObjectFormat, JsonObjectType, ObjectDeleteResult,
|
||||
ObjectIdType, ObjectMetadataUpdateResult, ObjectPermissionsUpdateData, ObjectType, Owner,
|
||||
Revision, RevisionAndLastEditor, ServerCloudObject, ServerFolder, ServerGuestSubject,
|
||||
ServerNotebook, ServerObject, ServerObjectGuest, ServerPreference, ServerWorkflow,
|
||||
ServerWorkflowEnum, Space, UpdateCloudObjectResult,
|
||||
};
|
||||
use crate::drive::folders::{CloudFolder, CloudFolderModel, FolderId};
|
||||
use crate::drive::sharing::{SharingAccessLevel, Subject, UserKind};
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::notebooks::{CloudNotebook, CloudNotebookModel, NotebookId};
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::server::cloud_objects::listener::ObjectUpdateMessage;
|
||||
use crate::server::cloud_objects::test_utils::{
|
||||
create_update_manager_struct, initialize_app, mock_server_api, UpdateManagerStruct,
|
||||
};
|
||||
use crate::server::cloud_objects::update_manager::{
|
||||
get_duplicate_object_name, FetchSingleObjectOption, GenericStringObjectInput, InitiatedBy,
|
||||
ServerMetadata, ServerPermissions,
|
||||
};
|
||||
use crate::server::ids::{
|
||||
ClientId, HashableId, ObjectUid, ServerId, ServerIdAndType, SyncId, ToServerId,
|
||||
};
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::settings::{CloudPreferenceModel, Preference};
|
||||
use crate::workflows::workflow::{Argument, ArgumentType, Workflow};
|
||||
use crate::workflows::workflow_enum::{
|
||||
CloudWorkflowEnum, CloudWorkflowEnumModel, EnumVariants, WorkflowEnum,
|
||||
};
|
||||
use crate::workflows::{CloudWorkflow, CloudWorkflowModel, WorkflowId};
|
||||
use crate::workspaces::user_profiles::{UserProfileWithUID, UserProfiles};
|
||||
use crate::ASSETS;
|
||||
|
||||
fn create_object<K, M>(
|
||||
app: &mut App,
|
||||
@@ -261,12 +254,12 @@ fn create_workflow_enum_internal(
|
||||
}
|
||||
|
||||
fn mock_server_workflow(id: WorkflowId, owner: Owner, metadata: ServerMetadata) -> ServerWorkflow {
|
||||
ServerWorkflow {
|
||||
id: SyncId::ServerId(id.into()),
|
||||
ServerWorkflow::new(
|
||||
SyncId::ServerId(id.into()),
|
||||
CloudWorkflowModel::new(Workflow::new(format!("w{id}"), format!("c{id}"))),
|
||||
metadata,
|
||||
permissions: mock_server_permissions(owner),
|
||||
model: CloudWorkflowModel::new(Workflow::new(format!("w{id}"), format!("c{id}"))),
|
||||
}
|
||||
mock_server_permissions(owner),
|
||||
)
|
||||
}
|
||||
|
||||
fn mock_server_workflow_with_enum(
|
||||
@@ -275,11 +268,9 @@ fn mock_server_workflow_with_enum(
|
||||
owner: Owner,
|
||||
metadata: ServerMetadata,
|
||||
) -> (ServerWorkflow, ServerWorkflowEnum) {
|
||||
let workflow = ServerWorkflow {
|
||||
id: SyncId::ServerId(id.into()),
|
||||
metadata: metadata.clone(),
|
||||
permissions: mock_server_permissions(owner),
|
||||
model: CloudWorkflowModel::new(
|
||||
let workflow = ServerWorkflow::new(
|
||||
SyncId::ServerId(id.into()),
|
||||
CloudWorkflowModel::new(
|
||||
Workflow::new(format!("w{id}"), format!("c{id}")).with_arguments(vec![Argument {
|
||||
name: format!("e{enum_id}"),
|
||||
default_value: None,
|
||||
@@ -289,47 +280,49 @@ fn mock_server_workflow_with_enum(
|
||||
},
|
||||
}]),
|
||||
),
|
||||
};
|
||||
metadata.clone(),
|
||||
mock_server_permissions(owner),
|
||||
);
|
||||
|
||||
let workflow_enum = ServerWorkflowEnum {
|
||||
id: SyncId::ServerId(enum_id.into()),
|
||||
metadata,
|
||||
permissions: mock_server_permissions(owner),
|
||||
model: CloudWorkflowEnumModel::new(WorkflowEnum {
|
||||
let workflow_enum = ServerWorkflowEnum::new(
|
||||
SyncId::ServerId(enum_id.into()),
|
||||
CloudWorkflowEnumModel::new(WorkflowEnum {
|
||||
name: format!("e{id}"),
|
||||
is_shared: false,
|
||||
variants: EnumVariants::Static(vec!["v1".to_string(), "v2".to_string()]),
|
||||
}),
|
||||
};
|
||||
metadata,
|
||||
mock_server_permissions(owner),
|
||||
);
|
||||
|
||||
(workflow, workflow_enum)
|
||||
}
|
||||
|
||||
fn mock_server_notebook(id: NotebookId, owner: Owner, metadata: ServerMetadata) -> ServerNotebook {
|
||||
ServerNotebook {
|
||||
id: SyncId::ServerId(id.into()),
|
||||
metadata,
|
||||
permissions: mock_server_permissions(owner),
|
||||
model: CloudNotebookModel {
|
||||
ServerNotebook::new(
|
||||
SyncId::ServerId(id.into()),
|
||||
CloudNotebookModel {
|
||||
title: format!("n{id}"),
|
||||
data: format!("n{id}"),
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
}
|
||||
metadata,
|
||||
mock_server_permissions(owner),
|
||||
)
|
||||
}
|
||||
|
||||
fn mock_server_folder(id: FolderId, owner: Owner, metadata: ServerMetadata) -> ServerFolder {
|
||||
ServerFolder {
|
||||
id: SyncId::ServerId(id.into()),
|
||||
metadata,
|
||||
permissions: mock_server_permissions(owner),
|
||||
model: CloudFolderModel {
|
||||
ServerFolder::new(
|
||||
SyncId::ServerId(id.into()),
|
||||
CloudFolderModel {
|
||||
name: format!("f{id}"),
|
||||
is_open: false,
|
||||
is_warp_pack: false,
|
||||
},
|
||||
}
|
||||
metadata,
|
||||
mock_server_permissions(owner),
|
||||
)
|
||||
}
|
||||
|
||||
fn update_notebook(
|
||||
@@ -603,10 +596,10 @@ fn mock_fetch_single_cloud_object(
|
||||
.times(1)
|
||||
.return_once(move |_| {
|
||||
Ok(GetCloudObjectResponse {
|
||||
object: ServerCloudObject::Workflow(Box::new(ServerWorkflow {
|
||||
id: SyncId::ServerId(workflow_id.into()),
|
||||
model: CloudWorkflowModel::new(Workflow::new("server workflow", "echo server")),
|
||||
metadata: ServerMetadata {
|
||||
object: ServerCloudObject::Workflow(Box::new(ServerWorkflow::new(
|
||||
SyncId::ServerId(workflow_id.into()),
|
||||
CloudWorkflowModel::new(Workflow::new("server workflow", "echo server")),
|
||||
ServerMetadata {
|
||||
uid: server_id,
|
||||
revision: Revision::now(),
|
||||
metadata_last_updated_ts: Utc::now().into(),
|
||||
@@ -617,13 +610,13 @@ fn mock_fetch_single_cloud_object(
|
||||
last_editor_uid: None,
|
||||
current_editor_uid: None,
|
||||
},
|
||||
permissions: ServerPermissions {
|
||||
ServerPermissions {
|
||||
space: Owner::mock_current_user(),
|
||||
guests: Vec::new(),
|
||||
anyone_link_sharing: None,
|
||||
permissions_last_updated_ts: Utc::now().into(),
|
||||
},
|
||||
})),
|
||||
))),
|
||||
descendants: vec![],
|
||||
action_histories: vec![ObjectActionHistory {
|
||||
uid: server_id.uid(),
|
||||
@@ -637,7 +630,7 @@ fn mock_fetch_single_cloud_object(
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_after_creation_item_not_in_sync_queue_folder() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
let client_id = ClientId::new();
|
||||
initialize_app(&mut app);
|
||||
let object_id: FolderId = 123.into();
|
||||
@@ -665,7 +658,7 @@ fn test_sync_state_after_creation_item_not_in_sync_queue_folder() {
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_after_creation_item_not_in_sync_queue_workflow() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
let client_id = ClientId::new();
|
||||
initialize_app(&mut app);
|
||||
let object_id: WorkflowId = 123.into();
|
||||
@@ -693,7 +686,7 @@ fn test_sync_state_after_creation_item_not_in_sync_queue_workflow() {
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_after_creation_item_not_in_sync_queue_notebook() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
let client_id = ClientId::new();
|
||||
initialize_app(&mut app);
|
||||
|
||||
@@ -722,7 +715,7 @@ fn test_sync_state_after_creation_item_not_in_sync_queue_notebook() {
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_after_creation_item_not_in_sync_queue_generic_object() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
let client_id = ClientId::new();
|
||||
initialize_app(&mut app);
|
||||
let object_id: GenericStringObjectId = 123.into();
|
||||
@@ -806,7 +799,9 @@ async fn run_sync_state_after_creation_item_not_in_sync_queue<K, M>(
|
||||
// we created an object in the db
|
||||
assert_eq!(
|
||||
std::mem::discriminant(&events[0]),
|
||||
std::mem::discriminant(&object.model().upsert_event(&object))
|
||||
std::mem::discriminant(&M::upsert_event(
|
||||
object.upsert_params(object.model().object_type())
|
||||
))
|
||||
);
|
||||
// when we got the correct response back from the server,
|
||||
// we updated the db with the server id
|
||||
@@ -834,7 +829,7 @@ async fn run_sync_state_after_creation_item_not_in_sync_queue<K, M>(
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_after_creation_item_in_flight() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -921,7 +916,7 @@ fn test_sync_state_after_creation_item_in_flight() {
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_after_creation_fails_due_to_limit() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let client_id = ClientId::new();
|
||||
@@ -1046,7 +1041,7 @@ fn test_sync_state_after_creation_fails_due_to_limit() {
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_after_update_item_not_in_sync_queue() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -1191,7 +1186,7 @@ fn test_sync_state_after_update_item_not_in_sync_queue() {
|
||||
|
||||
#[test]
|
||||
fn test_create_sets_editor() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let mut server_api = mock_server_api();
|
||||
@@ -1235,7 +1230,7 @@ fn test_create_sets_editor() {
|
||||
|
||||
#[test]
|
||||
fn test_bulk_create_generic_string_objects() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let client_id_1 = ClientId::new();
|
||||
@@ -1393,7 +1388,7 @@ fn test_bulk_create_generic_string_objects() {
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_after_update_item_not_in_sync_queue_generic_string_object() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let client_id = ClientId::new();
|
||||
@@ -1524,7 +1519,7 @@ fn test_sync_state_after_update_item_not_in_sync_queue_generic_string_object() {
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_after_update_item_in_sync_queue() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -1641,7 +1636,7 @@ fn test_sync_state_after_update_item_in_sync_queue() {
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_after_creation_failure_item_not_in_sync_queue() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -1687,7 +1682,7 @@ fn test_sync_state_after_creation_failure_item_not_in_sync_queue() {
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_after_update_failure_item_in_sync_queue() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -1808,7 +1803,7 @@ fn test_sync_state_after_update_failure_item_in_sync_queue() {
|
||||
|
||||
#[test]
|
||||
fn test_sync_state_after_object_with_dependencies_created() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -1958,7 +1953,7 @@ fn test_sync_state_after_object_with_dependencies_created() {
|
||||
|
||||
#[test]
|
||||
fn test_fetch_single_cloud_object_not_pending_no_overwrite() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let client_id = ClientId::new();
|
||||
@@ -2054,7 +2049,7 @@ fn test_fetch_single_cloud_object_not_pending_no_overwrite() {
|
||||
|
||||
#[test]
|
||||
fn test_fetch_single_cloud_object_pending_no_overwrite() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let server_id: ServerId = 123.into();
|
||||
@@ -2154,7 +2149,7 @@ fn test_fetch_single_cloud_object_pending_no_overwrite() {
|
||||
|
||||
#[test]
|
||||
fn test_fetch_single_cloud_object_pending_with_overwrite() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let server_id: ServerId = 123.into();
|
||||
@@ -2260,7 +2255,7 @@ fn test_fetch_single_cloud_object_pending_with_overwrite() {
|
||||
|
||||
#[test]
|
||||
fn test_metadata_after_trash_item_success() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let server_id: ServerId = 123.into();
|
||||
@@ -2330,7 +2325,7 @@ fn test_metadata_after_trash_item_success() {
|
||||
|
||||
#[test]
|
||||
fn test_pending_metadata_update_with_rtc() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let server_api = mock_server_api();
|
||||
|
||||
@@ -2397,7 +2392,7 @@ fn test_pending_metadata_update_with_rtc() {
|
||||
mocked_metadata_update_message,
|
||||
);
|
||||
|
||||
// Assert we don't have a pending change now that we've updated the metadat
|
||||
// Assert we don't have a pending change now that we've updated the metadata
|
||||
assert_pending_online_only_change_for_object(
|
||||
&mut app,
|
||||
¬ebook_id.to_server_id().uid(),
|
||||
@@ -2437,7 +2432,7 @@ fn test_pending_metadata_update_with_rtc() {
|
||||
|
||||
#[test]
|
||||
fn test_metadata_update_with_rtc_no_pending() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let server_api = mock_server_api();
|
||||
|
||||
@@ -2535,7 +2530,7 @@ fn test_metadata_update_with_rtc_no_pending() {
|
||||
|
||||
#[test]
|
||||
fn test_metadata_after_trash_item_failure() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let server_id: ServerId = 123.into();
|
||||
@@ -2604,7 +2599,7 @@ fn test_metadata_after_trash_item_failure() {
|
||||
|
||||
#[test]
|
||||
fn test_pending_metadata_update_with_polling() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -2673,9 +2668,9 @@ fn test_pending_metadata_update_with_polling() {
|
||||
> = HashMap::new();
|
||||
updated_generic_string_objects.insert(
|
||||
GenericStringObjectFormat::Json(JsonObjectType::Preference),
|
||||
vec![Box::new(ServerPreference {
|
||||
id: SyncId::ServerId(generic_object_server_id),
|
||||
model: CloudPreferenceModel::new(
|
||||
vec![Box::new(ServerPreference::new(
|
||||
SyncId::ServerId(generic_object_server_id),
|
||||
CloudPreferenceModel::new(
|
||||
Preference::new(
|
||||
"test_storage_key".to_string(),
|
||||
"{\"test_key\": \"test_value\"}",
|
||||
@@ -2683,23 +2678,23 @@ fn test_pending_metadata_update_with_polling() {
|
||||
)
|
||||
.expect("error creating preference"),
|
||||
),
|
||||
metadata: mocked_metadata.clone(),
|
||||
permissions: mock_server_permissions(Owner::mock_current_user()),
|
||||
})],
|
||||
mocked_metadata.clone(),
|
||||
mock_server_permissions(Owner::mock_current_user()),
|
||||
))],
|
||||
);
|
||||
|
||||
let mocked_response = InitialLoadResponse {
|
||||
updated_notebooks: vec![ServerNotebook {
|
||||
id: SyncId::ServerId(notebood_server_id),
|
||||
model: CloudNotebookModel {
|
||||
updated_notebooks: vec![ServerNotebook::new(
|
||||
SyncId::ServerId(notebood_server_id),
|
||||
CloudNotebookModel {
|
||||
title: "".into(),
|
||||
data: "".into(),
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
metadata: mocked_metadata.clone(),
|
||||
permissions: mock_server_permissions(Owner::mock_current_user()),
|
||||
}],
|
||||
mocked_metadata.clone(),
|
||||
mock_server_permissions(Owner::mock_current_user()),
|
||||
)],
|
||||
deleted_notebooks: vec![],
|
||||
updated_workflows: vec![],
|
||||
deleted_workflows: vec![],
|
||||
@@ -2757,7 +2752,7 @@ fn test_pending_metadata_update_with_polling() {
|
||||
|
||||
#[test]
|
||||
fn test_metadata_update_with_polling_no_pending() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let server_api = mock_server_api();
|
||||
|
||||
@@ -2806,17 +2801,17 @@ fn test_metadata_update_with_polling_no_pending() {
|
||||
current_editor_uid: Some("ian@warp.dev".to_string()),
|
||||
};
|
||||
let mocked_response = InitialLoadResponse {
|
||||
updated_notebooks: vec![ServerNotebook {
|
||||
id: SyncId::ServerId(server_id),
|
||||
model: CloudNotebookModel {
|
||||
updated_notebooks: vec![ServerNotebook::new(
|
||||
SyncId::ServerId(server_id),
|
||||
CloudNotebookModel {
|
||||
title: "".into(),
|
||||
data: "".into(),
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
metadata: mocked_metadata.clone(),
|
||||
permissions: mock_server_permissions(Owner::mock_current_user()),
|
||||
}],
|
||||
mocked_metadata.clone(),
|
||||
mock_server_permissions(Owner::mock_current_user()),
|
||||
)],
|
||||
deleted_notebooks: vec![],
|
||||
updated_workflows: vec![],
|
||||
deleted_workflows: vec![],
|
||||
@@ -2874,7 +2869,7 @@ fn test_metadata_update_with_polling_no_pending() {
|
||||
|
||||
#[test]
|
||||
fn test_metadata_after_untrash_item_success() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let server_id: ServerId = 123.into();
|
||||
@@ -2944,7 +2939,7 @@ fn test_metadata_after_untrash_item_success() {
|
||||
|
||||
#[test]
|
||||
fn test_metadata_after_untrash_item_and_move_to_root() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let server_id: ServerId = 123.into();
|
||||
@@ -3017,7 +3012,7 @@ fn test_metadata_after_untrash_item_and_move_to_root() {
|
||||
|
||||
#[test]
|
||||
fn test_metadata_after_untrash_item_failure() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let server_id: ServerId = 123.into();
|
||||
@@ -3071,7 +3066,7 @@ fn test_metadata_after_untrash_item_failure() {
|
||||
|
||||
#[test]
|
||||
fn test_metadata_after_optimistic_grab_baton_success() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let server_id: ServerId = 123.into();
|
||||
@@ -3143,7 +3138,7 @@ fn test_metadata_after_optimistic_grab_baton_success() {
|
||||
|
||||
#[test]
|
||||
fn test_metadata_after_optimistic_grab_baton_failure() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let server_id: ServerId = 123.into();
|
||||
@@ -3202,7 +3197,7 @@ fn test_metadata_after_optimistic_grab_baton_failure() {
|
||||
|
||||
#[test]
|
||||
fn test_metadata_after_non_optimistic_grab_baton_success() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let server_id: ServerId = 123.into();
|
||||
@@ -3274,7 +3269,7 @@ fn test_metadata_after_non_optimistic_grab_baton_success() {
|
||||
|
||||
#[test]
|
||||
fn test_metadata_after_non_optimistic_grab_baton_failure() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let server_id: ServerId = 123.into();
|
||||
@@ -3332,7 +3327,7 @@ fn test_metadata_after_non_optimistic_grab_baton_failure() {
|
||||
|
||||
#[test]
|
||||
fn test_report_initial_load() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let server_api = mock_server_api();
|
||||
let update_manager_struct = create_update_manager_struct(&mut app, Arc::new(server_api));
|
||||
@@ -3417,7 +3412,7 @@ fn test_get_duplicate_object_name() {
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_workflow_not_pending_no_overwrite() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let mut server_api = mock_server_api();
|
||||
@@ -3497,7 +3492,7 @@ fn test_duplicate_workflow_not_pending_no_overwrite() {
|
||||
|
||||
#[test]
|
||||
fn test_replace_object_with_conflicts() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let mut server_api = mock_server_api();
|
||||
@@ -3614,7 +3609,7 @@ fn test_replace_object_with_conflicts() {
|
||||
|
||||
#[test]
|
||||
fn test_pending_conflict_correctly_clears_after_edits() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let mut server_api = mock_server_api();
|
||||
@@ -3673,7 +3668,7 @@ fn test_pending_conflict_correctly_clears_after_edits() {
|
||||
ModelEvent::UpsertNotebook { .. }
|
||||
));
|
||||
|
||||
// While we still have edits in flight, recieve an RTC message, that has a TS before the object update
|
||||
// While we still have edits in flight, receive an RTC message, that has a TS before the object update
|
||||
// response. This should put the notebook conflict status as pending
|
||||
let mocked_metadata = ServerMetadata {
|
||||
uid: server_id,
|
||||
@@ -3746,7 +3741,7 @@ fn test_pending_conflict_correctly_clears_after_edits() {
|
||||
|
||||
#[test]
|
||||
fn test_pending_conflict_correctly_stays_after_edits() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let mut server_api = mock_server_api();
|
||||
@@ -3805,7 +3800,7 @@ fn test_pending_conflict_correctly_stays_after_edits() {
|
||||
ModelEvent::UpsertNotebook { .. }
|
||||
));
|
||||
|
||||
// While we still have edits in flight, recieve an RTC message, that has a TS after the object update
|
||||
// While we still have edits in flight, receive an RTC message, that has a TS after the object update
|
||||
// response. This should put the notebook conflict status as pending
|
||||
let mocked_metadata = ServerMetadata {
|
||||
uid: server_id,
|
||||
@@ -3877,7 +3872,7 @@ fn test_pending_self_conflict_clears_out_of_order() {
|
||||
// This tests the case where the client makes an edit and receives the RTC update for it
|
||||
// _before_ the server response to their edit.
|
||||
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let mut server_api = MockObjectClient::new();
|
||||
@@ -3972,7 +3967,7 @@ fn test_pending_self_conflict_clears_out_of_order() {
|
||||
fn test_pending_newer_conflict_remains_out_of_order() {
|
||||
// This tests the case where the client makes an edit which is eventually accepted by the
|
||||
// server, but receives an RTC update about a _newer_ edit in the meantime.
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let mut server_api = MockObjectClient::new();
|
||||
@@ -4067,7 +4062,7 @@ fn test_pending_newer_conflict_remains_out_of_order() {
|
||||
|
||||
#[test]
|
||||
fn test_accepts_new_metadata_with_force_refresh() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let server_api = mock_server_api();
|
||||
let server_id: ServerId = 123.into();
|
||||
@@ -4107,17 +4102,17 @@ fn test_accepts_new_metadata_with_force_refresh() {
|
||||
current_editor_uid: None,
|
||||
};
|
||||
let mocked_response = InitialLoadResponse {
|
||||
updated_notebooks: vec![ServerNotebook {
|
||||
id: SyncId::ServerId(server_id),
|
||||
model: CloudNotebookModel {
|
||||
updated_notebooks: vec![ServerNotebook::new(
|
||||
SyncId::ServerId(server_id),
|
||||
CloudNotebookModel {
|
||||
title: "".into(),
|
||||
data: "".into(),
|
||||
ai_document_id: None,
|
||||
conversation_id: None,
|
||||
},
|
||||
metadata: mocked_metadata.clone(),
|
||||
permissions: mock_server_permissions(Owner::mock_current_user()),
|
||||
}],
|
||||
mocked_metadata.clone(),
|
||||
mock_server_permissions(Owner::mock_current_user()),
|
||||
)],
|
||||
deleted_notebooks: vec![],
|
||||
updated_workflows: vec![],
|
||||
deleted_workflows: vec![],
|
||||
@@ -4160,7 +4155,7 @@ fn test_accepts_new_metadata_with_force_refresh() {
|
||||
|
||||
#[test]
|
||||
fn test_delete_single_object() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -4232,7 +4227,7 @@ fn test_delete_single_object() {
|
||||
|
||||
#[test]
|
||||
fn test_empty_trash() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let ts = Utc::now();
|
||||
@@ -4344,7 +4339,7 @@ fn test_empty_trash() {
|
||||
|
||||
#[test]
|
||||
fn test_leave_shared_object() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -4451,7 +4446,7 @@ fn test_leave_shared_object() {
|
||||
|
||||
#[test]
|
||||
fn test_create_object_online_success() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -4551,7 +4546,7 @@ fn test_create_object_online_success() {
|
||||
|
||||
#[test]
|
||||
fn test_create_object_online_failure() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -4610,7 +4605,7 @@ fn test_create_object_online_failure() {
|
||||
|
||||
#[test]
|
||||
fn test_create_object_online_user_facing_error() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -4673,7 +4668,7 @@ fn test_create_object_online_user_facing_error() {
|
||||
|
||||
#[test]
|
||||
fn test_create_object_online_with_folder_id() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -4756,7 +4751,7 @@ fn test_create_object_online_with_folder_id() {
|
||||
|
||||
#[test]
|
||||
fn test_create_object_online_with_client_folder_id_fails() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -4808,7 +4803,7 @@ fn test_create_object_online_with_client_folder_id_fails() {
|
||||
|
||||
#[test]
|
||||
fn test_record_object_action() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -4895,7 +4890,7 @@ fn test_record_object_action() {
|
||||
|
||||
#[test]
|
||||
fn test_overwrite_object_action_history_no_actions_on_client() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let server_api = mock_server_api();
|
||||
|
||||
@@ -4963,7 +4958,7 @@ fn test_overwrite_object_action_history_no_actions_on_client() {
|
||||
|
||||
#[test]
|
||||
fn test_overwrite_object_action_history_reject() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let server_api = mock_server_api();
|
||||
|
||||
@@ -5117,7 +5112,7 @@ fn test_overwrite_object_action_history_reject() {
|
||||
|
||||
#[test]
|
||||
fn test_overwrite_object_action_history_ignores_pending_local_actions() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let server_api = mock_server_api();
|
||||
|
||||
@@ -5271,7 +5266,7 @@ fn test_overwrite_object_action_history_ignores_pending_local_actions() {
|
||||
|
||||
#[test]
|
||||
fn test_object_action_histories_with_initial_load() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let server_api = mock_server_api();
|
||||
|
||||
@@ -5457,7 +5452,7 @@ fn test_object_action_histories_with_initial_load() {
|
||||
|
||||
#[test]
|
||||
fn test_delete_single_object_with_actions() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -5581,7 +5576,7 @@ fn test_delete_single_object_with_actions() {
|
||||
/// changing the owner of an object, which implicitly also clears its parent folder.
|
||||
#[test]
|
||||
fn test_move_object_personal_to_team_success() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -5681,7 +5676,7 @@ fn test_move_object_personal_to_team_success() {
|
||||
/// optimistically update the model, and then roll those updates back on failure.
|
||||
#[test]
|
||||
fn test_move_object_personal_to_team_failure() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -5787,7 +5782,7 @@ fn test_move_object_personal_to_team_failure() {
|
||||
/// space to a team drive.
|
||||
#[test]
|
||||
fn test_move_cloud_environment_personal_to_team_success() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -5886,7 +5881,7 @@ fn test_move_cloud_environment_personal_to_team_success() {
|
||||
/// and change the reference stored within the workflow to point to that enum.
|
||||
#[test]
|
||||
fn test_move_workflow_with_enums_personal_to_team_success() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let team = Space::Team {
|
||||
@@ -6023,7 +6018,7 @@ fn test_move_workflow_with_enums_personal_to_team_success() {
|
||||
|
||||
#[test]
|
||||
fn test_move_workflow_with_enums_personal_to_team_failure() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
let team = Space::Team {
|
||||
@@ -6174,7 +6169,7 @@ fn test_move_workflow_with_enums_personal_to_team_failure() {
|
||||
/// `Some`.
|
||||
#[test]
|
||||
fn test_move_object_root_to_folder_success() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -6267,7 +6262,7 @@ fn test_move_object_root_to_folder_success() {
|
||||
/// optimistically update the model, and then roll back the updates on failure.
|
||||
#[test]
|
||||
fn test_move_object_root_to_folder_failure() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -6366,7 +6361,7 @@ fn test_move_object_root_to_folder_failure() {
|
||||
/// during, and after a move where the object's parent folder changes from `Some` to `None`.
|
||||
#[test]
|
||||
fn test_move_object_folder_to_root_success() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -6459,7 +6454,7 @@ fn test_move_object_folder_to_root_success() {
|
||||
/// we optimistically clear its parent folder, and then restore it on failure.
|
||||
#[test]
|
||||
fn test_move_object_folder_to_root_failure() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -6558,7 +6553,7 @@ fn test_move_object_folder_to_root_failure() {
|
||||
/// checks that we update the object's metadata and emit events correctly.
|
||||
#[test]
|
||||
fn test_move_object_folder_to_folder_success() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -6653,7 +6648,7 @@ fn test_move_object_folder_to_folder_success() {
|
||||
/// we optimistically apply the move in-memory and then undo it on failure.
|
||||
#[test]
|
||||
fn test_move_object_folder_to_folder_failure() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -6754,7 +6749,7 @@ fn test_move_object_folder_to_folder_failure() {
|
||||
/// object was trashed by another client.
|
||||
#[test]
|
||||
fn test_trash_object_over_rtc() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let server_api = mock_server_api();
|
||||
let workflow_id: WorkflowId = 123.into();
|
||||
@@ -6831,7 +6826,7 @@ fn test_trash_object_over_rtc() {
|
||||
/// object was un-trashed by another client.
|
||||
#[test]
|
||||
fn test_untrash_object_over_rtc() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let server_api = mock_server_api();
|
||||
let workflow_id: WorkflowId = 123.into();
|
||||
@@ -6908,7 +6903,7 @@ fn test_untrash_object_over_rtc() {
|
||||
/// object was moved from one folder to another in the same space by another client.
|
||||
#[test]
|
||||
fn test_move_object_from_folder_to_folder_over_rtc() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let server_api = mock_server_api();
|
||||
let workflow_id: WorkflowId = 123.into();
|
||||
@@ -6989,7 +6984,7 @@ fn test_move_object_from_folder_to_folder_over_rtc() {
|
||||
/// object was moved from a folder to the root of its space by another client.
|
||||
#[test]
|
||||
fn test_move_object_from_folder_to_root_over_rtc() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let server_api = mock_server_api();
|
||||
let workflow_id: WorkflowId = 123.into();
|
||||
@@ -7069,7 +7064,7 @@ fn test_move_object_from_folder_to_root_over_rtc() {
|
||||
/// an object was moved from the root of its space into a folder by another client.
|
||||
#[test]
|
||||
fn test_move_object_from_root_to_folder_over_rtc() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let server_api = mock_server_api();
|
||||
let workflow_id: WorkflowId = 123.into();
|
||||
@@ -7147,7 +7142,7 @@ fn test_move_object_from_root_to_folder_over_rtc() {
|
||||
|
||||
#[test]
|
||||
fn test_permissions_update_grants_access() {
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let mut server_api = mock_server_api();
|
||||
@@ -7261,7 +7256,7 @@ fn test_permissions_update_grants_access() {
|
||||
fn test_permissions_update_existing_object() {
|
||||
let _guard = FeatureFlag::SharedWithMe.override_enabled(true);
|
||||
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
|
||||
let server_api = mock_server_api();
|
||||
@@ -7322,7 +7317,7 @@ fn test_permissions_update_existing_object() {
|
||||
#[test]
|
||||
fn test_add_guest_success() {
|
||||
let _guard = FeatureFlag::SharedWithMe.override_enabled(true);
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -7436,7 +7431,7 @@ fn test_add_guest_success() {
|
||||
#[test]
|
||||
fn test_add_guest_failure() {
|
||||
let _guard = FeatureFlag::SharedWithMe.override_enabled(true);
|
||||
App::test(Assets, |mut app| async move {
|
||||
App::test(ASSETS, |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let mut server_api = mock_server_api();
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
use chrono::{DateTime, FixedOffset, Local};
|
||||
|
||||
pub trait DateTimeExt {
|
||||
fn now() -> DateTime<FixedOffset>;
|
||||
}
|
||||
|
||||
impl DateTimeExt for DateTime<FixedOffset> {
|
||||
/// Gets current date and time and timezone in DateTime<FixedOffset>.
|
||||
fn now() -> DateTime<FixedOffset> {
|
||||
let local_time = Local::now();
|
||||
local_time.with_timezone(local_time.offset())
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,6 @@ impl Display for ServerExperiment {
|
||||
Self::EnvVarsEarlyAccessExperiment => "ENV_VARS_EARLY_ACCESS_EXPERIMENT",
|
||||
Self::AgentModeAnalyticsExperiment => "AGENT_MODE_ANALYTICS_EXPERIMENT",
|
||||
Self::WindowsLaunchExperiment => "WINDOWS_LAUNCH_EXPERIMENT",
|
||||
Self::TmuxSshWarpificationControl => "TMUX_SSH_WARPIFICATION_CONTROL",
|
||||
Self::TmuxSshWarpificationExperiment => "TMUX_SSH_WARPIFICATION_EXPERIMENT",
|
||||
Self::CodebaseContextControl => "CODEBASE_CONTEXT_CONTROL",
|
||||
Self::CodebaseContextExperiment => "CODEBASE_CONTEXT_EXPERIMENT",
|
||||
Self::SuggestedCodeDiffsControl => "SUGGESTED_CODE_DIFFS_CONTROL",
|
||||
@@ -32,8 +30,6 @@ impl Display for ServerExperiment {
|
||||
Self::PromptSuggestionsViaMaaOutOfBandExperiment => {
|
||||
"PROMPT_SUGGESTIONS_VIA_MAA_OOB_EXPERIMENT"
|
||||
}
|
||||
Self::FreeUserNoAiControl => "FREE_USER_NO_AI_CONTROL",
|
||||
Self::FreeUserNoAiExperiment => "FREE_USER_NO_AI_EXPERIMENT",
|
||||
Self::OzMultiHarnessControl => "OZ_MULTI_HARNESS_CONTROL",
|
||||
Self::OzMultiHarnessExperiment => "OZ_MULTI_HARNESS_EXPERIMENT",
|
||||
#[cfg(test)]
|
||||
@@ -52,8 +48,6 @@ impl ServerExperiment {
|
||||
"ENV_VARS_EARLY_ACCESS_EXPERIMENT" => Ok(Self::EnvVarsEarlyAccessExperiment),
|
||||
"AGENT_MODE_ANALYTICS_EXPERIMENT" => Ok(Self::AgentModeAnalyticsExperiment),
|
||||
"WINDOWS_LAUNCH_EXPERIMENT" => Ok(Self::WindowsLaunchExperiment),
|
||||
"TMUX_SSH_WARPIFICATION_CONTROL" => Ok(Self::TmuxSshWarpificationControl),
|
||||
"TMUX_SSH_WARPIFICATION_EXPERIMENT" => Ok(Self::TmuxSshWarpificationExperiment),
|
||||
"CODEBASE_CONTEXT_EXPERIMENT" => Ok(Self::CodebaseContextExperiment),
|
||||
"CODEBASE_CONTEXT_CONTROL" => Ok(Self::CodebaseContextControl),
|
||||
"SUGGESTED_CODE_DIFFS_CONTROL" => Ok(Self::SuggestedCodeDiffsControl),
|
||||
@@ -65,8 +59,6 @@ impl ServerExperiment {
|
||||
}
|
||||
"PROMPT_SUGGESTIONS_VIA_MAA_CONTROL" => Ok(Self::PromptSuggestionsViaMaaControl),
|
||||
"PROMPT_SUGGESTIONS_VIA_MAA_EXPERIMENT" => Ok(Self::PromptSuggestionsViaMaaExperiment),
|
||||
"FREE_USER_NO_AI_CONTROL" => Ok(Self::FreeUserNoAiControl),
|
||||
"FREE_USER_NO_AI_EXPERIMENT" => Ok(Self::FreeUserNoAiExperiment),
|
||||
"OZ_MULTI_HARNESS_CONTROL" => Ok(Self::OzMultiHarnessControl),
|
||||
"OZ_MULTI_HARNESS_EXPERIMENT" => Ok(Self::OzMultiHarnessExperiment),
|
||||
s => Err(anyhow::anyhow!(
|
||||
@@ -93,8 +85,6 @@ impl TryFrom<Experiment> for ServerExperiment {
|
||||
Experiment::DisableAgentModeExperiment => Ok(Self::DisableAgentModeExperiment),
|
||||
Experiment::EnvVarsEarlyAccessExperiment => Ok(Self::EnvVarsEarlyAccessExperiment),
|
||||
Experiment::AgentModeAnalyticsExperiment => Ok(Self::AgentModeAnalyticsExperiment),
|
||||
Experiment::TmuxSshWarpificationControl => Ok(Self::TmuxSshWarpificationControl),
|
||||
Experiment::TmuxSshWarpificationExperiment => Ok(Self::TmuxSshWarpificationExperiment),
|
||||
Experiment::WindowsLaunchExperiment => Ok(Self::WindowsLaunchExperiment),
|
||||
Experiment::CodebaseContextControl => Ok(Self::CodebaseContextControl),
|
||||
Experiment::CodebaseContextExperiment => Ok(Self::CodebaseContextExperiment),
|
||||
@@ -104,8 +94,6 @@ impl TryFrom<Experiment> for ServerExperiment {
|
||||
Experiment::PromptSuggestionsViaMaaOob => {
|
||||
Ok(Self::PromptSuggestionsViaMaaOutOfBandExperiment)
|
||||
}
|
||||
Experiment::FreeUserNoAiControl => Ok(Self::FreeUserNoAiControl),
|
||||
Experiment::FreeUserNoAiExperiment => Ok(Self::FreeUserNoAiExperiment),
|
||||
Experiment::OzMultiHarnessControl => Ok(Self::OzMultiHarnessControl),
|
||||
Experiment::OzMultiHarnessExperiment => Ok(Self::OzMultiHarnessExperiment),
|
||||
// Experiments that we no longer support on the client.
|
||||
|
||||
@@ -11,21 +11,19 @@
|
||||
//! See [here](https://www.notion.so/warpdev/Server-side-experiments-dynamic-feature-enablement-c0fb9aed695d4178a19b8830e3269094)
|
||||
//! for a full guide on the server-side experiment framework.
|
||||
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::workspaces::workspace::CustomerType;
|
||||
use galaxyui::AppContext;
|
||||
#[cfg(not(test))]
|
||||
use galaxyui::SingletonEntity as _;
|
||||
#[cfg(test)]
|
||||
use galaxyui::SingletonEntity;
|
||||
|
||||
use crate::features::FeatureFlag;
|
||||
|
||||
mod convert;
|
||||
mod model;
|
||||
|
||||
pub use model::{Event as ServerExperimentsEvent, ServerExperiments};
|
||||
|
||||
/// The known server-side experiments.
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
|
||||
pub enum ServerExperiment {
|
||||
SessionSharingExperiment,
|
||||
@@ -34,8 +32,6 @@ pub enum ServerExperiment {
|
||||
EnvVarsEarlyAccessExperiment,
|
||||
AgentModeAnalyticsExperiment,
|
||||
WindowsLaunchExperiment,
|
||||
TmuxSshWarpificationControl,
|
||||
TmuxSshWarpificationExperiment,
|
||||
CodebaseContextExperiment,
|
||||
CodebaseContextControl,
|
||||
SuggestedCodeDiffsControl,
|
||||
@@ -46,8 +42,6 @@ pub enum ServerExperiment {
|
||||
PromptSuggestionsViaMaaControl,
|
||||
PromptSuggestionsViaMaaExperiment,
|
||||
PromptSuggestionsViaMaaOutOfBandExperiment,
|
||||
FreeUserNoAiControl,
|
||||
FreeUserNoAiExperiment,
|
||||
OzMultiHarnessControl,
|
||||
OzMultiHarnessExperiment,
|
||||
/// A test-only experiment.
|
||||
@@ -92,14 +86,6 @@ impl ServerExperiment {
|
||||
// TODO(alokedesai): Clean this up now that we no longer gate access to the Windows
|
||||
// build on an allowlist.
|
||||
}
|
||||
Self::TmuxSshWarpificationControl => FeatureFlag::SSHTmuxWrapper.set_enabled(false),
|
||||
Self::TmuxSshWarpificationExperiment => {
|
||||
// Only enable the TMUX-based experience if not on windows. ConPTY doesn't support
|
||||
// DCS, which we need in order to use tmux control mode.
|
||||
if cfg!(not(windows)) {
|
||||
FeatureFlag::SSHTmuxWrapper.set_enabled(true)
|
||||
}
|
||||
}
|
||||
Self::CodebaseContextExperiment => {
|
||||
FeatureFlag::FullSourceCodeEmbedding.set_enabled(true);
|
||||
FeatureFlag::CodebaseIndexPersistence.set_enabled(true);
|
||||
@@ -137,12 +123,6 @@ impl ServerExperiment {
|
||||
}
|
||||
// The normal experiment arm is no longer used.
|
||||
Self::PromptSuggestionsViaMaaExperiment => {}
|
||||
Self::FreeUserNoAiControl => {
|
||||
FeatureFlag::FreeUserNoAi.set_enabled(false);
|
||||
}
|
||||
Self::FreeUserNoAiExperiment => {
|
||||
FeatureFlag::FreeUserNoAi.set_enabled(true);
|
||||
}
|
||||
Self::OzMultiHarnessControl => {
|
||||
FeatureFlag::AgentHarness.set_enabled(false);
|
||||
}
|
||||
@@ -158,16 +138,3 @@ impl ServerExperiment {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when the user is in the `FreeUserNoAiExperiment` arm **and** is on the
|
||||
/// free tier. This is the single source of truth for gating any client-side behaviour
|
||||
/// that should be locked/disabled for users without AI credits.
|
||||
pub fn is_free_user_no_ai_experiment_active(ctx: &AppContext) -> bool {
|
||||
let in_experiment = FeatureFlag::FreeUserNoAi.is_enabled();
|
||||
let is_free_tier = UserWorkspaces::handle(ctx)
|
||||
.as_ref(ctx)
|
||||
.current_team()
|
||||
.map(|team| team.billing_metadata.customer_type == CustomerType::Free)
|
||||
.unwrap_or(true); // no team = solo free user
|
||||
in_experiment && is_free_tier
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::ServerExperiment;
|
||||
use crate::{persistence::ModelEvent, report_if_error, GlobalResourceHandlesProvider};
|
||||
use anyhow::Context;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
#[cfg(test)]
|
||||
pub use tests::TestModel;
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use super::ServerExperiment;
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::{report_if_error, GlobalResourceHandlesProvider};
|
||||
|
||||
/// A global model for maintaining server-side experiment state.
|
||||
pub struct ServerExperiments {
|
||||
@@ -29,7 +30,7 @@ impl ServerExperiments {
|
||||
|
||||
/// Updates the model with the latest server-side state.
|
||||
///
|
||||
/// Assumes the set of proivded [`ServerExperiment`]s are unambiguous;
|
||||
/// Assumes the set of provided [`ServerExperiment`]s are unambiguous;
|
||||
/// that is, there are not two arms enabled for the same experiment group.
|
||||
pub fn apply_latest_state(
|
||||
&mut self,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use warpui::{App, Entity, SingletonEntity};
|
||||
|
||||
use super::{ServerExperiment, ServerExperiments};
|
||||
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
|
||||
use galaxyui::{App, Entity, SingletonEntity};
|
||||
|
||||
/// A model for testing purposes only.
|
||||
///
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
pub mod schema;
|
||||
|
||||
use galaxy_graphql::client::RequestOptions;
|
||||
pub use galaxy_graphql::client::{
|
||||
get_request_context, get_user_facing_error_message, GraphQLError,
|
||||
};
|
||||
|
||||
/// Returns the default [`RequestOptions`] that should be used for a GraphQL request.
|
||||
pub fn default_request_options() -> RequestOptions {
|
||||
RequestOptions {
|
||||
#[cfg(feature = "agent_mode_evals")]
|
||||
path_prefix: Some("/agent-mode-evals".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
pub use galaxy_graphql::client::{get_request_context, get_user_facing_error_message, GraphQLError};
|
||||
|
||||
@@ -1,322 +1,120 @@
|
||||
pub mod util;
|
||||
|
||||
use crate::{
|
||||
ai::cloud_environments::CloudAmbientAgentEnvironmentModel,
|
||||
ai::{
|
||||
ambient_agents::scheduled::CloudScheduledAmbientAgentModel,
|
||||
execution_profiles::CloudAIExecutionProfileModel,
|
||||
facts::CloudAIFactModel,
|
||||
mcp::{templatable::CloudTemplatableMCPServerModel, CloudMCPServerModel},
|
||||
},
|
||||
cloud_object::{
|
||||
model::generic_string_model::GenericStringObjectId, GenericServerObject,
|
||||
RevisionAndLastEditor, ServerFolder, ServerObject, UpdateCloudObjectResult,
|
||||
},
|
||||
env_vars::CloudEnvVarCollectionModel,
|
||||
server::{graphql::get_user_facing_error_message, ids::ServerId},
|
||||
settings::cloud_preferences::CloudPreferenceModel,
|
||||
workflows::workflow_enum::CloudWorkflowEnumModel,
|
||||
};
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use galaxy_graphql::{
|
||||
generic_string_object::GenericStringObjectFormat,
|
||||
mutations::update_generic_string_object::{
|
||||
GenericStringObjectUpdate, UpdateGenericStringObjectResult,
|
||||
},
|
||||
object::ObjectUpdateSuccess,
|
||||
pub use util::{action_type_to_gql_action_type, object_action_history_from_gql};
|
||||
use galaxy_graphql::generic_string_object::GenericStringObjectFormat;
|
||||
use galaxy_graphql::mutations::update_generic_string_object::{
|
||||
GenericStringObjectUpdate, UpdateGenericStringObjectResult,
|
||||
};
|
||||
use warp_graphql::object::ObjectUpdateSuccess;
|
||||
|
||||
impl TryFrom<UpdateGenericStringObjectResult> for UpdateCloudObjectResult<Box<dyn ServerObject>> {
|
||||
type Error = anyhow::Error;
|
||||
use crate::cloud_object::{
|
||||
RevisionAndLastEditor, ServerAIExecutionProfile, ServerAIFact, ServerAmbientAgentEnvironment,
|
||||
ServerEnvVarCollection, ServerFolder, ServerMCPServer, ServerObject, ServerPreference,
|
||||
ServerScheduledAmbientAgent, ServerTemplatableMCPServer, ServerWorkflowEnum, TryFromGql,
|
||||
UpdateCloudObjectResult,
|
||||
};
|
||||
use crate::server::graphql::get_user_facing_error_message;
|
||||
|
||||
fn try_from(value: UpdateGenericStringObjectResult) -> std::result::Result<Self, Self::Error> {
|
||||
match value {
|
||||
UpdateGenericStringObjectResult::UpdateGenericStringObjectOutput(output) => {
|
||||
match output.update {
|
||||
GenericStringObjectUpdate::ObjectUpdateSuccess(success) => {
|
||||
Ok(UpdateCloudObjectResult::Success {
|
||||
revision_and_editor: RevisionAndLastEditor {
|
||||
revision: success.revision_ts.into(),
|
||||
last_editor_uid: Some(success.last_editor_uid.into_inner()),
|
||||
},
|
||||
})
|
||||
}
|
||||
GenericStringObjectUpdate::GenericStringObjectUpdateRejected(rejected) => {
|
||||
let boxed: Box<dyn ServerObject> = match rejected
|
||||
.conflicting_generic_string_object
|
||||
.format
|
||||
{
|
||||
GenericStringObjectFormat::JsonEnvVarCollection => {
|
||||
let gso = GenericServerObject::<
|
||||
GenericStringObjectId,
|
||||
CloudEnvVarCollectionModel,
|
||||
>::try_from_graphql_fields(
|
||||
ServerId::from_string_lossy(
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.uid
|
||||
.inner(),
|
||||
),
|
||||
Some(
|
||||
rejected.conflicting_generic_string_object.serialized_model,
|
||||
),
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.try_into()?,
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.permissions
|
||||
.try_into()?,
|
||||
)?;
|
||||
let boxed: Box<dyn ServerObject> = Box::new(gso);
|
||||
boxed
|
||||
}
|
||||
GenericStringObjectFormat::JsonPreference => {
|
||||
let gso = GenericServerObject::<
|
||||
GenericStringObjectId,
|
||||
CloudPreferenceModel,
|
||||
>::try_from_graphql_fields(
|
||||
ServerId::from_string_lossy(
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.uid
|
||||
.inner(),
|
||||
),
|
||||
Some(
|
||||
rejected.conflicting_generic_string_object.serialized_model,
|
||||
),
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.try_into()?,
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.permissions
|
||||
.try_into()?,
|
||||
)?;
|
||||
let boxed: Box<dyn ServerObject> = Box::new(gso);
|
||||
boxed
|
||||
}
|
||||
GenericStringObjectFormat::JsonWorkflowEnum => {
|
||||
let gso = GenericServerObject::<
|
||||
GenericStringObjectId,
|
||||
CloudWorkflowEnumModel,
|
||||
>::try_from_graphql_fields(
|
||||
ServerId::from_string_lossy(
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.uid
|
||||
.inner(),
|
||||
),
|
||||
Some(
|
||||
rejected.conflicting_generic_string_object.serialized_model,
|
||||
),
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.try_into()?,
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.permissions
|
||||
.try_into()?,
|
||||
)?;
|
||||
let boxed: Box<dyn ServerObject> = Box::new(gso);
|
||||
boxed
|
||||
}
|
||||
GenericStringObjectFormat::JsonAIFact => {
|
||||
let gso = GenericServerObject::<
|
||||
GenericStringObjectId,
|
||||
CloudAIFactModel,
|
||||
>::try_from_graphql_fields(
|
||||
ServerId::from_string_lossy(
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.uid
|
||||
.inner(),
|
||||
),
|
||||
Some(
|
||||
rejected.conflicting_generic_string_object.serialized_model,
|
||||
),
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.try_into()?,
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.permissions
|
||||
.try_into()?,
|
||||
)?;
|
||||
let boxed: Box<dyn ServerObject> = Box::new(gso);
|
||||
boxed
|
||||
}
|
||||
GenericStringObjectFormat::JsonAIExecutionProfile => {
|
||||
let gso = GenericServerObject::<
|
||||
GenericStringObjectId,
|
||||
CloudAIExecutionProfileModel,
|
||||
>::try_from_graphql_fields(
|
||||
ServerId::from_string_lossy(
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.uid
|
||||
.inner(),
|
||||
),
|
||||
Some(
|
||||
rejected.conflicting_generic_string_object.serialized_model,
|
||||
),
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.try_into()?,
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.permissions
|
||||
.try_into()?,
|
||||
)?;
|
||||
let boxed: Box<dyn ServerObject> = Box::new(gso);
|
||||
boxed
|
||||
}
|
||||
GenericStringObjectFormat::JsonMCPServer => {
|
||||
let gso = GenericServerObject::<
|
||||
GenericStringObjectId,
|
||||
CloudMCPServerModel,
|
||||
>::try_from_graphql_fields(
|
||||
ServerId::from_string_lossy(
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.uid
|
||||
.inner(),
|
||||
),
|
||||
Some(
|
||||
rejected.conflicting_generic_string_object.serialized_model,
|
||||
),
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.try_into()?,
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.permissions
|
||||
.try_into()?,
|
||||
)?;
|
||||
let boxed: Box<dyn ServerObject> = Box::new(gso);
|
||||
boxed
|
||||
}
|
||||
GenericStringObjectFormat::JsonTemplatableMCPServer => {
|
||||
let gso = GenericServerObject::<
|
||||
GenericStringObjectId,
|
||||
CloudTemplatableMCPServerModel,
|
||||
>::try_from_graphql_fields(
|
||||
ServerId::from_string_lossy(
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.uid
|
||||
.inner(),
|
||||
),
|
||||
Some(
|
||||
rejected.conflicting_generic_string_object.serialized_model,
|
||||
),
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.try_into()?,
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.permissions
|
||||
.try_into()?,
|
||||
)?;
|
||||
let boxed: Box<dyn ServerObject> = Box::new(gso);
|
||||
boxed
|
||||
}
|
||||
GenericStringObjectFormat::JsonCloudEnvironment => {
|
||||
let gso = GenericServerObject::<
|
||||
GenericStringObjectId,
|
||||
CloudAmbientAgentEnvironmentModel,
|
||||
>::try_from_graphql_fields(
|
||||
ServerId::from_string_lossy(
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.uid
|
||||
.inner(),
|
||||
),
|
||||
Some(
|
||||
rejected.conflicting_generic_string_object.serialized_model,
|
||||
),
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.try_into()?,
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.permissions
|
||||
.try_into()?,
|
||||
)?;
|
||||
let boxed: Box<dyn ServerObject> = Box::new(gso);
|
||||
boxed
|
||||
}
|
||||
GenericStringObjectFormat::JsonScheduledAmbientAgent => {
|
||||
let gso = GenericServerObject::<
|
||||
GenericStringObjectId,
|
||||
CloudScheduledAmbientAgentModel,
|
||||
>::try_from_graphql_fields(
|
||||
ServerId::from_string_lossy(
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.uid
|
||||
.inner(),
|
||||
),
|
||||
Some(
|
||||
rejected.conflicting_generic_string_object.serialized_model,
|
||||
),
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.metadata
|
||||
.try_into()?,
|
||||
rejected
|
||||
.conflicting_generic_string_object
|
||||
.permissions
|
||||
.try_into()?,
|
||||
)?;
|
||||
let boxed: Box<dyn ServerObject> = Box::new(gso);
|
||||
boxed
|
||||
}
|
||||
};
|
||||
Ok(UpdateCloudObjectResult::Rejected { object: boxed })
|
||||
}
|
||||
GenericStringObjectUpdate::Unknown => {
|
||||
bail!("update generic string object response has unknown variant")
|
||||
}
|
||||
fn boxed_rejected_generic_string_object<T>(
|
||||
object: warp_graphql::generic_string_object::GenericStringObject,
|
||||
) -> Result<Box<dyn ServerObject>>
|
||||
where
|
||||
T: TryFromGql<GqlType = warp_graphql::generic_string_object::GenericStringObject>
|
||||
+ ServerObject
|
||||
+ 'static,
|
||||
{
|
||||
Ok(Box::new(T::try_from_gql(object)?))
|
||||
}
|
||||
|
||||
pub fn update_generic_string_object_result_to_update_result(
|
||||
value: UpdateGenericStringObjectResult,
|
||||
) -> Result<UpdateCloudObjectResult<Box<dyn ServerObject>>> {
|
||||
match value {
|
||||
UpdateGenericStringObjectResult::UpdateGenericStringObjectOutput(output) => {
|
||||
match output.update {
|
||||
GenericStringObjectUpdate::ObjectUpdateSuccess(success) => {
|
||||
Ok(UpdateCloudObjectResult::Success {
|
||||
revision_and_editor: RevisionAndLastEditor {
|
||||
revision: success.revision_ts.into(),
|
||||
last_editor_uid: Some(success.last_editor_uid.into_inner()),
|
||||
},
|
||||
})
|
||||
}
|
||||
GenericStringObjectUpdate::GenericStringObjectUpdateRejected(rejected) => {
|
||||
let format = rejected.conflicting_generic_string_object.format;
|
||||
let boxed: Box<dyn ServerObject> = match format {
|
||||
GenericStringObjectFormat::JsonEnvVarCollection => {
|
||||
boxed_rejected_generic_string_object::<ServerEnvVarCollection>(
|
||||
rejected.conflicting_generic_string_object,
|
||||
)?
|
||||
}
|
||||
GenericStringObjectFormat::JsonPreference => {
|
||||
boxed_rejected_generic_string_object::<ServerPreference>(
|
||||
rejected.conflicting_generic_string_object,
|
||||
)?
|
||||
}
|
||||
GenericStringObjectFormat::JsonWorkflowEnum => {
|
||||
boxed_rejected_generic_string_object::<ServerWorkflowEnum>(
|
||||
rejected.conflicting_generic_string_object,
|
||||
)?
|
||||
}
|
||||
GenericStringObjectFormat::JsonAIFact => {
|
||||
boxed_rejected_generic_string_object::<ServerAIFact>(
|
||||
rejected.conflicting_generic_string_object,
|
||||
)?
|
||||
}
|
||||
GenericStringObjectFormat::JsonAIExecutionProfile => {
|
||||
boxed_rejected_generic_string_object::<ServerAIExecutionProfile>(
|
||||
rejected.conflicting_generic_string_object,
|
||||
)?
|
||||
}
|
||||
GenericStringObjectFormat::JsonMCPServer => {
|
||||
boxed_rejected_generic_string_object::<ServerMCPServer>(
|
||||
rejected.conflicting_generic_string_object,
|
||||
)?
|
||||
}
|
||||
GenericStringObjectFormat::JsonTemplatableMCPServer => {
|
||||
boxed_rejected_generic_string_object::<ServerTemplatableMCPServer>(
|
||||
rejected.conflicting_generic_string_object,
|
||||
)?
|
||||
}
|
||||
GenericStringObjectFormat::JsonCloudEnvironment => {
|
||||
boxed_rejected_generic_string_object::<ServerAmbientAgentEnvironment>(
|
||||
rejected.conflicting_generic_string_object,
|
||||
)?
|
||||
}
|
||||
GenericStringObjectFormat::JsonScheduledAmbientAgent => {
|
||||
boxed_rejected_generic_string_object::<ServerScheduledAmbientAgent>(
|
||||
rejected.conflicting_generic_string_object,
|
||||
)?
|
||||
}
|
||||
GenericStringObjectFormat::Unknown => {
|
||||
bail!("conflicting generic string object has unknown format")
|
||||
}
|
||||
};
|
||||
Ok(UpdateCloudObjectResult::Rejected { object: boxed })
|
||||
}
|
||||
GenericStringObjectUpdate::Unknown => {
|
||||
bail!("update generic string object response has unknown variant")
|
||||
}
|
||||
}
|
||||
UpdateGenericStringObjectResult::UserFacingError(e) => {
|
||||
bail!(get_user_facing_error_message(e))
|
||||
}
|
||||
UpdateGenericStringObjectResult::Unknown => {
|
||||
bail!("update generic string object response has unknown variant")
|
||||
}
|
||||
}
|
||||
UpdateGenericStringObjectResult::UserFacingError(e) => {
|
||||
bail!(get_user_facing_error_message(e))
|
||||
}
|
||||
UpdateGenericStringObjectResult::Unknown => {
|
||||
bail!("update generic string object response has unknown variant")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ObjectUpdateSuccess> for UpdateCloudObjectResult<ServerFolder> {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: ObjectUpdateSuccess) -> Result<Self, Self::Error> {
|
||||
Ok(UpdateCloudObjectResult::Success {
|
||||
revision_and_editor: RevisionAndLastEditor {
|
||||
revision: value.revision_ts.into(),
|
||||
last_editor_uid: Some(value.last_editor_uid.into_inner()),
|
||||
},
|
||||
})
|
||||
}
|
||||
pub fn object_update_success_to_update_result(
|
||||
value: ObjectUpdateSuccess,
|
||||
) -> Result<UpdateCloudObjectResult<ServerFolder>> {
|
||||
Ok(UpdateCloudObjectResult::Success {
|
||||
revision_and_editor: RevisionAndLastEditor {
|
||||
revision: value.revision_ts.into(),
|
||||
last_editor_uid: Some(value.last_editor_uid.into_inner()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,37 +1,13 @@
|
||||
use crate::anyhow;
|
||||
|
||||
use crate::cloud_object::model::actions::ObjectActionHistory;
|
||||
use crate::cloud_object::model::actions::ObjectActionType;
|
||||
use crate::cloud_object::model::actions::{ObjectAction, ObjectActionSubtype};
|
||||
use crate::cloud_object::{GenericStringObjectUniqueKey, UniquePer};
|
||||
use crate::cloud_object::model::actions::{
|
||||
ObjectAction, ObjectActionHistory, ObjectActionSubtype, ObjectActionType,
|
||||
};
|
||||
use crate::server::ids::{HashedSqliteId, ObjectUid, ServerId, SyncId};
|
||||
|
||||
impl From<GenericStringObjectUniqueKey>
|
||||
for galaxy_graphql::generic_string_object::GenericStringObjectUniqueKey
|
||||
{
|
||||
fn from(key: GenericStringObjectUniqueKey) -> Self {
|
||||
use galaxy_graphql::generic_string_object::GenericStringObjectUniqueKey as GraphQLFormat;
|
||||
GraphQLFormat {
|
||||
key: key.key,
|
||||
unique_per: key.unique_per.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UniquePer> for galaxy_graphql::generic_string_object::UniquePer {
|
||||
fn from(unique_per: UniquePer) -> Self {
|
||||
use galaxy_graphql::generic_string_object::UniquePer as GraphQLUniquePer;
|
||||
match unique_per {
|
||||
UniquePer::User => GraphQLUniquePer::User,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ObjectActionType> for galaxy_graphql::object_actions::ActionType {
|
||||
fn from(action: ObjectActionType) -> Self {
|
||||
match action {
|
||||
ObjectActionType::Execute => galaxy_graphql::object_actions::ActionType::Executed,
|
||||
}
|
||||
pub fn action_type_to_gql_action_type(
|
||||
action: ObjectActionType,
|
||||
) -> galaxy_graphql::object_actions::ActionType {
|
||||
match action {
|
||||
ObjectActionType::Execute => galaxy_graphql::object_actions::ActionType::Executed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,35 +57,34 @@ fn try_into_object_action(
|
||||
|
||||
/// Converts the graphql action history type into an ObjectActionHistory, requires converting
|
||||
/// the individual actions, action types, and action subtypes.
|
||||
impl TryInto<ObjectActionHistory> for galaxy_graphql::object_actions::ObjectActionHistory {
|
||||
type Error = anyhow::Error;
|
||||
fn try_into(self) -> Result<ObjectActionHistory, Self::Error> {
|
||||
let uid: ObjectUid = self.uid.into_inner();
|
||||
let sync_id = SyncId::ServerId(ServerId::from_string_lossy(&uid));
|
||||
let hashed_sqlite_id = sync_id.sqlite_uid_hash(self.object_type.try_into()?);
|
||||
pub fn object_action_history_from_gql(
|
||||
history: galaxy_graphql::object_actions::ObjectActionHistory,
|
||||
) -> Result<ObjectActionHistory, anyhow::Error> {
|
||||
let uid: ObjectUid = history.uid.into_inner();
|
||||
let sync_id = SyncId::ServerId(ServerId::from_string_lossy(&uid));
|
||||
let hashed_sqlite_id = sync_id.sqlite_uid_hash(history.object_type.try_into()?);
|
||||
|
||||
let actions = self
|
||||
.actions
|
||||
.map(|actions| {
|
||||
actions
|
||||
.iter()
|
||||
.filter_map(|action| {
|
||||
try_into_object_action(action, uid.clone(), hashed_sqlite_id.clone()).ok()
|
||||
})
|
||||
.collect::<Vec<ObjectAction>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(ObjectActionHistory {
|
||||
uid,
|
||||
hashed_sqlite_id,
|
||||
latest_processed_at_timestamp: self
|
||||
.latest_processed_at_timestamp
|
||||
.ok_or(anyhow!(
|
||||
"Parsing error: latest processed at timestamp did not exist."
|
||||
))?
|
||||
.utc(),
|
||||
actions,
|
||||
let actions = history
|
||||
.actions
|
||||
.map(|actions| {
|
||||
actions
|
||||
.iter()
|
||||
.filter_map(|action| {
|
||||
try_into_object_action(action, uid.clone(), hashed_sqlite_id.clone()).ok()
|
||||
})
|
||||
.collect::<Vec<ObjectAction>>()
|
||||
})
|
||||
}
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(ObjectActionHistory {
|
||||
uid,
|
||||
hashed_sqlite_id,
|
||||
latest_processed_at_timestamp: history
|
||||
.latest_processed_at_timestamp
|
||||
.ok_or(anyhow!(
|
||||
"Parsing error: latest processed at timestamp did not exist."
|
||||
))?
|
||||
.utc(),
|
||||
actions,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Re-export types from galaxy_server_client.
|
||||
pub use galaxy_server_client::ids::{
|
||||
// Re-export types from cloud_objects.
|
||||
#[allow(unused_imports)]
|
||||
pub use cloud_objects::ids::GenericStringObjectId;
|
||||
pub use cloud_objects::ids::{
|
||||
parse_sqlite_id_to_uid, ApiKeyUid, ClientId, HashableId, HashedSqliteId, ObjectUid, ServerId,
|
||||
ServerIdAndType, SyncId, ToServerId,
|
||||
};
|
||||
@@ -9,7 +11,7 @@ pub use galaxy_server_client::ids::{
|
||||
/// Takes type and desired prefix for HashableId.
|
||||
///
|
||||
/// Note: This macro uses `$crate::server::ids::*` paths, so it only works within the warp crate.
|
||||
/// For types defined in galaxy_server_client, use `galaxy_server_client::server_id_traits!` instead.
|
||||
/// For types defined in cloud_objects, use `cloud_objects::server_id_traits!` instead.
|
||||
#[macro_export]
|
||||
macro_rules! server_id_traits {
|
||||
($t:ty, $prefix:literal) => {
|
||||
@@ -70,5 +72,5 @@ macro_rules! server_id_traits {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ids_test.rs"]
|
||||
#[path = "ids_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{notebooks::NotebookId, workflows::WorkflowId};
|
||||
|
||||
use super::{ClientId, ServerId, SyncId};
|
||||
use crate::notebooks::NotebookId;
|
||||
use crate::workflows::WorkflowId;
|
||||
|
||||
#[test]
|
||||
pub fn test_client_sync_id_serialization() {
|
||||
@@ -1,12 +1,10 @@
|
||||
pub mod block;
|
||||
pub mod cloud_objects;
|
||||
pub mod datetime_ext;
|
||||
pub mod experiments;
|
||||
pub mod graphql;
|
||||
pub mod ids;
|
||||
pub mod network_log_pane_manager;
|
||||
pub mod network_log_view;
|
||||
pub mod network_logging;
|
||||
pub mod retry_strategies;
|
||||
pub mod server_api;
|
||||
pub mod sync_queue;
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
//! place.
|
||||
use galaxy_editor::content::buffer::InitialBufferState;
|
||||
use galaxy_editor::render::element::VerticalExpansionBehavior;
|
||||
use galaxy_server_client::network_logging::NetworkLogModel;
|
||||
use galaxy_util::path::LineAndColumnArg;
|
||||
use galaxyui::elements::{ChildView, MouseStateHandle};
|
||||
use galaxyui::text_layout::ClipConfig;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::{
|
||||
elements::{ChildView, MouseStateHandle},
|
||||
text_layout::ClipConfig,
|
||||
ui_components::components::UiComponent,
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
@@ -24,14 +25,10 @@ use crate::code::editor::scroll::{ScrollPosition, ScrollTrigger};
|
||||
use crate::code::editor::view::{CodeEditorRenderOptions, CodeEditorView};
|
||||
use crate::editor::InteractionState;
|
||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||
use crate::pane_group::{
|
||||
pane::view::{self, HeaderContent, StandardHeader, StandardHeaderOptions},
|
||||
BackingView, PaneConfiguration, PaneEvent, PaneHeaderAction,
|
||||
};
|
||||
use crate::server::network_logging::NetworkLogModel;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::pane_group::pane::view::{self, HeaderContent, StandardHeader, StandardHeaderOptions};
|
||||
use crate::pane_group::{BackingView, PaneConfiguration, PaneEvent, PaneHeaderAction};
|
||||
use crate::ui_components::buttons::icon_button_with_color;
|
||||
use crate::ui_components::icons;
|
||||
use crate::ui_components::{blended_colors, icons};
|
||||
|
||||
/// Header text for the network log pane.
|
||||
pub const NETWORK_LOG_HEADER_TEXT: &str = "Network log";
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
use std::fmt;
|
||||
|
||||
use bounded_vec_deque::BoundedVecDeque;
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use enclose::enclose;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::server::datetime_ext::DateTimeExt;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
|
||||
/// Maximum number of network log items retained in memory. Matches the
|
||||
/// previous file-rotation threshold so the pane surface behaves consistently
|
||||
/// with historical expectations.
|
||||
const NETWORK_LOGGING_MAX_ITEMS: usize = 50;
|
||||
|
||||
/// Upper bound on the bounded async channel between the HTTP client hooks and
|
||||
/// the in-memory model. Keeps a small backlog to tolerate bursts without
|
||||
/// blocking the request thread.
|
||||
const NETWORK_LOGGING_MAX_QUEUE_SIZE: usize = 100;
|
||||
|
||||
/// In-memory store of the most recent network log items. Populated by
|
||||
/// [`init`] and read by the network log pane. Holds at most
|
||||
/// [`NETWORK_LOGGING_MAX_ITEMS`] entries; older entries are dropped when new
|
||||
/// ones arrive.
|
||||
pub struct NetworkLogModel {
|
||||
items: BoundedVecDeque<NetworkLogItem>,
|
||||
}
|
||||
|
||||
impl Default for NetworkLogModel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
items: BoundedVecDeque::new(NETWORK_LOGGING_MAX_ITEMS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkLogModel {
|
||||
/// Appends a new log item, evicting the oldest if at capacity.
|
||||
pub fn push(&mut self, item: NetworkLogItem, ctx: &mut ModelContext<Self>) {
|
||||
// `BoundedVecDeque::push_back` returns the evicted item when the
|
||||
// store is at capacity; we discard it since the pane only needs the
|
||||
// most recent entries.
|
||||
let _evicted = self.items.push_back(item);
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
/// Returns the current snapshot as a single string with one item per line,
|
||||
/// in chronological order. Returns an empty string when no items have been
|
||||
/// captured.
|
||||
pub fn snapshot_text(&self) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, item) in self.items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&item.0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Number of items currently retained. Exposed for tests.
|
||||
#[cfg(test)]
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for NetworkLogModel {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
impl SingletonEntity for NetworkLogModel {}
|
||||
|
||||
/// Initializes a network logging task that listens for requests that pass
|
||||
/// through the provided HTTP clients and forwards them to the in-memory
|
||||
/// [`NetworkLogModel`].
|
||||
///
|
||||
/// The logging happens via an async channel so that request hooks never block
|
||||
/// on the main thread. Items are delivered to the model on the main thread via
|
||||
/// [`ModelContext::spawn_stream_local`], mirroring how `ServerApiProvider`
|
||||
/// consumes its own event stream.
|
||||
pub(super) fn init<'a>(
|
||||
http_clients: impl IntoIterator<Item = &'a mut http_client::Client>,
|
||||
ctx: &mut ModelContext<ServerApiProvider>,
|
||||
) {
|
||||
let (tx, rx) = async_channel::bounded::<NetworkLogItem>(NETWORK_LOGGING_MAX_QUEUE_SIZE);
|
||||
|
||||
ctx.spawn_stream_local(
|
||||
rx,
|
||||
move |_, item, ctx| {
|
||||
NetworkLogModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.push(item, ctx);
|
||||
});
|
||||
},
|
||||
|_, _| {},
|
||||
);
|
||||
|
||||
for client in http_clients.into_iter() {
|
||||
client.set_before_request_fn(Box::new(enclose!((tx) move |request, serialized_payload| {
|
||||
if !tx.is_closed() {
|
||||
if let Err(e) = tx.try_send(NetworkLogItem::request(
|
||||
request,
|
||||
serialized_payload.clone(),
|
||||
DateTime::now(),
|
||||
)) {
|
||||
log::error!(
|
||||
"Error sending request from http client to logging task: {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
})));
|
||||
|
||||
client.set_after_response_fn(Box::new(enclose!((tx) move |response| {
|
||||
if !tx.is_closed() {
|
||||
if let Err(e) = tx.try_send(NetworkLogItem::response(response, DateTime::now())) {
|
||||
log::error!("Error sending request from http client to logging task: {e}");
|
||||
}
|
||||
}
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an item (either a request or response) captured for the network
|
||||
/// activity log. The inner string contains a timestamp and the
|
||||
/// [`Debug`]-formatted representation of the request or response, matching the
|
||||
/// format previously written to `warp_network.log`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NetworkLogItem(String);
|
||||
|
||||
impl NetworkLogItem {
|
||||
pub fn request(
|
||||
request: &reqwest::Request,
|
||||
serialized_payload: Option<String>,
|
||||
timestamp: DateTime<FixedOffset>,
|
||||
) -> Self {
|
||||
Self(format!(
|
||||
"[{}]: {:?}{}",
|
||||
timestamp.format("%Y-%m-%d %H:%M:%S,%3f"),
|
||||
request,
|
||||
serialized_payload.map_or("".to_owned(), |payload| format!("\nBody {payload}"))
|
||||
))
|
||||
}
|
||||
|
||||
pub fn response(response: &reqwest::Response, timestamp: DateTime<FixedOffset>) -> Self {
|
||||
Self(format!(
|
||||
"[{}]: {:?}",
|
||||
timestamp.format("%Y-%m-%d %H:%M:%S,%3f"),
|
||||
response
|
||||
))
|
||||
}
|
||||
|
||||
/// Constructs a log item directly from a pre-formatted string. Used in
|
||||
/// tests where we don't have a real `reqwest` request/response handy.
|
||||
#[cfg(test)]
|
||||
pub fn from_string(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for NetworkLogItem {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "network_logging_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,70 +0,0 @@
|
||||
use super::{NetworkLogItem, NetworkLogModel, NETWORK_LOGGING_MAX_ITEMS};
|
||||
use galaxyui::App;
|
||||
|
||||
#[test]
|
||||
fn empty_snapshot_is_empty_string() {
|
||||
App::test((), |app| async move {
|
||||
let model = app.add_singleton_model(|_| NetworkLogModel::default());
|
||||
model.read(&app, |model, _| {
|
||||
assert_eq!(model.snapshot_text(), "");
|
||||
assert_eq!(model.len(), 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_joins_items_with_newlines() {
|
||||
App::test((), |mut app| async move {
|
||||
let model = app.add_singleton_model(|_| NetworkLogModel::default());
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.push(NetworkLogItem::from_string("first"), ctx);
|
||||
model.push(NetworkLogItem::from_string("second"), ctx);
|
||||
model.push(NetworkLogItem::from_string("third"), ctx);
|
||||
});
|
||||
model.read(&app, |model, _| {
|
||||
assert_eq!(model.snapshot_text(), "first\nsecond\nthird");
|
||||
assert_eq!(model.len(), 3);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_beyond_capacity_drops_oldest() {
|
||||
App::test((), |mut app| async move {
|
||||
let model = app.add_singleton_model(|_| NetworkLogModel::default());
|
||||
// Push exactly the capacity; the snapshot should contain all items
|
||||
// and the count should equal the capacity.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
for i in 0..NETWORK_LOGGING_MAX_ITEMS {
|
||||
model.push(NetworkLogItem::from_string(format!("item-{i}")), ctx);
|
||||
}
|
||||
});
|
||||
model.read(&app, |model, _| {
|
||||
assert_eq!(model.len(), NETWORK_LOGGING_MAX_ITEMS);
|
||||
// Oldest is still present when we're exactly at capacity.
|
||||
assert!(model.snapshot_text().starts_with("item-0\n"));
|
||||
});
|
||||
|
||||
// Push one more: the oldest item should be evicted so the store stays
|
||||
// at capacity, and the snapshot should start at item-1 now.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
model.push(NetworkLogItem::from_string("overflow"), ctx);
|
||||
});
|
||||
model.read(&app, |model, _| {
|
||||
assert_eq!(model.len(), NETWORK_LOGGING_MAX_ITEMS);
|
||||
assert!(!model.snapshot_text().contains("item-0\n"));
|
||||
assert!(model.snapshot_text().starts_with("item-1\n"));
|
||||
assert!(model.snapshot_text().ends_with("\noverflow"));
|
||||
});
|
||||
|
||||
// Pushing many additional items keeps the store at capacity.
|
||||
model.update(&mut app, |model, ctx| {
|
||||
for i in 0..10 {
|
||||
model.push(NetworkLogItem::from_string(format!("extra-{i}")), ctx);
|
||||
}
|
||||
});
|
||||
model.read(&app, |model, _| {
|
||||
assert_eq!(model.len(), NETWORK_LOGGING_MAX_ITEMS);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
use galaxyui::RetryOption;
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use warpui::r#async::Timer;
|
||||
use warpui::{duration_with_jitter, RetryOption};
|
||||
|
||||
use crate::server::graphql::GraphQLError;
|
||||
use crate::server::server_api::presigned_upload::HttpStatusError;
|
||||
|
||||
/// Common duration for a periodic poll. In our app, we generally have the following to update the same data:
|
||||
@@ -50,8 +55,82 @@ pub(crate) fn is_transient_http_error(e: &anyhow::Error) -> bool {
|
||||
// the top-level error object — walk the chain.
|
||||
for cause in e.chain() {
|
||||
if let Some(http_err) = cause.downcast_ref::<HttpStatusError>() {
|
||||
return matches!(http_err.status, 408 | 429 | 500..=599);
|
||||
return is_transient_status(http_err.status);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Classify GraphQL/public-API status errors as transient or permanent.
|
||||
///
|
||||
/// Unlike [`is_transient_http_error`], errors without a typed HTTP/GraphQL transport
|
||||
/// cause are treated as permanent. This is intended for GraphQL operations where
|
||||
/// user-facing GraphQL errors are converted into plain `anyhow` errors at the
|
||||
/// operation layer and should not be retried or placed into transient cooldowns.
|
||||
pub(crate) fn is_transient_graphql_or_http_error(e: &anyhow::Error) -> bool {
|
||||
for cause in e.chain() {
|
||||
if let Some(graphql_err) = cause.downcast_ref::<GraphQLError>() {
|
||||
return match graphql_err {
|
||||
GraphQLError::RequestError(_) => true,
|
||||
GraphQLError::HttpError { status, .. } => is_transient_status(status.as_u16()),
|
||||
GraphQLError::StagingAccessBlocked
|
||||
| GraphQLError::IapChallengeBlocked
|
||||
| GraphQLError::ResponseError(_) => false,
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(http_err) = cause.downcast_ref::<HttpStatusError>() {
|
||||
return is_transient_status(http_err.status);
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn is_transient_status(status: u16) -> bool {
|
||||
matches!(status, 408 | 429 | 500..=599)
|
||||
}
|
||||
|
||||
/// Maximum total attempts per operation (initial attempt plus retries on transient errors).
|
||||
pub(crate) const MAX_ATTEMPTS: usize = 3;
|
||||
|
||||
/// Base backoff between retry attempts; each subsequent attempt multiplies by [`BACKOFF_FACTOR`].
|
||||
const INITIAL_BACKOFF: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Exponential growth factor for retry backoff.
|
||||
const BACKOFF_FACTOR: f32 = 2.0;
|
||||
|
||||
/// Maximum jitter as a fraction of the backoff interval.
|
||||
const BACKOFF_JITTER: f32 = 0.3;
|
||||
|
||||
/// Run `attempt_fn` with bounded exponential-backoff retries on transient failures.
|
||||
///
|
||||
/// `operation` is included in retry logs so concurrent callers can be distinguished.
|
||||
///
|
||||
/// `attempt_fn` is called repeatedly with a fresh `Future` per attempt, so callers that need
|
||||
/// per-attempt state (e.g. cloning a request body) own that inside their closure.
|
||||
///
|
||||
/// Transient errors are retried up to [`MAX_ATTEMPTS`] total. Permanent errors return
|
||||
/// immediately. A warning is logged between attempts so retries are visible in logs.
|
||||
pub(crate) async fn with_bounded_retry<T, F, Fut>(operation: &str, mut attempt_fn: F) -> Result<T>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Result<T>>,
|
||||
{
|
||||
let mut delay = INITIAL_BACKOFF;
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
match attempt_fn().await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(e) if attempt >= MAX_ATTEMPTS || !is_transient_http_error(&e) => return Err(e),
|
||||
Err(e) => {
|
||||
log::warn!("{operation}: attempt {attempt}/{MAX_ATTEMPTS} failed, retrying: {e:#}");
|
||||
Timer::after(duration_with_jitter(delay, BACKOFF_JITTER)).await;
|
||||
delay = delay.mul_f32(BACKOFF_FACTOR);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Unreachable when MAX_ATTEMPTS >= 1.
|
||||
Err(anyhow!(
|
||||
"retry loop exhausted without attempting operation (MAX_ATTEMPTS={MAX_ATTEMPTS})"
|
||||
))
|
||||
}
|
||||
|
||||
+439
-540
File diff suppressed because it is too large
Load Diff
+1176
-159
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,146 @@
|
||||
use chrono::TimeZone;
|
||||
use chrono::Utc;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use futures::executor::block_on;
|
||||
use warp_server_client::base_client::CLOUD_AGENT_ID_HEADER;
|
||||
|
||||
use super::super::ServerApi;
|
||||
use super::{
|
||||
build_list_agent_runs_url, AgentMessageHeader, AgentRunEvent, AgentSource,
|
||||
AmbientAgentTaskState, Artifact, ArtifactDownloadResponse, ArtifactType, ExecutionLocation,
|
||||
ListRunsResponse, ReadAgentMessageResponse, RunSortBy, RunSortOrder, TaskListFilter,
|
||||
build_fork_conversation_url, build_list_agent_runs_url, build_run_followup_url,
|
||||
AgentMessageHeader, AgentRunEvent, AgentSource, AmbientAgentTaskState, Artifact,
|
||||
ArtifactDownloadResponse, ArtifactType, ConnectedSelfHostedWorker, ExecutionLocation,
|
||||
ForkConversationResponse, ListConnectedSelfHostedWorkersResponse, ListRunsResponse,
|
||||
ReadAgentMessageResponse, RunFollowupRequest, RunSortBy, RunSortOrder, SpawnAgentRequest,
|
||||
TaskListFilter, UserQueryMode, CONNECTED_SELF_HOSTED_WORKERS_PATH,
|
||||
};
|
||||
use crate::notebooks::NotebookId;
|
||||
|
||||
#[test]
|
||||
fn ambient_agent_headers_for_task_overrides_existing_cloud_agent_header() {
|
||||
let server_api = ServerApi::new_for_test();
|
||||
let ambient_task_id = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
|
||||
let task_scoped_id = "123e4567-e89b-12d3-a456-426614174000".parse().unwrap();
|
||||
|
||||
server_api.set_ambient_agent_task_id(Some(ambient_task_id));
|
||||
|
||||
let cloud_agent_headers: Vec<_> =
|
||||
block_on(server_api.ambient_agent_headers_for_task(&task_scoped_id))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|(name, _)| *name == CLOUD_AGENT_ID_HEADER)
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
cloud_agent_headers,
|
||||
vec![(
|
||||
CLOUD_AGENT_ID_HEADER.to_string(),
|
||||
task_scoped_id.to_string()
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_request_serializes_agent_uid_as_agent_identity_uid() {
|
||||
let request = SpawnAgentRequest {
|
||||
prompt: Some("hello".to_string()),
|
||||
mode: UserQueryMode::Normal,
|
||||
config: None,
|
||||
title: None,
|
||||
team: None,
|
||||
agent_identity_uid: Some("agent_123".to_string()),
|
||||
skill: None,
|
||||
attachments: vec![],
|
||||
interactive: None,
|
||||
parent_run_id: None,
|
||||
runtime_skills: vec![],
|
||||
referenced_attachments: vec![],
|
||||
conversation_id: None,
|
||||
initial_snapshot_token: None,
|
||||
snapshot_disabled: None,
|
||||
orchestration_handoff: None,
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&request).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
value.get("agent_identity_uid").and_then(|v| v.as_str()),
|
||||
Some("agent_123")
|
||||
);
|
||||
assert!(value.get("agent_uid").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connected_self_hosted_workers_path_uses_public_api_route() {
|
||||
assert_eq!(
|
||||
CONNECTED_SELF_HOSTED_WORKERS_PATH,
|
||||
"agent/connected-self-hosted-workers"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_connected_self_hosted_workers_response() {
|
||||
let json = r#"{
|
||||
"workers": [
|
||||
{
|
||||
"worker_host": "worker-2",
|
||||
"connection_count": 2,
|
||||
"connected_at": "2026-05-18T19:00:00Z",
|
||||
"last_seen_at": "2026-05-18T19:05:00Z"
|
||||
},
|
||||
{
|
||||
"worker_host": "worker-1",
|
||||
"connection_count": 1,
|
||||
"connected_at": "2026-05-18T18:00:00Z",
|
||||
"last_seen_at": "2026-05-18T18:05:00Z"
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let response: ListConnectedSelfHostedWorkersResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
response.workers,
|
||||
vec![
|
||||
ConnectedSelfHostedWorker {
|
||||
worker_host: "worker-2".to_string(),
|
||||
connection_count: 2,
|
||||
connected_at: "2026-05-18T19:00:00Z".to_string(),
|
||||
last_seen_at: "2026-05-18T19:05:00Z".to_string(),
|
||||
},
|
||||
ConnectedSelfHostedWorker {
|
||||
worker_host: "worker-1".to_string(),
|
||||
connection_count: 1,
|
||||
connected_at: "2026-05-18T18:00:00Z".to_string(),
|
||||
last_seen_at: "2026-05-18T18:05:00Z".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_request_omits_prompt_when_none() {
|
||||
let request = SpawnAgentRequest {
|
||||
prompt: None,
|
||||
mode: UserQueryMode::Normal,
|
||||
config: None,
|
||||
title: None,
|
||||
team: None,
|
||||
agent_identity_uid: None,
|
||||
skill: None,
|
||||
attachments: vec![],
|
||||
interactive: None,
|
||||
parent_run_id: None,
|
||||
runtime_skills: vec![],
|
||||
referenced_attachments: vec![],
|
||||
conversation_id: None,
|
||||
initial_snapshot_token: None,
|
||||
snapshot_disabled: None,
|
||||
orchestration_handoff: None,
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&request).unwrap();
|
||||
|
||||
assert!(value.get("prompt").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_file_artifact_download_response() {
|
||||
let json = r#"{
|
||||
@@ -972,3 +1105,56 @@ fn build_list_agent_runs_url_routes_to_runs_not_tasks() {
|
||||
assert!(url.starts_with("agent/runs?"));
|
||||
assert!(!url.starts_with("agent/tasks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_run_followup_url_routes_to_run_followups() {
|
||||
let run_id = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
|
||||
assert_eq!(
|
||||
build_run_followup_url(&run_id),
|
||||
"agent/runs/550e8400-e29b-41d4-a716-446655440000/followups"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_run_followup_request() {
|
||||
let request = RunFollowupRequest {
|
||||
message: "continue from here".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_value(request).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"message": "continue from here",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_fork_conversation_url_routes_to_conversation_fork() {
|
||||
assert_eq!(
|
||||
build_fork_conversation_url("550e8400-e29b-41d4-a716-446655440000"),
|
||||
"agent/conversations/550e8400-e29b-41d4-a716-446655440000/fork"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_fork_conversation_url_escapes_path_param() {
|
||||
assert_eq!(
|
||||
build_fork_conversation_url("conversation/with spaces"),
|
||||
"agent/conversations/conversation%2Fwith%20spaces/fork"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_fork_conversation_response() {
|
||||
let response: ForkConversationResponse = serde_json::from_value(serde_json::json!({
|
||||
"forked_conversation_id": "abcdef01-2345-6789-abcd-ef0123456789",
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.forked_conversation_id,
|
||||
"abcdef01-2345-6789-abcd-ef0123456789"
|
||||
);
|
||||
}
|
||||
@@ -1,347 +1,12 @@
|
||||
use std::result::Result as StdResult;
|
||||
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use async_trait::async_trait;
|
||||
use firebase::FirebaseError;
|
||||
use galaxy_core::errors::{AnyhowErrorExt, ErrorExt};
|
||||
use galaxy_graphql::mutations::expire_api_key::ExpireApiKeyResult;
|
||||
use galaxy_graphql::queries::get_conversation_usage::ConversationUsage;
|
||||
use instant::Duration;
|
||||
#[cfg(test)]
|
||||
use mockall::{automock, predicate::*};
|
||||
use thiserror::Error;
|
||||
|
||||
use galaxy_graphql::mutations::create_anonymous_user::{
|
||||
AnonymousUserType, CreateAnonymousUserResult,
|
||||
#[cfg(test)]
|
||||
pub use galaxy_server_client::auth::MockAuthClient;
|
||||
pub use galaxy_server_client::auth::{
|
||||
AuthClient, FetchUserResult, MintCustomTokenError, SyncedUserSettings, UserAuthenticationError,
|
||||
};
|
||||
use galaxy_graphql::mutations::generate_api_key::GenerateApiKeyResult;
|
||||
use galaxy_graphql::mutations::mint_custom_token::MintCustomTokenResult;
|
||||
use galaxy_graphql::queries::api_keys::ApiKeyProperties;
|
||||
use galaxy_graphql::queries::get_user::UserOutput as GqlUserOutput;
|
||||
|
||||
use crate::auth::credentials::{AuthToken, Credentials, FirebaseToken, LoginToken};
|
||||
use crate::auth::user::User;
|
||||
use crate::server::experiments::ServerExperiment;
|
||||
use crate::server::ids::ApiKeyUid;
|
||||
use crate::server::server_api::register_error;
|
||||
use crate::settings::PrivacySettingsSnapshot;
|
||||
|
||||
use super::ServerApi;
|
||||
|
||||
/// Header key for the ambient workload token attached to multi-agent requests.
|
||||
pub const AMBIENT_WORKLOAD_TOKEN_HEADER: &str = "X-Warp-Ambient-Workload-Token";
|
||||
|
||||
/// Header key for the cloud agent task ID attached to requests from ambient agents.
|
||||
pub const CLOUD_AGENT_ID_HEADER: &str = "X-Warp-Cloud-Agent-ID";
|
||||
|
||||
/// User settings that are currently 'synced' (e.g. stored server-side) on a per-user basis.
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
pub struct SyncedUserSettings {
|
||||
pub is_cloud_conversation_storage_enabled: bool,
|
||||
pub is_crash_reporting_enabled: bool,
|
||||
pub is_telemetry_enabled: bool,
|
||||
}
|
||||
|
||||
/// Results of an attempt to fetch the current user.
|
||||
pub struct FetchUserResult {
|
||||
pub user: User,
|
||||
/// The credentials used to authenticate this user.
|
||||
pub credentials: Credentials,
|
||||
pub server_experiments: Vec<ServerExperiment>,
|
||||
/// Whether this attempt to fetch the user was for refreshing an existing logged-in user.
|
||||
pub from_refresh: bool,
|
||||
/// LLM model choices for this user.
|
||||
pub llms: crate::ai::llms::ModelsByFeature,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[cfg_attr(test, automock)]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
pub trait AuthClient: 'static + Send + Sync {
|
||||
/// Creates an anonymous user, who is allowed to use Warp but may lack the ability
|
||||
/// to interact with particular features.
|
||||
async fn create_anonymous_user(
|
||||
&self,
|
||||
referral_code: Option<String>,
|
||||
anonymous_user_type: AnonymousUserType,
|
||||
) -> Result<CreateAnonymousUserResult>;
|
||||
|
||||
/// Returns the cached access token, if it is still valid. If it has expired, fetches a new
|
||||
/// access token using the user's refresh token, caches it, and the returns it.
|
||||
/// Returns an auth mode that may not require an Authorization header (e.g. session cookies or
|
||||
/// test credentials).
|
||||
async fn get_or_refresh_access_token(&self) -> Result<AuthToken>;
|
||||
|
||||
/// Fetches data required to construct the [`User`] object. This includes the user's metadata
|
||||
/// and authentication tokens.
|
||||
async fn fetch_user(
|
||||
&self,
|
||||
token: LoginToken,
|
||||
for_refresh: bool,
|
||||
) -> StdResult<FetchUserResult, UserAuthenticationError>;
|
||||
|
||||
/// Creates and fetches an new custom token for the current user from Firebase.
|
||||
/// This only works for anonymous users, and will surface an error if the user is not anonymous.
|
||||
async fn fetch_new_custom_token(&self) -> Result<MintCustomTokenResult>;
|
||||
|
||||
/// Handles the response from [`Self::fetch_new_custom_token`], returning the newly-minted custom token.
|
||||
fn on_custom_token_fetched(
|
||||
&self,
|
||||
response: Result<MintCustomTokenResult>,
|
||||
) -> Result<String, MintCustomTokenError>;
|
||||
|
||||
/// Queries warp-server for a set of the currently logged-in user's fields.
|
||||
async fn fetch_user_properties<'a>(&self, auth_token: Option<&'a str>)
|
||||
-> Result<GqlUserOutput>;
|
||||
|
||||
/// Upon success, returns an `Option` containing the user's settings retrieved from the server,
|
||||
/// if any. The user may not have server-side settings if they onboarded prior to the launch
|
||||
/// of telemetry opt-out, have not logged in since the launch, and have never changed defaults
|
||||
/// for any of the settings in [`SyncedUserSettings`]. If the fetched settings object exists
|
||||
/// but is missing required fields, or if the request itself failed, returns an error.
|
||||
async fn get_user_settings(&self) -> Result<Option<SyncedUserSettings>>;
|
||||
|
||||
/// Returns conversation usage history for the current user over the past n days.
|
||||
/// If last_updated_end_timestamp is provided, only conversations with
|
||||
/// lastUpdated earlier than this timestamp are returned.
|
||||
async fn get_conversation_usage_history(
|
||||
&self,
|
||||
days: Option<i32>,
|
||||
limit: Option<i32>,
|
||||
last_updated_end_timestamp: Option<galaxy_graphql::scalars::Time>,
|
||||
) -> Result<Vec<ConversationUsage>>;
|
||||
|
||||
async fn set_is_telemetry_enabled(&self, value: bool) -> Result<()>;
|
||||
|
||||
async fn set_is_crash_reporting_enabled(&self, value: bool) -> Result<()>;
|
||||
|
||||
async fn set_is_cloud_conversation_storage_enabled(&self, value: bool) -> Result<()>;
|
||||
|
||||
/// Sends a request to update the user's settings on the server with values contained in the
|
||||
/// given `settings_snapshot`.
|
||||
async fn update_user_settings(&self, settings_snapshot: PrivacySettingsSnapshot) -> Result<()>;
|
||||
|
||||
async fn set_user_is_onboarded(&self) -> Result<bool>;
|
||||
|
||||
/// Requests a device authorization code from the server. This is only used for headless CLI/SDK authentication.
|
||||
async fn request_device_code(
|
||||
&self,
|
||||
) -> StdResult<oauth2::StandardDeviceAuthorizationResponse, UserAuthenticationError>;
|
||||
|
||||
/// Wait for the request to be approved or rejected and exchange it for a short-lived custom access token.
|
||||
async fn exchange_device_access_token(
|
||||
&self,
|
||||
details: &oauth2::StandardDeviceAuthorizationResponse,
|
||||
timeout: Duration,
|
||||
) -> StdResult<FirebaseToken, UserAuthenticationError>;
|
||||
// API Keys
|
||||
async fn list_api_keys(&self) -> Result<Vec<ApiKeyProperties>>;
|
||||
|
||||
async fn create_api_key(
|
||||
&self,
|
||||
name: String,
|
||||
team_id: Option<cynic::Id>,
|
||||
expires_at: Option<galaxy_graphql::scalars::Time>,
|
||||
) -> Result<GenerateApiKeyResult>;
|
||||
|
||||
async fn expire_api_key(&self, key_uid: &ApiKeyUid) -> Result<ExpireApiKeyResult>;
|
||||
|
||||
/// Returns a cached ambient workload token, or issues a new one if not present or expired.
|
||||
///
|
||||
/// Returns `Ok(None)` if not running in an isolation platform (e.g., Namespace) or on WASM.
|
||||
async fn get_or_create_ambient_workload_token(&self) -> Result<Option<String>>;
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl AuthClient for ServerApi {
|
||||
async fn create_anonymous_user(
|
||||
&self,
|
||||
_referral_code: Option<String>,
|
||||
_anonymous_user_type: AnonymousUserType,
|
||||
) -> Result<CreateAnonymousUserResult> {
|
||||
bail!("Server auth disabled")
|
||||
}
|
||||
|
||||
async fn get_or_refresh_access_token(&self) -> Result<AuthToken> {
|
||||
Ok(AuthToken::NoAuth)
|
||||
}
|
||||
|
||||
async fn fetch_user(
|
||||
&self,
|
||||
_token: LoginToken,
|
||||
_for_refresh: bool,
|
||||
) -> StdResult<FetchUserResult, UserAuthenticationError> {
|
||||
Err(UserAuthenticationError::Unexpected(anyhow!(
|
||||
"Server auth disabled"
|
||||
)))
|
||||
}
|
||||
|
||||
async fn fetch_new_custom_token(&self) -> Result<MintCustomTokenResult> {
|
||||
bail!("Server auth disabled")
|
||||
}
|
||||
|
||||
fn on_custom_token_fetched(
|
||||
&self,
|
||||
_response: Result<MintCustomTokenResult>,
|
||||
) -> Result<String, MintCustomTokenError> {
|
||||
Err(MintCustomTokenError::Unknown)
|
||||
}
|
||||
|
||||
async fn fetch_user_properties<'a>(
|
||||
&self,
|
||||
_auth_token: Option<&'a str>,
|
||||
) -> Result<GqlUserOutput> {
|
||||
bail!("Server auth disabled")
|
||||
}
|
||||
|
||||
async fn get_user_settings(&self) -> Result<Option<SyncedUserSettings>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_conversation_usage_history(
|
||||
&self,
|
||||
_days: Option<i32>,
|
||||
_limit: Option<i32>,
|
||||
_last_updated_end_timestamp: Option<galaxy_graphql::scalars::Time>,
|
||||
) -> Result<Vec<ConversationUsage>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn set_is_telemetry_enabled(&self, _value: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_is_crash_reporting_enabled(&self, _value: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_is_cloud_conversation_storage_enabled(&self, _value: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_user_settings(
|
||||
&self,
|
||||
_settings_snapshot: PrivacySettingsSnapshot,
|
||||
) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_user_is_onboarded(&self) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn request_device_code(
|
||||
&self,
|
||||
) -> StdResult<oauth2::StandardDeviceAuthorizationResponse, UserAuthenticationError> {
|
||||
Err(UserAuthenticationError::Unexpected(anyhow!(
|
||||
"Server auth disabled"
|
||||
)))
|
||||
}
|
||||
|
||||
async fn exchange_device_access_token(
|
||||
&self,
|
||||
_details: &oauth2::StandardDeviceAuthorizationResponse,
|
||||
_timeout: Duration,
|
||||
) -> StdResult<FirebaseToken, UserAuthenticationError> {
|
||||
Err(UserAuthenticationError::Unexpected(anyhow!(
|
||||
"Server auth disabled"
|
||||
)))
|
||||
}
|
||||
|
||||
async fn list_api_keys(&self) -> Result<Vec<ApiKeyProperties>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn create_api_key(
|
||||
&self,
|
||||
_name: String,
|
||||
_team_id: Option<cynic::Id>,
|
||||
_expires_at: Option<galaxy_graphql::scalars::Time>,
|
||||
) -> Result<GenerateApiKeyResult> {
|
||||
bail!("Server auth disabled")
|
||||
}
|
||||
|
||||
async fn expire_api_key(&self, _key_uid: &ApiKeyUid) -> Result<ExpireApiKeyResult> {
|
||||
bail!("Server auth disabled")
|
||||
}
|
||||
|
||||
async fn get_or_create_ambient_workload_token(&self) -> Result<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`oauth2::Client`] type, specialized to the endpoints that we require.
|
||||
pub type OAuth2Client = oauth2::basic::BasicClient<
|
||||
oauth2::EndpointNotSet, // HasAuthUrl
|
||||
oauth2::EndpointSet, // HasDeviceAuthUrl
|
||||
oauth2::EndpointNotSet, // HasIntrospectionUrl
|
||||
oauth2::EndpointNotSet, // HasRevocationUrl
|
||||
oauth2::EndpointSet, // HasTokenUrl
|
||||
>;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
#[allow(dead_code)]
|
||||
/// Error type when retrieving a user and validating it against Firebase.
|
||||
pub enum UserAuthenticationError {
|
||||
/// The user's refresh token is invalid. This could occur if the user authed through
|
||||
/// e.g. Google/GitHub and changed their password.
|
||||
#[error("Firebase returned a token error when fetching an ID token")]
|
||||
DeniedAccessToken(FirebaseError),
|
||||
/// The user's account is invalid. This could occur if the user requested their account
|
||||
/// be deleted per their GDPR/CCPA rights.
|
||||
#[error("Firebase returned a user error when fetching an ID token")]
|
||||
UserAccountDisabled(FirebaseError),
|
||||
#[error("Invalid state parameter in auth redirect")]
|
||||
InvalidStateParameter,
|
||||
#[error("Missing state parameter in auth redirect")]
|
||||
MissingStateParameter,
|
||||
#[error("unexpected error occurred when fetching an ID token: {0:#}")]
|
||||
Unexpected(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl ErrorExt for UserAuthenticationError {
|
||||
fn is_actionable(&self) -> bool {
|
||||
match self {
|
||||
UserAuthenticationError::DeniedAccessToken(err) => {
|
||||
// If a request to our server failed because the user's refresh token
|
||||
// has expired, they should re-auth, but there's no value in reporting
|
||||
// this back to us.
|
||||
log::info!("ignoring denied access token error: {err:#}");
|
||||
false
|
||||
}
|
||||
UserAuthenticationError::UserAccountDisabled(err) => {
|
||||
// Similarly, if their account is disabled, they can't make requests.
|
||||
log::info!("ignoring user account disabled error: {err:#}");
|
||||
false
|
||||
}
|
||||
UserAuthenticationError::Unexpected(err) => err.is_actionable(),
|
||||
UserAuthenticationError::InvalidStateParameter
|
||||
| UserAuthenticationError::MissingStateParameter => {
|
||||
// For now, we're marking these as actionable, since a surplus of these errors
|
||||
// could mean that something is wrong in our login flow (e.g. we're not properly
|
||||
// passing the `state` variable back to the desktop client).
|
||||
// But in general, someone attempting to trick another into logging into their
|
||||
// account with a spoofed `state` variable is not actionable.
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
register_error!(UserAuthenticationError);
|
||||
|
||||
impl From<FirebaseError> for UserAuthenticationError {
|
||||
fn from(error: FirebaseError) -> Self {
|
||||
UserAuthenticationError::Unexpected(
|
||||
anyhow::Error::from(error).context("Firebase error (server auth disabled)"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
#[allow(dead_code)]
|
||||
/// Error type when creating anonymous users
|
||||
/// Error type when creating anonymous users.
|
||||
pub enum AnonymousUserCreationError {
|
||||
#[error("The network request to create the anonymous user failed")]
|
||||
CreationFailed,
|
||||
@@ -357,16 +22,6 @@ pub enum AnonymousUserCreationError {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
#[allow(dead_code)]
|
||||
/// Error type when minting a new custom token for an anonymous user
|
||||
pub enum MintCustomTokenError {
|
||||
#[error("Received a user facing error: {0}")]
|
||||
UserFacingError(String),
|
||||
#[error("Failed to create new custom token with unknown error")]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "auth_test.rs"]
|
||||
#[path = "auth_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use crate::auth::credentials::{FirebaseToken, RefreshToken};
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::auth::credentials::{FirebaseToken, RefreshToken};
|
||||
#[cfg(feature = "skip_login")]
|
||||
use crate::server::server_api::ServerApi;
|
||||
|
||||
#[test]
|
||||
fn test_firebase_token_urls() -> Result<()> {
|
||||
let custom_token = FirebaseToken::Custom("ct".to_string());
|
||||
@@ -34,3 +37,18 @@ fn test_firebase_token_urls() -> Result<()> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "skip_login")]
|
||||
#[test]
|
||||
fn access_token_skip_login_rejects_bearer_token() {
|
||||
let (event_sender, _) = async_channel::unbounded();
|
||||
let server_api =
|
||||
ServerApi::new_for_test_with_bearer_token(Some("daemon-token".to_string()), event_sender);
|
||||
|
||||
let error = futures::executor::block_on(server_api.get_or_refresh_access_token()).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"skip_login enabled; failing all authenticated requests"
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,26 @@
|
||||
use super::auth::AuthClient;
|
||||
use super::ServerApi;
|
||||
use crate::ai::generate_block_title::api::{GenerateBlockTitleRequest, GenerateBlockTitleResponse};
|
||||
use crate::server::{
|
||||
block::{Block, DisplaySetting},
|
||||
graphql::{get_request_context, get_user_facing_error_message},
|
||||
};
|
||||
use std::convert::TryFrom;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
use galaxy_core::channel::{Channel, ChannelState};
|
||||
use galaxy_graphql::{
|
||||
mutations::{
|
||||
share_block::{BlockInput, ShareBlock, ShareBlockResult, ShareBlockVariables},
|
||||
unshare_block::{
|
||||
UnshareBlock, UnshareBlockInput, UnshareBlockResult, UnshareBlockVariables,
|
||||
},
|
||||
},
|
||||
queries::get_blocks_for_user::{
|
||||
Block as GqlBlock, GetBlocksForUser, GetBlocksForUserVariables,
|
||||
},
|
||||
};
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
use std::convert::TryFrom;
|
||||
use galaxy_core::channel::{Channel, ChannelState};
|
||||
use galaxy_graphql::mutations::share_block::{
|
||||
BlockInput, ShareBlock, ShareBlockResult, ShareBlockVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::unshare_block::{
|
||||
UnshareBlock, UnshareBlockInput, UnshareBlockResult, UnshareBlockVariables,
|
||||
};
|
||||
use galaxy_graphql::queries::get_blocks_for_user::{
|
||||
Block as GqlBlock, GetBlocksForUser, GetBlocksForUserVariables,
|
||||
};
|
||||
|
||||
use super::ServerApi;
|
||||
use crate::ai::generate_block_title::api::{GenerateBlockTitleRequest, GenerateBlockTitleResponse};
|
||||
use crate::server::block::{Block, DisplaySetting};
|
||||
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
@@ -146,7 +143,7 @@ impl BlockClient for ServerApi {
|
||||
request: GenerateBlockTitleRequest,
|
||||
) -> Result<GenerateBlockTitleResponse, anyhow::Error> {
|
||||
let auth_token = self.get_or_refresh_access_token().await?;
|
||||
let request_builder = self.client.post(format!(
|
||||
let request_builder = self.base_client.http_client().post(format!(
|
||||
"{}/ai/generate_block_title",
|
||||
ChannelState::server_root_url()
|
||||
));
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use futures::TryStreamExt as _;
|
||||
use tokio_util::io::StreamReader;
|
||||
|
||||
pub(crate) async fn write_response_body_to_path(
|
||||
response: http_client::Response,
|
||||
path: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
if let Some(parent) = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
tokio::fs::create_dir_all(parent).await.with_context(|| {
|
||||
format!("Failed to create download directory '{}'", parent.display())
|
||||
})?;
|
||||
}
|
||||
|
||||
let mut file = tokio::fs::File::create(path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to create download file '{}'", path.display()))?;
|
||||
let mut response_stream =
|
||||
StreamReader::new(response.bytes_stream().map_err(std::io::Error::other));
|
||||
tokio::io::copy(&mut response_stream, &mut file)
|
||||
.await
|
||||
.with_context(|| format!("Failed to write download file '{}'", path.display()))?;
|
||||
file.sync_data()
|
||||
.await
|
||||
.with_context(|| format!("Failed to sync download file '{}'", path.display()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,18 +8,49 @@ use async_trait::async_trait;
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub use super::presigned_upload::FileUploadBody;
|
||||
pub use super::presigned_upload::UploadBody;
|
||||
use super::ServerApi;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::agent_sdk::retry::with_bounded_retry;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::artifacts::Artifact;
|
||||
|
||||
/// A presigned upload target returned by the server.
|
||||
#[serde_with::serde_as]
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct UploadTarget {
|
||||
pub url: String,
|
||||
pub method: String,
|
||||
pub headers: HashMap<String, String>,
|
||||
/// Ordered multipart form fields for POST uploads.
|
||||
#[serde(default)]
|
||||
#[serde_as(deserialize_as = "serde_with::DefaultOnNull")]
|
||||
pub fields: Vec<UploadField>,
|
||||
}
|
||||
|
||||
/// A single multipart form field on a POST upload target.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct UploadField {
|
||||
pub name: String,
|
||||
pub value: UploadFieldValue,
|
||||
}
|
||||
|
||||
/// Descriptor for a field value when uploading to an [`UploadTarget`].
|
||||
/// This is currently only used for `POST` requests, but may be supported
|
||||
/// for HTTP headers in the future.
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum UploadFieldValue {
|
||||
/// Literal string value known at URL-generation time.
|
||||
Static { value: String },
|
||||
/// Client should compute CRC32C of the upload, base64-encode the 4-byte
|
||||
/// big-endian result, and send it as this field's value.
|
||||
ContentCrc32C,
|
||||
/// Client should use the raw upload bytes as this field's value.
|
||||
ContentData,
|
||||
}
|
||||
|
||||
/// Request body for upload-snapshot upload targets.
|
||||
@@ -92,6 +123,11 @@ pub struct ResolvedHarnessPrompt {
|
||||
/// system context. Empty when no resumption is in effect.
|
||||
#[serde(default)]
|
||||
pub resumption_prompt: Option<String>,
|
||||
/// Optional server-retrieved context relevant to the task prompt. Each harness
|
||||
/// decides how to inject this — typically by prepending it to the user-turn prompt
|
||||
/// after any resumption preamble.
|
||||
#[serde(default)]
|
||||
pub context: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
@@ -110,6 +146,32 @@ struct FinishTaskRequest {
|
||||
summary: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct ShutdownError {
|
||||
category: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub(crate) struct ReportShutdownRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<ShutdownError>,
|
||||
}
|
||||
|
||||
impl ReportShutdownRequest {
|
||||
/// A clean shutdown with no error payload.
|
||||
pub fn clean() -> Self {
|
||||
Self { error: None }
|
||||
}
|
||||
|
||||
/// An abnormal shutdown carrying an error category and message.
|
||||
pub fn abnormal(category: String, message: String) -> Self {
|
||||
Self {
|
||||
error: Some(ShutdownError { category, message }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for API endpoints used to support third-party agent harnesses in Oz.
|
||||
#[cfg_attr(test, automock)]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
@@ -143,6 +205,16 @@ pub trait HarnessSupportClient: 'static + Send + Sync {
|
||||
/// artifacts already reported via `report_artifact`.
|
||||
async fn finish_task(&self, success: bool, summary: &str) -> Result<()>;
|
||||
|
||||
/// Report a clean shutdown of the agent process.
|
||||
async fn report_clean_shutdown(&self) -> Result<()>;
|
||||
|
||||
/// Report an error shutdown of the agent process.
|
||||
async fn report_error_shutdown(
|
||||
&self,
|
||||
error_category: String,
|
||||
error_message: String,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Get presigned upload targets for a workspace state snapshot.
|
||||
///
|
||||
/// The returned list is aligned by index with `request.files`. See
|
||||
@@ -168,6 +240,119 @@ pub trait HarnessSupportClient: 'static + Send + Sync {
|
||||
fn http_client(&self) -> &http_client::Client;
|
||||
}
|
||||
|
||||
impl ServerApi {
|
||||
pub(crate) async fn get_public_api_response_for_task(
|
||||
&self,
|
||||
task_id: &AmbientAgentTaskId,
|
||||
path: &str,
|
||||
) -> Result<http_client::Response> {
|
||||
let auth_token = self
|
||||
.get_or_refresh_access_token()
|
||||
.await
|
||||
.context("Failed to get access token for API request")?;
|
||||
|
||||
let url = format!("{}/api/v1/{}", crate::ChannelState::server_root_url(), path);
|
||||
|
||||
let mut request = self.base_client.http_client().get(&url);
|
||||
if let Some(token) = auth_token.as_bearer_token() {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
|
||||
for (name, value) in self.ambient_agent_headers_for_task(task_id).await? {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to send API request to {url}"))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(response)
|
||||
} else {
|
||||
Err(Self::error_from_response(response).await)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn post_public_api_response_for_task<B>(
|
||||
&self,
|
||||
task_id: &AmbientAgentTaskId,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<http_client::Response>
|
||||
where
|
||||
B: serde::Serialize,
|
||||
{
|
||||
let auth_token = self
|
||||
.get_or_refresh_access_token()
|
||||
.await
|
||||
.context("Failed to get access token for API request")?;
|
||||
|
||||
let url = format!("{}/api/v1/{}", crate::ChannelState::server_root_url(), path);
|
||||
|
||||
let mut request = self.base_client.http_client().post(&url).json(body);
|
||||
if let Some(token) = auth_token.as_bearer_token() {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
|
||||
for (name, value) in self.ambient_agent_headers_for_task(task_id).await? {
|
||||
request = request.header(name, value);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to send API request to {url}"))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(response)
|
||||
} else {
|
||||
Err(Self::error_from_response(response).await)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_prompt_for_task(
|
||||
&self,
|
||||
task_id: &AmbientAgentTaskId,
|
||||
request: ResolvePromptRequest,
|
||||
) -> Result<ResolvedHarnessPrompt> {
|
||||
let response = self
|
||||
.post_public_api_response_for_task(task_id, "harness-support/resolve-prompt", &request)
|
||||
.await?;
|
||||
let url = response.url().clone();
|
||||
response
|
||||
.json::<ResolvedHarnessPrompt>()
|
||||
.await
|
||||
.with_context(|| format!("Failed to deserialize response from {url}"))
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch_transcript_for_task(
|
||||
&self,
|
||||
task_id: &AmbientAgentTaskId,
|
||||
) -> Result<bytes::Bytes> {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
with_bounded_retry("fetch task-scoped harness-support transcript", || async {
|
||||
let response = self
|
||||
.get_public_api_response_for_task(task_id, "harness-support/transcript")
|
||||
.await?;
|
||||
response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read task-scoped harness-support transcript body")
|
||||
})
|
||||
.await
|
||||
}
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
let _ = task_id;
|
||||
unreachable!(
|
||||
"fetch_transcript_for_task is not supported on wasm; agent_sdk is not built on this target"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl HarnessSupportClient for ServerApi {
|
||||
@@ -242,6 +427,26 @@ impl HarnessSupportClient for ServerApi {
|
||||
.await
|
||||
}
|
||||
|
||||
async fn report_clean_shutdown(&self) -> Result<()> {
|
||||
self.post_public_api_unit(
|
||||
"harness-support/report-shutdown",
|
||||
&ReportShutdownRequest::clean(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn report_error_shutdown(
|
||||
&self,
|
||||
error_category: String,
|
||||
error_message: String,
|
||||
) -> Result<()> {
|
||||
self.post_public_api_unit(
|
||||
"harness-support/report-shutdown",
|
||||
&ReportShutdownRequest::abnormal(error_category, error_message),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_snapshot_upload_targets(
|
||||
&self,
|
||||
request: &SnapshotUploadRequest,
|
||||
@@ -275,7 +480,7 @@ impl HarnessSupportClient for ServerApi {
|
||||
}
|
||||
|
||||
fn http_client(&self) -> &http_client::Client {
|
||||
&self.client
|
||||
self.base_client.http_client()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +488,7 @@ impl HarnessSupportClient for ServerApi {
|
||||
pub async fn upload_to_target(
|
||||
http_client: &http_client::Client,
|
||||
target: &UploadTarget,
|
||||
body: impl Into<reqwest::Body>,
|
||||
body: impl UploadBody,
|
||||
) -> Result<()> {
|
||||
super::presigned_upload::upload_to_target(http_client, target, body).await
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
use crate::ai::artifacts::Artifact;
|
||||
|
||||
#[test]
|
||||
fn upload_target_deserializes_null_fields_as_empty() {
|
||||
use super::UploadTarget;
|
||||
|
||||
let target: UploadTarget = serde_json::from_value(serde_json::json!({
|
||||
"url": "https://example.com/upload",
|
||||
"method": "PUT",
|
||||
"headers": {},
|
||||
"fields": null
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(target.fields.len(), 0);
|
||||
}
|
||||
|
||||
/// Assert that `Artifact`s serialize to the expected format for the /harness-support/report-artifact
|
||||
/// endpoint.
|
||||
/// If `Artifact` serialization changes, this test will catch it.
|
||||
@@ -23,3 +38,28 @@ fn pull_request_artifact_serializes_to_expected_wire_format() {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_shutdown_clean_serializes_without_error() {
|
||||
use super::ReportShutdownRequest;
|
||||
|
||||
let request = ReportShutdownRequest::clean();
|
||||
let json = serde_json::to_value(&request).unwrap();
|
||||
assert_eq!(json, serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_shutdown_abnormal_serializes_with_error() {
|
||||
|
||||
let request = ReportShutdownRequest::abnormal("oom".to_string(), "out of memory".to_string());
|
||||
let json = serde_json::to_value(&request).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"error": {
|
||||
"category": "oom",
|
||||
"message": "out of memory"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
use super::ServerApi;
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
|
||||
use crate::channel::ChannelState;
|
||||
use crate::features::FeatureFlag;
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
|
||||
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
|
||||
use galaxy_graphql::mutations::create_simple_integration::{
|
||||
CreateSimpleIntegration, CreateSimpleIntegrationOutput, CreateSimpleIntegrationResult,
|
||||
CreateSimpleIntegrationVariables, SimpleIntegrationConfig,
|
||||
@@ -39,6 +33,11 @@ use galaxy_graphql::queries::user_repo_auth_status::{
|
||||
UserRepoAuthStatusOutput, UserRepoAuthStatusResult, UserRepoAuthStatusVariables,
|
||||
};
|
||||
|
||||
use super::ServerApi;
|
||||
use crate::channel::ChannelState;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub trait IntegrationsClientBounds: Send + Sync {}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// We don't resolve managed MCPs from agent run CLI flows on WASM, so this code is unused there.
|
||||
#![cfg_attr(target_family = "wasm", expect(dead_code))]
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use cynic::MutationBuilder;
|
||||
#[cfg(test)]
|
||||
use mockall::automock;
|
||||
use uuid::Uuid;
|
||||
use warp_graphql::mutations::create_managed_mcp_client_config::{
|
||||
CreateManagedMcpClientConfig, CreateManagedMcpClientConfigInput,
|
||||
CreateManagedMcpClientConfigOutput, CreateManagedMcpClientConfigResult,
|
||||
CreateManagedMcpClientConfigVariables,
|
||||
};
|
||||
|
||||
use super::ServerApi;
|
||||
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
pub trait ManagedMcpClient: 'static + Send + Sync {
|
||||
async fn create_managed_mcp_client_config(
|
||||
&self,
|
||||
uid: Uuid,
|
||||
) -> Result<CreateManagedMcpClientConfigOutput>;
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl ManagedMcpClient for ServerApi {
|
||||
async fn create_managed_mcp_client_config(
|
||||
&self,
|
||||
uid: Uuid,
|
||||
) -> Result<CreateManagedMcpClientConfigOutput> {
|
||||
let variables = CreateManagedMcpClientConfigVariables {
|
||||
input: CreateManagedMcpClientConfigInput {
|
||||
uid: cynic::Id::new(uid.to_string()),
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = CreateManagedMcpClientConfig::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.create_managed_mcp_client_config {
|
||||
CreateManagedMcpClientConfigResult::CreateManagedMcpClientConfigOutput(output) => {
|
||||
Ok(output)
|
||||
}
|
||||
CreateManagedMcpClientConfigResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
CreateManagedMcpClientConfigResult::Unknown => Err(anyhow!(
|
||||
"Unknown error while creating managed MCP client config"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,27 @@ use std::collections::HashMap;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
use galaxy_graphql::managed_secrets::{ManagedSecret, ManagedSecretType};
|
||||
use galaxy_graphql::mutations::create_managed_secret::{
|
||||
CreateManagedSecret, CreateManagedSecretInput, CreateManagedSecretResult,
|
||||
CreateManagedSecretVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::delete_managed_secret::{
|
||||
DeleteManagedSecret, DeleteManagedSecretInput, DeleteManagedSecretResult,
|
||||
DeleteManagedSecretVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::issue_task_identity_token::{
|
||||
IssueTaskIdentityToken, IssueTaskIdentityTokenInput, IssueTaskIdentityTokenResult,
|
||||
IssueTaskIdentityTokenVariables,
|
||||
};
|
||||
use galaxy_graphql::object_permissions::OwnerType;
|
||||
use galaxy_graphql::mutations::update_managed_secret::{
|
||||
UpdateManagedSecret, UpdateManagedSecretInput, UpdateManagedSecretResult,
|
||||
UpdateManagedSecretVariables,
|
||||
};
|
||||
use galaxy_graphql::object_permissions::{Owner, OwnerType};
|
||||
use galaxy_graphql::queries::list_harness_auth_secrets::{
|
||||
ListHarnessAuthSecrets, ListHarnessAuthSecretsInput, ListHarnessAuthSecretsVariables,
|
||||
};
|
||||
use galaxy_graphql::queries::list_managed_secrets::{
|
||||
ListManagedSecrets, ListManagedSecretsVariables, ManagedSecretsInput, ManagedSecretsResult,
|
||||
};
|
||||
@@ -17,31 +33,12 @@ use galaxy_graphql::queries::managed_secret_config::{
|
||||
use galaxy_graphql::queries::task_secrets::{
|
||||
ManagedSecretValue, TaskSecrets, TaskSecretsInput, TaskSecretsResult, TaskSecretsVariables,
|
||||
};
|
||||
use galaxy_graphql::{
|
||||
managed_secrets::{ManagedSecret, ManagedSecretType},
|
||||
mutations::{
|
||||
create_managed_secret::{
|
||||
CreateManagedSecret, CreateManagedSecretInput, CreateManagedSecretResult,
|
||||
CreateManagedSecretVariables,
|
||||
},
|
||||
delete_managed_secret::{
|
||||
DeleteManagedSecret, DeleteManagedSecretInput, DeleteManagedSecretResult,
|
||||
DeleteManagedSecretVariables,
|
||||
},
|
||||
update_managed_secret::{
|
||||
UpdateManagedSecret, UpdateManagedSecretInput, UpdateManagedSecretResult,
|
||||
UpdateManagedSecretVariables,
|
||||
},
|
||||
},
|
||||
object_permissions::Owner,
|
||||
};
|
||||
pub use galaxy_managed_secrets::client::{ManagedSecretConfigs, ManagedSecretsClient};
|
||||
use galaxy_managed_secrets::client::{SecretOwner, TaskIdentityToken};
|
||||
|
||||
use super::ServerApi;
|
||||
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
|
||||
|
||||
pub use galaxy_managed_secrets::client::{ManagedSecretConfigs, ManagedSecretsClient};
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl ManagedSecretsClient for ServerApi {
|
||||
@@ -202,6 +199,37 @@ impl ManagedSecretsClient for ServerApi {
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_harness_auth_secrets(
|
||||
&self,
|
||||
harness: warp_graphql::ai::AgentHarness,
|
||||
) -> Result<Vec<ManagedSecret>> {
|
||||
let Some(harness_input) = Option::<
|
||||
warp_graphql::queries::list_harness_auth_secrets::AgentHarnessInput,
|
||||
>::from(harness) else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let variables = ListHarnessAuthSecretsVariables {
|
||||
input: ListHarnessAuthSecretsInput {
|
||||
harness: harness_input,
|
||||
},
|
||||
request_context: get_request_context(),
|
||||
};
|
||||
let operation = ListHarnessAuthSecrets::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.harness_auth_secrets {
|
||||
warp_graphql::queries::list_harness_auth_secrets::HarnessAuthSecretsResult::HarnessAuthSecretsOutput(output) => {
|
||||
Ok(output.managed_secrets)
|
||||
}
|
||||
warp_graphql::queries::list_harness_auth_secrets::HarnessAuthSecretsResult::UserFacingError(error) => {
|
||||
Err(anyhow!(get_user_facing_error_message(error)))
|
||||
}
|
||||
warp_graphql::queries::list_harness_auth_secrets::HarnessAuthSecretsResult::Unknown => {
|
||||
Err(anyhow!("Unknown error while listing harness auth secrets"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_secrets(&self) -> Result<Vec<ManagedSecret>> {
|
||||
let variables = ListManagedSecretsVariables {
|
||||
// Pagination over managed secrets is not yet supported.
|
||||
|
||||
+192
-431
@@ -1,335 +1,162 @@
|
||||
use crate::{
|
||||
ai::{
|
||||
ambient_agents::scheduled::ScheduledAmbientAgent,
|
||||
cloud_environments::AmbientAgentEnvironment,
|
||||
document::ai_document_model::AIDocumentId,
|
||||
execution_profiles::AIExecutionProfile,
|
||||
facts::AIFact,
|
||||
mcp::{MCPServer, TemplatableMCPServer},
|
||||
},
|
||||
channel::ChannelState,
|
||||
cloud_object::{
|
||||
model::{
|
||||
actions::{ObjectActionHistory, ObjectActionType},
|
||||
generic_string_model::{
|
||||
GenericStringModel, GenericStringObjectId, Serializer, StringModel,
|
||||
},
|
||||
json_model::JsonSerializer,
|
||||
},
|
||||
BulkCreateCloudObjectResult, BulkCreateGenericStringObjectsRequest,
|
||||
CreateCloudObjectResult, CreateObjectRequest, CreatedCloudObject, GenericCloudObject,
|
||||
GenericServerObject, GenericStringObjectFormat, GenericStringObjectUniqueKey,
|
||||
JsonObjectType, ObjectDeleteResult, ObjectIdType, ObjectMetadataUpdateResult,
|
||||
ObjectPermissionUpdateResult, ObjectPermissionsUpdateData, ObjectType, ObjectsToUpdate,
|
||||
Owner, Revision, RevisionAndLastEditor, ServerCloudObject, ServerFolder, ServerMetadata,
|
||||
ServerNotebook, ServerObject, ServerPermissions, ServerWorkflow, UpdateCloudObjectResult,
|
||||
},
|
||||
drive::{folders::FolderId, sharing::SharingAccessLevel},
|
||||
env_vars::EnvVarCollection,
|
||||
notebooks::{NotebookId, SerializedNotebook},
|
||||
server::{
|
||||
cloud_objects::{
|
||||
listener::ObjectUpdateMessage,
|
||||
update_manager::{GetCloudObjectResponse, InitialLoadResponse},
|
||||
},
|
||||
graphql::{get_request_context, get_user_facing_error_message},
|
||||
ids::{ClientId, HashableId, ServerId, ServerIdAndType, SyncId, ToServerId},
|
||||
server_api::{auth::AuthClient, ServerApi},
|
||||
sync_queue::SerializedModel,
|
||||
},
|
||||
settings::Preference,
|
||||
workflows::{workflow_enum::WorkflowEnum, WorkflowId},
|
||||
workspaces::user_profiles::UserProfileWithUID,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_channel::Sender;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
// #[cfg(any(test, feature = "test-util"))]
|
||||
// pub use cloud_object_client::MockObjectClient;
|
||||
use cloud_object_client::{
|
||||
GetCloudObjectResponse, InitialLoadResponse, ObjectActionHistory, ObjectActionType,
|
||||
ObjectDeleteResult, ObjectMetadataUpdateResult, ObjectPermissionUpdateResult,
|
||||
ObjectPermissionsUpdateData, ObjectUpdateMessage,
|
||||
};
|
||||
pub use cloud_object_client::{GuestIdentifier, ObjectClient};
|
||||
use cloud_object_models::JsonSerializer;
|
||||
use cynic::{MutationBuilder, QueryBuilder, SubscriptionBuilder};
|
||||
use galaxy_core::report_error;
|
||||
use galaxy_graphql::{
|
||||
error::UserFacingErrorInterface,
|
||||
generic_string_object::GenericStringObjectInput,
|
||||
mutations::{
|
||||
add_object_guests::{
|
||||
AddObjectGuests, AddObjectGuestsInput, AddObjectGuestsResult, AddObjectGuestsVariables,
|
||||
},
|
||||
bulk_create_objects::{
|
||||
BulkCreateGenericStringObjectsInput, BulkCreateObjects, BulkCreateObjectsInput,
|
||||
BulkCreateObjectsResult, BulkCreateObjectsVariables,
|
||||
},
|
||||
create_folder::{
|
||||
CreateFolder, CreateFolderInput, CreateFolderResult, CreateFolderVariables,
|
||||
},
|
||||
create_generic_string_object::{
|
||||
CreateGenericStringObject, CreateGenericStringObjectInput,
|
||||
CreateGenericStringObjectResult, CreateGenericStringObjectVariables,
|
||||
},
|
||||
create_notebook::{
|
||||
CreateNotebook, CreateNotebookInput, CreateNotebookResult, CreateNotebookVariables,
|
||||
},
|
||||
create_workflow::{
|
||||
CreateWorkflow, CreateWorkflowInput, CreateWorkflowResult, CreateWorkflowVariables,
|
||||
},
|
||||
delete_object::{
|
||||
DeleteObject, DeleteObjectInput, DeleteObjectResult, DeleteObjectVariables,
|
||||
},
|
||||
empty_trash::{EmptyTrash, EmptyTrashInput, EmptyTrashResult, EmptyTrashVariables},
|
||||
give_up_notebook_edit_access::{
|
||||
GiveUpNotebookEditAccess, GiveUpNotebookEditAccessVariables,
|
||||
},
|
||||
grab_notebook_edit_access::{GrabNotebookEditAccess, GrabNotebookEditAccessVariables},
|
||||
leave_object::{LeaveObject, LeaveObjectInput, LeaveObjectResult, LeaveObjectVariables},
|
||||
move_object::{MoveObject, MoveObjectInput, MoveObjectResult, MoveObjectVariables},
|
||||
record_object_action::{
|
||||
RecordObjectAction, RecordObjectActionInput, RecordObjectActionResult,
|
||||
RecordObjectActionVariables,
|
||||
},
|
||||
remove_object_guest::{
|
||||
RemoveObjectGuest, RemoveObjectGuestInput, RemoveObjectGuestResult,
|
||||
RemoveObjectGuestVariables,
|
||||
},
|
||||
remove_object_link_permissions::{
|
||||
RemoveObjectLinkPermissions, RemoveObjectLinkPermissionsInput,
|
||||
RemoveObjectLinkPermissionsResult, RemoveObjectLinkPermissionsVariables,
|
||||
},
|
||||
set_object_link_permissions::{
|
||||
SetObjectLinkPermissions, SetObjectLinkPermissionsInput,
|
||||
SetObjectLinkPermissionsResult, SetObjectLinkPermissionsVariables,
|
||||
},
|
||||
transfer_generic_string_object_owner::{
|
||||
TransferGenericStringObjectOwner, TransferGenericStringObjectOwnerInput,
|
||||
TransferGenericStringObjectOwnerResult, TransferGenericStringObjectOwnerVariables,
|
||||
},
|
||||
transfer_notebook_owner::{
|
||||
TransferNotebookOwner, TransferNotebookOwnerInput, TransferNotebookOwnerResult,
|
||||
TransferNotebookOwnerVariables,
|
||||
},
|
||||
transfer_workflow_owner::{
|
||||
TransferWorkflowOwner, TransferWorkflowOwnerInput, TransferWorkflowOwnerResult,
|
||||
TransferWorkflowOwnerVariables,
|
||||
},
|
||||
trash_object::{TrashObject, TrashObjectInput, TrashObjectResult, TrashObjectVariables},
|
||||
untrash_object::{UntrashObject, UntrashObjectInput, UntrashObjectVariables},
|
||||
update_folder::{
|
||||
UpdateFolder, UpdateFolderInput, UpdateFolderResult, UpdateFolderVariables,
|
||||
},
|
||||
update_generic_string_object::{
|
||||
UpdateGenericStringObject, UpdateGenericStringObjectInput,
|
||||
UpdateGenericStringObjectVariables,
|
||||
},
|
||||
update_notebook::{
|
||||
NotebookUpdate, UpdateNotebook, UpdateNotebookInput, UpdateNotebookResult,
|
||||
UpdateNotebookVariables,
|
||||
},
|
||||
update_object_guests::{
|
||||
UpdateObjectGuests, UpdateObjectGuestsInput, UpdateObjectGuestsResult,
|
||||
UpdateObjectGuestsVariables,
|
||||
},
|
||||
update_workflow::{
|
||||
UpdateWorkflow, UpdateWorkflowInput, UpdateWorkflowResult, UpdateWorkflowVariables,
|
||||
WorkflowUpdate,
|
||||
},
|
||||
},
|
||||
notebook::{UpdateNotebookEditAccessInput, UpdateNotebookEditAccessResult},
|
||||
object::CloudObjectWithDescendants,
|
||||
object_permissions::AccessLevel,
|
||||
queries::{
|
||||
get_cloud_environments::{
|
||||
GetCloudEnvironmentsQuery, GetCloudEnvironmentsQueryVariables,
|
||||
GetCloudEnvironmentsResult,
|
||||
},
|
||||
get_cloud_object::{
|
||||
CloudObjectInput, CloudObjectResult, GetCloudObject, GetCloudObjectVariables,
|
||||
},
|
||||
get_updated_cloud_objects::{
|
||||
GetUpdatedCloudObjects, GetUpdatedCloudObjectsVariables, UpdatedCloudObjectsInput,
|
||||
UpdatedCloudObjectsResult,
|
||||
},
|
||||
},
|
||||
subscriptions::{
|
||||
get_warp_drive_updates::GetWarpDriveUpdates, start_graphql_streaming_operation,
|
||||
},
|
||||
use galaxy_graphql::error::UserFacingErrorInterface;
|
||||
use galaxy_graphql::generic_string_object::GenericStringObjectInput;
|
||||
use galaxy_graphql::mutations::add_object_guests::{
|
||||
AddObjectGuests, AddObjectGuestsInput, AddObjectGuestsResult, AddObjectGuestsVariables,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use mockall::{automock, predicate::*};
|
||||
use std::collections::HashMap;
|
||||
use galaxy_graphql::mutations::bulk_create_objects::{
|
||||
BulkCreateGenericStringObjectsInput, BulkCreateObjects, BulkCreateObjectsInput,
|
||||
BulkCreateObjectsResult, BulkCreateObjectsVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::create_folder::{
|
||||
CreateFolder, CreateFolderInput, CreateFolderResult, CreateFolderVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::create_generic_string_object::{
|
||||
CreateGenericStringObject, CreateGenericStringObjectInput, CreateGenericStringObjectResult,
|
||||
CreateGenericStringObjectVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::create_notebook::{
|
||||
CreateNotebook, CreateNotebookInput, CreateNotebookResult, CreateNotebookVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::create_workflow::{
|
||||
CreateWorkflow, CreateWorkflowInput, CreateWorkflowResult, CreateWorkflowVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::delete_object::{
|
||||
DeleteObject, DeleteObjectInput, DeleteObjectResult, DeleteObjectVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::empty_trash::{
|
||||
EmptyTrash, EmptyTrashInput, EmptyTrashResult, EmptyTrashVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::give_up_notebook_edit_access::{
|
||||
GiveUpNotebookEditAccess, GiveUpNotebookEditAccessVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::grab_notebook_edit_access::{
|
||||
GrabNotebookEditAccess, GrabNotebookEditAccessVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::leave_object::{
|
||||
LeaveObject, LeaveObjectInput, LeaveObjectResult, LeaveObjectVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::move_object::{
|
||||
MoveObject, MoveObjectInput, MoveObjectResult, MoveObjectVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::record_object_action::{
|
||||
RecordObjectAction, RecordObjectActionInput, RecordObjectActionResult,
|
||||
RecordObjectActionVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::remove_object_guest::{
|
||||
RemoveObjectGuest, RemoveObjectGuestInput, RemoveObjectGuestResult, RemoveObjectGuestVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::remove_object_link_permissions::{
|
||||
RemoveObjectLinkPermissions, RemoveObjectLinkPermissionsInput,
|
||||
RemoveObjectLinkPermissionsResult, RemoveObjectLinkPermissionsVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::set_object_link_permissions::{
|
||||
SetObjectLinkPermissions, SetObjectLinkPermissionsInput, SetObjectLinkPermissionsResult,
|
||||
SetObjectLinkPermissionsVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::transfer_generic_string_object_owner::{
|
||||
TransferGenericStringObjectOwner, TransferGenericStringObjectOwnerInput,
|
||||
TransferGenericStringObjectOwnerResult, TransferGenericStringObjectOwnerVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::transfer_notebook_owner::{
|
||||
TransferNotebookOwner, TransferNotebookOwnerInput, TransferNotebookOwnerResult,
|
||||
TransferNotebookOwnerVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::transfer_workflow_owner::{
|
||||
TransferWorkflowOwner, TransferWorkflowOwnerInput, TransferWorkflowOwnerResult,
|
||||
TransferWorkflowOwnerVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::trash_object::{
|
||||
TrashObject, TrashObjectInput, TrashObjectResult, TrashObjectVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::untrash_object::{
|
||||
UntrashObject, UntrashObjectInput, UntrashObjectVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::update_folder::{
|
||||
UpdateFolder, UpdateFolderInput, UpdateFolderResult, UpdateFolderVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::update_generic_string_object::{
|
||||
UpdateGenericStringObject, UpdateGenericStringObjectInput, UpdateGenericStringObjectVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::update_notebook::{
|
||||
NotebookUpdate, UpdateNotebook, UpdateNotebookInput, UpdateNotebookResult,
|
||||
UpdateNotebookVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::update_object_guests::{
|
||||
UpdateObjectGuests, UpdateObjectGuestsInput, UpdateObjectGuestsResult,
|
||||
UpdateObjectGuestsVariables,
|
||||
};
|
||||
use galaxy_graphql::mutations::update_workflow::{
|
||||
UpdateWorkflow, UpdateWorkflowInput, UpdateWorkflowResult, UpdateWorkflowVariables,
|
||||
WorkflowUpdate,
|
||||
};
|
||||
use galaxy_graphql::notebook::{UpdateNotebookEditAccessInput, UpdateNotebookEditAccessResult};
|
||||
use galaxy_graphql::object::CloudObjectWithDescendants;
|
||||
use galaxy_graphql::object_permissions::AccessLevel;
|
||||
use galaxy_graphql::queries::get_cloud_environments::{
|
||||
GetCloudEnvironmentsQuery, GetCloudEnvironmentsQueryVariables, GetCloudEnvironmentsResult,
|
||||
};
|
||||
use galaxy_graphql::queries::get_cloud_object::{
|
||||
CloudObjectInput, CloudObjectResult, GetCloudObject, GetCloudObjectVariables,
|
||||
};
|
||||
use galaxy_graphql::queries::get_updated_cloud_objects::{
|
||||
GetUpdatedCloudObjects, GetUpdatedCloudObjectsVariables, UpdatedCloudObjectsInput,
|
||||
UpdatedCloudObjectsResult,
|
||||
};
|
||||
use galaxy_graphql::subscriptions::get_warp_drive_updates::GetWarpDriveUpdates;
|
||||
use galaxy_graphql::subscriptions::start_graphql_streaming_operation;
|
||||
|
||||
/// Identifies a guest to remove from an object.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum GuestIdentifier {
|
||||
/// Remove a user guest by their email address.
|
||||
Email(String),
|
||||
/// Remove a team guest by their team UID.
|
||||
TeamUid(ServerId),
|
||||
}
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
pub trait ObjectClient: 'static + Send + Sync {
|
||||
/// This method saves a workflow for a given owner and returns it on success.
|
||||
async fn create_workflow(
|
||||
&self,
|
||||
request: CreateObjectRequest,
|
||||
) -> Result<CreateCloudObjectResult>;
|
||||
|
||||
/// Updates a workflow with the new data. The update may be rejected if a revision
|
||||
/// is specified _and_ that revision is not the current revision of the object in storage.
|
||||
async fn update_workflow(
|
||||
&self,
|
||||
workflow_id: WorkflowId,
|
||||
data: SerializedModel,
|
||||
revision: Option<Revision>,
|
||||
) -> Result<UpdateCloudObjectResult<ServerWorkflow>>;
|
||||
|
||||
/// Creates n generic string objects in a single graphql request. Use
|
||||
/// this rather than calling create_generic_string_object multiple times
|
||||
/// in a loop.
|
||||
async fn bulk_create_generic_string_objects(
|
||||
&self,
|
||||
owner: Owner,
|
||||
objects: &[BulkCreateGenericStringObjectsRequest],
|
||||
) -> Result<BulkCreateCloudObjectResult>;
|
||||
|
||||
async fn create_generic_string_object(
|
||||
&self,
|
||||
format: GenericStringObjectFormat,
|
||||
uniqueness_key: Option<GenericStringObjectUniqueKey>,
|
||||
request: CreateObjectRequest,
|
||||
) -> Result<CreateCloudObjectResult>;
|
||||
|
||||
/// Creates a notebook on the server, returning the ID and revision of the object after
|
||||
/// creation.
|
||||
async fn create_notebook(
|
||||
&self,
|
||||
request: CreateObjectRequest,
|
||||
) -> Result<CreateCloudObjectResult>;
|
||||
|
||||
/// Updates a notebook with the new title and data. The update may be rejected if a revision
|
||||
/// is specified _and_ that revision is not the current revision of the object in storage.
|
||||
async fn update_notebook(
|
||||
&self,
|
||||
notebook_id: NotebookId,
|
||||
title: Option<String>,
|
||||
data: Option<SerializedModel>,
|
||||
revision: Option<Revision>,
|
||||
) -> Result<UpdateCloudObjectResult<ServerNotebook>>;
|
||||
|
||||
async fn create_folder(&self, request: CreateObjectRequest) -> Result<CreateCloudObjectResult>;
|
||||
|
||||
async fn update_folder(
|
||||
&self,
|
||||
folder_id: FolderId,
|
||||
name: SerializedModel,
|
||||
) -> Result<UpdateCloudObjectResult<ServerFolder>>;
|
||||
|
||||
async fn update_generic_string_object(
|
||||
&self,
|
||||
object_id: GenericStringObjectId,
|
||||
model: SerializedModel,
|
||||
revision: Option<Revision>,
|
||||
) -> Result<UpdateCloudObjectResult<Box<dyn ServerObject>>>;
|
||||
|
||||
/// Sets the current editor of the notebook to be the logged in user
|
||||
async fn grab_notebook_edit_access(&self, notebook_id: NotebookId) -> Result<ServerMetadata>;
|
||||
/// Sets the current editor of the notebook to be null
|
||||
async fn give_up_notebook_edit_access(&self, notebook_id: NotebookId)
|
||||
-> Result<ServerMetadata>;
|
||||
|
||||
/// Gets updates for all Warp Drive actions.
|
||||
async fn get_warp_drive_updates(
|
||||
&self,
|
||||
message_sender: Sender<ObjectUpdateMessage>,
|
||||
stream_ready_sender: Sender<()>,
|
||||
) -> Result<()>;
|
||||
|
||||
async fn fetch_changed_objects(
|
||||
&self,
|
||||
objects_to_update: ObjectsToUpdate,
|
||||
force_refresh: bool,
|
||||
) -> Result<InitialLoadResponse>;
|
||||
|
||||
async fn fetch_single_cloud_object(&self, id: ServerId) -> Result<GetCloudObjectResponse>;
|
||||
|
||||
// Transfers a notebook to the given owner
|
||||
async fn transfer_notebook_owner(&self, notebook_id: NotebookId, owner: Owner) -> Result<bool>;
|
||||
|
||||
async fn transfer_workflow_owner(&self, workflow_id: WorkflowId, owner: Owner) -> Result<bool>;
|
||||
|
||||
async fn transfer_generic_string_object_owner(
|
||||
&self,
|
||||
workflow_id: GenericStringObjectId,
|
||||
owner: Owner,
|
||||
) -> Result<bool>;
|
||||
|
||||
async fn trash_object(&self, id: ServerId) -> Result<bool>;
|
||||
|
||||
async fn untrash_object(&self, id: ServerId) -> Result<ObjectMetadataUpdateResult>;
|
||||
|
||||
async fn delete_object(&self, id: ServerId) -> Result<ObjectDeleteResult>;
|
||||
|
||||
async fn empty_trash(&self, owner: Owner) -> Result<ObjectDeleteResult>;
|
||||
|
||||
async fn move_object(
|
||||
&self,
|
||||
id: ServerId,
|
||||
folder_id: Option<FolderId>,
|
||||
owner: Owner,
|
||||
object_type: ObjectType,
|
||||
) -> Result<bool>;
|
||||
|
||||
async fn record_object_action(
|
||||
&self,
|
||||
id: ServerId,
|
||||
action_type: ObjectActionType,
|
||||
timestamp: DateTime<Utc>,
|
||||
data: Option<String>,
|
||||
) -> Result<ObjectActionHistory>;
|
||||
|
||||
async fn leave_object(&self, id: ServerId) -> Result<ObjectDeleteResult>;
|
||||
|
||||
async fn set_object_link_permissions(
|
||||
&self,
|
||||
object_id: ServerId,
|
||||
access_level: SharingAccessLevel,
|
||||
) -> Result<ObjectPermissionUpdateResult>;
|
||||
|
||||
async fn remove_object_link_permissions(
|
||||
&self,
|
||||
object_id: ServerId,
|
||||
) -> Result<ObjectPermissionUpdateResult>;
|
||||
|
||||
async fn add_object_guests(
|
||||
&self,
|
||||
object_id: ServerId,
|
||||
guest_emails: Vec<String>,
|
||||
access_level: AccessLevel,
|
||||
) -> Result<ObjectPermissionsUpdateData>;
|
||||
|
||||
async fn update_object_guests(
|
||||
&self,
|
||||
object_id: ServerId,
|
||||
guest_emails: Vec<String>,
|
||||
access_level: AccessLevel,
|
||||
) -> Result<ServerPermissions>;
|
||||
|
||||
async fn remove_object_guest(
|
||||
&self,
|
||||
object_id: ServerId,
|
||||
guest: GuestIdentifier,
|
||||
) -> Result<ServerPermissions>;
|
||||
|
||||
/// Fetches the last-used timestamps for all cloud environments.
|
||||
///
|
||||
/// This is derived from `CloudEnvironment.lastTaskCreated.createdAt` (not `lastTaskRunTimestamp`)
|
||||
/// so that "Last used" reflects the most recently created task.
|
||||
///
|
||||
/// Returns a map from environment UID to timestamp.
|
||||
async fn fetch_environment_last_task_run_timestamps(
|
||||
&self,
|
||||
) -> Result<HashMap<String, DateTime<Utc>>>;
|
||||
}
|
||||
use crate::ai::ambient_agents::scheduled::ScheduledAmbientAgent;
|
||||
use crate::ai::cloud_environments::AmbientAgentEnvironment;
|
||||
use crate::ai::document::ai_document_model::AIDocumentId;
|
||||
use crate::ai::execution_profiles::AIExecutionProfile;
|
||||
use crate::ai::facts::AIFact;
|
||||
use crate::ai::mcp::{MCPServer, TemplatableMCPServer};
|
||||
use crate::channel::ChannelState;
|
||||
use crate::cloud_object::model::generic_string_model::{
|
||||
GenericStringModel, GenericStringObjectId, Serializer, StringModel,
|
||||
};
|
||||
use crate::cloud_object::{
|
||||
BulkCreateCloudObjectResult, BulkCreateGenericStringObjectsRequest, CreateCloudObjectResult,
|
||||
CreateObjectRequest, CreatedCloudObject, GenericCloudObject, GenericServerObject,
|
||||
GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, ObjectIdType,
|
||||
ObjectType, ObjectsToUpdate, Owner, Revision, RevisionAndLastEditor, ServerCloudObject,
|
||||
ServerFolder, ServerMetadata, ServerNotebook, ServerObject, ServerPermissions, ServerWorkflow,
|
||||
TryFromGql as _, UpdateCloudObjectResult,
|
||||
};
|
||||
use crate::drive::folders::FolderId;
|
||||
use crate::drive::sharing::SharingAccessLevel;
|
||||
use crate::env_vars::EnvVarCollection;
|
||||
use crate::notebooks::{NotebookId, SerializedNotebook};
|
||||
use crate::server::graphql::schema::{
|
||||
action_type_to_gql_action_type, object_action_history_from_gql,
|
||||
object_update_success_to_update_result, update_generic_string_object_result_to_update_result,
|
||||
};
|
||||
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
|
||||
use crate::server::ids::{ClientId, HashableId, ServerId, ServerIdAndType, SyncId, ToServerId};
|
||||
use crate::server::server_api::ServerApi;
|
||||
use crate::server::sync_queue::SerializedModel;
|
||||
use crate::settings::Preference;
|
||||
use crate::workflows::workflow_enum::WorkflowEnum;
|
||||
use crate::workflows::WorkflowId;
|
||||
use crate::workspaces::gql_convert::object_update_message_from_gql;
|
||||
use crate::workspaces::user_profiles::UserProfileWithUID;
|
||||
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
@@ -414,7 +241,7 @@ impl ObjectClient for ServerApi {
|
||||
}
|
||||
WorkflowUpdate::WorkflowUpdateRejected(rejected) => {
|
||||
Ok(UpdateCloudObjectResult::Rejected {
|
||||
object: rejected.conflicting_workflow.try_into()?,
|
||||
object: ServerWorkflow::try_from_gql(rejected.conflicting_workflow)?,
|
||||
})
|
||||
}
|
||||
WorkflowUpdate::Unknown => Err(anyhow!("WorkflowUpdate has unknown variant")),
|
||||
@@ -662,7 +489,7 @@ impl ObjectClient for ServerApi {
|
||||
}
|
||||
NotebookUpdate::NotebookUpdateRejected(rejected) => {
|
||||
Ok(UpdateCloudObjectResult::Rejected {
|
||||
object: rejected.conflicting_notebook.try_into()?,
|
||||
object: ServerNotebook::try_from_gql(rejected.conflicting_notebook)?,
|
||||
})
|
||||
}
|
||||
NotebookUpdate::Unknown => Err(anyhow!("NotebookUpdate has unknown variant")),
|
||||
@@ -740,7 +567,9 @@ impl ObjectClient for ServerApi {
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
|
||||
match response.update_folder {
|
||||
UpdateFolderResult::UpdateFolderOutput(output) => output.update.try_into(),
|
||||
UpdateFolderResult::UpdateFolderOutput(output) => {
|
||||
object_update_success_to_update_result(output.update)
|
||||
}
|
||||
UpdateFolderResult::UserFacingError(e) => {
|
||||
Err(anyhow!(get_user_facing_error_message(e)))
|
||||
}
|
||||
@@ -767,7 +596,7 @@ impl ObjectClient for ServerApi {
|
||||
|
||||
let operation = UpdateGenericStringObject::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
response.update_generic_string_object.try_into()
|
||||
update_generic_string_object_result_to_update_result(response.update_generic_string_object)
|
||||
}
|
||||
|
||||
async fn grab_notebook_edit_access(&self, notebook_id: NotebookId) -> Result<ServerMetadata> {
|
||||
@@ -858,7 +687,7 @@ impl ObjectClient for ServerApi {
|
||||
|
||||
let subscription = GetWarpDriveUpdates::build(());
|
||||
|
||||
start_graphql_streaming_operation(
|
||||
let result = start_graphql_streaming_operation(
|
||||
&ChannelState::ws_server_url(),
|
||||
init_payload,
|
||||
subscription,
|
||||
@@ -866,12 +695,18 @@ impl ObjectClient for ServerApi {
|
||||
res.ok_or_else(|| {
|
||||
anyhow!("missing response data for message in get_warp_drive_updates")
|
||||
})
|
||||
.and_then(|data| data.warp_drive_updates.try_into())
|
||||
.and_then(|data| object_update_message_from_gql(data.warp_drive_updates))
|
||||
},
|
||||
message_sender,
|
||||
stream_ready_sender,
|
||||
self.iap_proxy_auth_header().into_iter().collect(),
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
|
||||
if let Err(err) = &result {
|
||||
self.report_ws_iap_challenge(err);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn fetch_changed_objects(
|
||||
@@ -905,17 +740,7 @@ impl ObjectClient for ServerApi {
|
||||
.map(|notebooks| {
|
||||
notebooks
|
||||
.into_iter()
|
||||
.filter_map(|notebook| {
|
||||
ServerNotebook::try_from_graphql_fields(
|
||||
ServerId::from_string_lossy(notebook.metadata.uid.inner()),
|
||||
Some(notebook.title),
|
||||
Some(notebook.data),
|
||||
notebook.ai_document_id,
|
||||
notebook.metadata.try_into().ok()?,
|
||||
notebook.permissions.try_into().ok()?,
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.filter_map(|notebook| ServerNotebook::try_from_gql(notebook).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
@@ -925,15 +750,7 @@ impl ObjectClient for ServerApi {
|
||||
.map(|workflows| {
|
||||
workflows
|
||||
.into_iter()
|
||||
.filter_map(|workflow| {
|
||||
ServerWorkflow::try_from_graphql_fields(
|
||||
ServerId::from_string_lossy(workflow.metadata.uid.inner()),
|
||||
workflow.data,
|
||||
workflow.metadata.try_into().ok()?,
|
||||
workflow.permissions.try_into().ok()?,
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.filter_map(|workflow| ServerWorkflow::try_from_gql(workflow).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
@@ -943,16 +760,7 @@ impl ObjectClient for ServerApi {
|
||||
.map(|folders| {
|
||||
folders
|
||||
.into_iter()
|
||||
.filter_map(|folder| {
|
||||
ServerFolder::try_from_graphql_fields(
|
||||
ServerId::from_string_lossy(folder.metadata.uid.inner()),
|
||||
Some(folder.name),
|
||||
folder.metadata.try_into().ok()?,
|
||||
folder.permissions.try_into().ok()?,
|
||||
folder.is_warp_pack,
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.filter_map(|folder| ServerFolder::try_from_gql(folder).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
@@ -960,122 +768,75 @@ impl ObjectClient for ServerApi {
|
||||
let mut updated_generic_string_objects = HashMap::new();
|
||||
if let Some(objects) = output.generic_string_objects {
|
||||
for gso in objects {
|
||||
let uid = gso.metadata.uid.inner().to_string();
|
||||
let server_id = ServerId::from_string_lossy(&uid);
|
||||
|
||||
let metadata = match ServerMetadata::try_from(gso.metadata) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(err) => {
|
||||
report_error!(err.context(format!(
|
||||
"Failed to convert metadata for GSO {:?} {uid}",
|
||||
gso.format
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let permissions = match ServerPermissions::try_from(gso.permissions) {
|
||||
Ok(permissions) => permissions,
|
||||
Err(err) => {
|
||||
report_error!(err.context(format!(
|
||||
"Failed to convert permissions for GSO {:?} {uid}",
|
||||
gso.format
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match gso.format {
|
||||
galaxy_graphql::generic_string_object::GenericStringObjectFormat::JsonEnvVarCollection => {
|
||||
parse_server_gso::<EnvVarCollection, JsonSerializer>(
|
||||
&mut updated_generic_string_objects,
|
||||
GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection),
|
||||
server_id,
|
||||
metadata,
|
||||
permissions,
|
||||
gso.serialized_model,
|
||||
gso,
|
||||
);
|
||||
}
|
||||
galaxy_graphql::generic_string_object::GenericStringObjectFormat::JsonPreference => {
|
||||
parse_server_gso::<Preference, JsonSerializer>(
|
||||
&mut updated_generic_string_objects,
|
||||
GenericStringObjectFormat::Json(JsonObjectType::Preference),
|
||||
server_id,
|
||||
metadata,
|
||||
permissions,
|
||||
gso.serialized_model,
|
||||
gso,
|
||||
);
|
||||
}
|
||||
galaxy_graphql::generic_string_object::GenericStringObjectFormat::JsonWorkflowEnum => {
|
||||
parse_server_gso::<WorkflowEnum, JsonSerializer>(
|
||||
&mut updated_generic_string_objects,
|
||||
GenericStringObjectFormat::Json(JsonObjectType::WorkflowEnum),
|
||||
server_id,
|
||||
metadata,
|
||||
permissions,
|
||||
gso.serialized_model,
|
||||
gso,
|
||||
);
|
||||
}
|
||||
galaxy_graphql::generic_string_object::GenericStringObjectFormat::JsonAIFact => {
|
||||
parse_server_gso::<AIFact, JsonSerializer>(
|
||||
&mut updated_generic_string_objects,
|
||||
GenericStringObjectFormat::Json(JsonObjectType::AIFact),
|
||||
server_id,
|
||||
metadata,
|
||||
permissions,
|
||||
gso.serialized_model,
|
||||
gso,
|
||||
);
|
||||
}
|
||||
galaxy_graphql::generic_string_object::GenericStringObjectFormat::JsonMCPServer => {
|
||||
parse_server_gso::<MCPServer, JsonSerializer>(
|
||||
&mut updated_generic_string_objects,
|
||||
GenericStringObjectFormat::Json(JsonObjectType::MCPServer),
|
||||
server_id,
|
||||
metadata,
|
||||
permissions,
|
||||
gso.serialized_model,
|
||||
gso,
|
||||
);
|
||||
}
|
||||
galaxy_graphql::generic_string_object::GenericStringObjectFormat::JsonAIExecutionProfile => {
|
||||
parse_server_gso::<AIExecutionProfile, JsonSerializer>(
|
||||
&mut updated_generic_string_objects,
|
||||
GenericStringObjectFormat::Json(JsonObjectType::AIExecutionProfile),
|
||||
server_id,
|
||||
metadata,
|
||||
permissions,
|
||||
gso.serialized_model,
|
||||
gso,
|
||||
);
|
||||
}
|
||||
galaxy_graphql::generic_string_object::GenericStringObjectFormat::JsonTemplatableMCPServer => {
|
||||
parse_server_gso::<TemplatableMCPServer, JsonSerializer>(
|
||||
&mut updated_generic_string_objects,
|
||||
GenericStringObjectFormat::Json(JsonObjectType::TemplatableMCPServer),
|
||||
server_id,
|
||||
metadata,
|
||||
permissions,
|
||||
gso.serialized_model,
|
||||
gso,
|
||||
);
|
||||
}
|
||||
galaxy_graphql::generic_string_object::GenericStringObjectFormat::JsonCloudEnvironment => {
|
||||
parse_server_gso::<AmbientAgentEnvironment, JsonSerializer>(
|
||||
&mut updated_generic_string_objects,
|
||||
GenericStringObjectFormat::Json(JsonObjectType::CloudEnvironment),
|
||||
server_id,
|
||||
metadata,
|
||||
permissions,
|
||||
gso.serialized_model,
|
||||
gso,
|
||||
);
|
||||
}
|
||||
galaxy_graphql::generic_string_object::GenericStringObjectFormat::JsonScheduledAmbientAgent => {
|
||||
parse_server_gso::<ScheduledAmbientAgent, JsonSerializer>(
|
||||
&mut updated_generic_string_objects,
|
||||
GenericStringObjectFormat::Json(JsonObjectType::ScheduledAmbientAgent),
|
||||
server_id,
|
||||
metadata,
|
||||
permissions,
|
||||
gso.serialized_model,
|
||||
gso,
|
||||
);
|
||||
}
|
||||
// GSO formats unknown to this client build (e.g. the
|
||||
// server-only `JsonRunner`) are skipped so syncing of
|
||||
// known objects still succeeds instead of failing to
|
||||
// decode the whole response.
|
||||
warp_graphql::generic_string_object::GenericStringObjectFormat::Unknown => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1135,7 +896,7 @@ impl ObjectClient for ServerApi {
|
||||
.map(|histories| {
|
||||
histories
|
||||
.into_iter()
|
||||
.filter_map(|history| history.try_into().ok())
|
||||
.filter_map(|history| object_action_history_from_gql(history).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
@@ -1184,7 +945,7 @@ impl ObjectClient for ServerApi {
|
||||
.map(|histories| {
|
||||
histories
|
||||
.into_iter()
|
||||
.filter_map(|history| history.try_into().ok())
|
||||
.filter_map(|history| object_action_history_from_gql(history).ok())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
@@ -1397,7 +1158,7 @@ impl ObjectClient for ServerApi {
|
||||
) -> Result<ObjectActionHistory> {
|
||||
let variables = RecordObjectActionVariables {
|
||||
input: RecordObjectActionInput {
|
||||
action: action_type.into(),
|
||||
action: action_type_to_gql_action_type(action_type),
|
||||
json_data: data,
|
||||
timestamp: timestamp.into(),
|
||||
uid: id.into(),
|
||||
@@ -1408,7 +1169,9 @@ impl ObjectClient for ServerApi {
|
||||
let operation = RecordObjectAction::build(variables);
|
||||
let response = self.send_graphql_request(operation, None).await?;
|
||||
match response.record_object_action {
|
||||
RecordObjectActionResult::RecordObjectActionOutput(output) => output.history.try_into(),
|
||||
RecordObjectActionResult::RecordObjectActionOutput(output) => {
|
||||
object_action_history_from_gql(output.history)
|
||||
}
|
||||
RecordObjectActionResult::UserFacingError(e) => {
|
||||
Err(anyhow!(get_user_facing_error_message(e)))
|
||||
}
|
||||
@@ -1635,17 +1398,15 @@ impl ObjectClient for ServerApi {
|
||||
fn parse_server_gso<T, S>(
|
||||
map: &mut HashMap<GenericStringObjectFormat, Vec<Box<dyn ServerObject>>>,
|
||||
format: GenericStringObjectFormat,
|
||||
uid: ServerId,
|
||||
metadata: ServerMetadata,
|
||||
permissions: ServerPermissions,
|
||||
serialized_model: String,
|
||||
gso: warp_graphql::generic_string_object::GenericStringObject,
|
||||
) where
|
||||
T: StringModel<
|
||||
CloudObjectType = GenericCloudObject<GenericStringObjectId, GenericStringModel<T, S>>,
|
||||
>,
|
||||
S: Serializer<T>,
|
||||
{
|
||||
match GenericServerObject::<GenericStringObjectId, GenericStringModel<T, S>>::try_from_graphql_fields(uid, Some(serialized_model), metadata, permissions)
|
||||
let uid = ServerId::from_string_lossy(gso.metadata.uid.inner());
|
||||
match GenericServerObject::<GenericStringObjectId, GenericStringModel<T, S>>::try_from_gql(gso)
|
||||
{
|
||||
Ok(object) => {
|
||||
map.entry(format).or_default().push(Box::new(object));
|
||||
|
||||
@@ -1,48 +1,44 @@
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::future::Future;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use async_stream::try_stream;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use bytes::Bytes;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crc::{Crc, CRC_32_ISCSI};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use futures_lite::io::AsyncReadExt as _;
|
||||
use thiserror::Error;
|
||||
pub use warp_server_client::HttpStatusError;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
#[cfg(feature = "local_fs")]
|
||||
use super::ai::FileArtifactUploadTargetInfo;
|
||||
use super::harness_support::UploadTarget;
|
||||
|
||||
/// Typed error for HTTP-backed operations so downstream classifiers (e.g. the agent-SDK
|
||||
/// retry helper) can decide transient vs permanent failures without string-parsing the
|
||||
/// anyhow Display.
|
||||
///
|
||||
/// Emitted as the source cause of an upload failure; callers typically also attach a
|
||||
/// human-facing context message via `.context(...)` so `err.to_string()` remains useful.
|
||||
#[derive(Debug, Error)]
|
||||
#[error("HTTP request failed with status {status}: {body}")]
|
||||
pub struct HttpStatusError {
|
||||
pub status: u16,
|
||||
pub body: String,
|
||||
}
|
||||
use super::harness_support::{UploadFieldValue, UploadTarget};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
static CRC32C: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
|
||||
pub(crate) static CRC32C: Crc<u32> = Crc::<u32>::new(&CRC_32_ISCSI);
|
||||
const CONTENT_LENGTH_HEADER_NAME: &str = "content-length";
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
#[cfg(feature = "local_fs")]
|
||||
const FILE_UPLOAD_CHUNK_SIZE: usize = 64 * 1024;
|
||||
|
||||
struct NormalizedUploadTarget<'a> {
|
||||
url: &'a str,
|
||||
method: &'a str,
|
||||
headers: Vec<(&'a str, &'a str)>,
|
||||
fields: Vec<NormalizedField<'a>>,
|
||||
}
|
||||
|
||||
/// Borrowed view of a single multipart form field.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct NormalizedField<'a> {
|
||||
name: &'a str,
|
||||
value: NormalizedFieldValue<'a>,
|
||||
}
|
||||
|
||||
/// Borrowed view of a single multipart form field value.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum NormalizedFieldValue<'a> {
|
||||
Static(&'a str),
|
||||
ContentCrc32C,
|
||||
ContentData,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a UploadTarget> for NormalizedUploadTarget<'a> {
|
||||
@@ -55,11 +51,25 @@ impl<'a> From<&'a UploadTarget> for NormalizedUploadTarget<'a> {
|
||||
.iter()
|
||||
.map(|(name, value)| (name.as_str(), value.as_str()))
|
||||
.collect(),
|
||||
fields: target
|
||||
.fields
|
||||
.iter()
|
||||
.map(|field| NormalizedField {
|
||||
name: field.name.as_str(),
|
||||
value: match &field.value {
|
||||
UploadFieldValue::Static { value } => {
|
||||
NormalizedFieldValue::Static(value.as_str())
|
||||
}
|
||||
UploadFieldValue::ContentCrc32C => NormalizedFieldValue::ContentCrc32C,
|
||||
UploadFieldValue::ContentData => NormalizedFieldValue::ContentData,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl<'a> From<&'a FileArtifactUploadTargetInfo> for NormalizedUploadTarget<'a> {
|
||||
fn from(target: &'a FileArtifactUploadTargetInfo) -> Self {
|
||||
Self {
|
||||
@@ -70,13 +80,124 @@ impl<'a> From<&'a FileArtifactUploadTargetInfo> for NormalizedUploadTarget<'a> {
|
||||
.iter()
|
||||
.map(|header| (header.name.as_str(), header.value.as_str()))
|
||||
.collect(),
|
||||
fields: target
|
||||
.fields
|
||||
.iter()
|
||||
.map(|field| NormalizedField {
|
||||
name: field.name.as_str(),
|
||||
value: match &field.value {
|
||||
UploadFieldValue::Static { value } => {
|
||||
NormalizedFieldValue::Static(value.as_str())
|
||||
}
|
||||
UploadFieldValue::ContentCrc32C => NormalizedFieldValue::ContentCrc32C,
|
||||
UploadFieldValue::ContentData => NormalizedFieldValue::ContentData,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
#[derive(Clone)]
|
||||
struct SharedChecksumState(Arc<Mutex<Option<crc::Digest<'static, u32>>>>);
|
||||
/// A source of bytes to upload to a presigned upload target.
|
||||
///
|
||||
/// This abstracts over the requirements for file uploads, such as knowing
|
||||
/// the payload size, so that we can avoid loading entire files into memory
|
||||
/// wherever possible.
|
||||
///
|
||||
/// It also allows the upload target implementation to skip work that's
|
||||
/// not needed for a particular target. For example, AWS S3 requires that
|
||||
/// the client provide a checksum ahead of time, while GCS does not.
|
||||
pub trait UploadBody {
|
||||
/// Total length of the body in bytes. Used for the `Content-Length`
|
||||
/// header on PUT uploads and for the multipart `ContentData` part length.
|
||||
fn length(&self) -> u64;
|
||||
|
||||
/// Base64-encoded big-endian CRC32C of the body content.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn compute_crc32c_base64(&self) -> impl Future<Output = Result<String>> + Send;
|
||||
|
||||
/// Consume this body and produce a `reqwest::Body` for the upload
|
||||
/// request.
|
||||
fn into_reqwest_body(self) -> impl Future<Output = Result<reqwest::Body>> + Send;
|
||||
}
|
||||
|
||||
/// In-memory implementation of [`UploadBody`].
|
||||
impl UploadBody for Vec<u8> {
|
||||
fn length(&self) -> u64 {
|
||||
self.len() as u64
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
async fn compute_crc32c_base64(&self) -> Result<String> {
|
||||
Ok(encode_crc32c_base64(CRC32C.checksum(self)))
|
||||
}
|
||||
|
||||
async fn into_reqwest_body(self) -> Result<reqwest::Body> {
|
||||
Ok(reqwest::Body::from(self))
|
||||
}
|
||||
}
|
||||
|
||||
/// `UploadBody` implementation backed by a file on disk. Used for streaming
|
||||
/// artifact uploads without buffering the file content in memory.
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileUploadBody {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl FileUploadBody {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self { path }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
impl UploadBody for FileUploadBody {
|
||||
fn length(&self) -> u64 {
|
||||
// Read metadata on demand so callers don't have to keep a pre-computed
|
||||
// size in sync with the file on disk. Returning 0 on a stat failure is
|
||||
// safe: reqwest will still stream the actual bytes, and any PUT paths
|
||||
// that use this for `Content-Length` already tolerate unknown lengths.
|
||||
std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
async fn compute_crc32c_base64(&self) -> Result<String> {
|
||||
use std::io::Read as _;
|
||||
let path = self.path.clone();
|
||||
|
||||
// Compute the checksum as a blocking task for 2 reasons:
|
||||
// - Filesystem operations generally aren't natively async to begin with
|
||||
// - Checksum calculation is CPU-bound, and shouldn't tie up an async worker thread
|
||||
tokio::task::spawn_blocking(move || -> Result<String> {
|
||||
let mut file = std::fs::File::open(&path)
|
||||
.with_context(|| format!("Failed to open artifact file '{}'", path.display()))?;
|
||||
let mut digest = CRC32C.digest();
|
||||
let mut buf = vec![0u8; FILE_UPLOAD_CHUNK_SIZE];
|
||||
loop {
|
||||
let n = file.read(&mut buf).with_context(|| {
|
||||
format!("Failed to read artifact file '{}'", path.display())
|
||||
})?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buf[..n]);
|
||||
}
|
||||
Ok(encode_crc32c_base64(digest.finalize()))
|
||||
})
|
||||
.await
|
||||
.context("CRC32C computation task failed to join")?
|
||||
}
|
||||
|
||||
async fn into_reqwest_body(self) -> Result<reqwest::Body> {
|
||||
let file = tokio::fs::File::open(&self.path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to open artifact file '{}'", self.path.display()))?;
|
||||
Ok(reqwest::Body::wrap_stream(
|
||||
tokio_util::io::ReaderStream::new(file),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
struct UploadErrorContext {
|
||||
@@ -84,38 +205,9 @@ struct UploadErrorContext {
|
||||
failure: &'static str,
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
impl SharedChecksumState {
|
||||
fn new() -> Self {
|
||||
Self(Arc::new(Mutex::new(Some(CRC32C.digest()))))
|
||||
}
|
||||
|
||||
fn update(&self, bytes: &[u8]) {
|
||||
if bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut digest = self.0.lock().expect("checksum state mutex poisoned");
|
||||
digest
|
||||
.as_mut()
|
||||
.expect("checksum already finalized")
|
||||
.update(bytes);
|
||||
}
|
||||
|
||||
fn finalize(&self) -> Result<String> {
|
||||
let digest = self
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("checksum state mutex poisoned"))?
|
||||
.take()
|
||||
.ok_or_else(|| anyhow!("checksum already finalized"))?;
|
||||
Ok(encode_crc32c_base64(digest.finalize()))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_upload_request<'a>(
|
||||
http_client: &'a http_client::Client,
|
||||
target: NormalizedUploadTarget<'_>,
|
||||
target: &NormalizedUploadTarget<'_>,
|
||||
content_length: Option<u64>,
|
||||
) -> Result<http_client::RequestBuilder<'a>> {
|
||||
let method = target.method.to_ascii_uppercase();
|
||||
@@ -132,8 +224,8 @@ fn build_upload_request<'a>(
|
||||
other => return Err(anyhow!("Unsupported HTTP method: {other}")),
|
||||
};
|
||||
|
||||
for (name, value) in target.headers {
|
||||
request = request.header(name, value);
|
||||
for (name, value) in &target.headers {
|
||||
request = request.header(*name, *value);
|
||||
}
|
||||
|
||||
if let Some(content_length) = content_length.filter(|_| !has_content_length) {
|
||||
@@ -165,12 +257,12 @@ async fn ensure_upload_succeeded(
|
||||
|
||||
async fn send_upload_request(
|
||||
http_client: &http_client::Client,
|
||||
target: NormalizedUploadTarget<'_>,
|
||||
target: &NormalizedUploadTarget<'_>,
|
||||
body: impl Into<reqwest::Body>,
|
||||
content_length: Option<u64>,
|
||||
content_length: u64,
|
||||
error_context: UploadErrorContext,
|
||||
) -> Result<()> {
|
||||
let response = build_upload_request(http_client, target, content_length)?
|
||||
let response = build_upload_request(http_client, target, Some(content_length))?
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
@@ -182,19 +274,121 @@ async fn send_upload_request(
|
||||
pub(crate) async fn upload_to_target(
|
||||
http_client: &http_client::Client,
|
||||
target: &UploadTarget,
|
||||
body: impl Into<reqwest::Body>,
|
||||
body: impl UploadBody,
|
||||
) -> Result<()> {
|
||||
send_upload_request(
|
||||
http_client,
|
||||
target.into(),
|
||||
body,
|
||||
None,
|
||||
UploadErrorContext {
|
||||
transport: "Failed to upload to presigned URL",
|
||||
failure: "Upload",
|
||||
},
|
||||
)
|
||||
.await
|
||||
let normalized = NormalizedUploadTarget::from(target);
|
||||
let error_context = UploadErrorContext {
|
||||
transport: "Failed to upload to presigned URL",
|
||||
failure: "Upload",
|
||||
};
|
||||
send_upload(http_client, &normalized, body, None, error_context).await
|
||||
}
|
||||
|
||||
/// Internal dispatcher shared between [`upload_to_target`] and
|
||||
/// [`upload_file_to_target`]. Routes the request to either a plain body upload
|
||||
/// or a multipart form upload based on whether the target has form fields.
|
||||
async fn send_upload(
|
||||
http_client: &http_client::Client,
|
||||
target: &NormalizedUploadTarget<'_>,
|
||||
body: impl UploadBody,
|
||||
precomputed_crc32c: Option<String>,
|
||||
error_context: UploadErrorContext,
|
||||
) -> Result<()> {
|
||||
if target.fields.is_empty() {
|
||||
let length = body.length();
|
||||
let reqwest_body = body.into_reqwest_body().await?;
|
||||
send_upload_request(http_client, target, reqwest_body, length, error_context).await
|
||||
} else {
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
send_multipart_upload(http_client, target, body, precomputed_crc32c, error_context)
|
||||
.await
|
||||
}
|
||||
#[cfg(target_family = "wasm")]
|
||||
{
|
||||
let _ = (http_client, body, precomputed_crc32c, error_context);
|
||||
Err(anyhow!(
|
||||
"Multipart upload targets are not supported on wasm"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a `multipart/form-data` request.
|
||||
///
|
||||
/// Callers that already computed the CRC32C (for example `upload_file_to_target`,
|
||||
/// which needs it for `confirmFileArtifactUpload`) pass it through via
|
||||
/// `precomputed_crc32c` so we don't hash the body twice. Otherwise we only
|
||||
/// compute the checksum when requested by the upload target.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
async fn send_multipart_upload(
|
||||
http_client: &http_client::Client,
|
||||
target: &NormalizedUploadTarget<'_>,
|
||||
body: impl UploadBody,
|
||||
precomputed_crc32c: Option<String>,
|
||||
error_context: UploadErrorContext,
|
||||
) -> Result<()> {
|
||||
let form = build_multipart_form(target, body, precomputed_crc32c).await?;
|
||||
let request = build_upload_request(http_client, target, None)?;
|
||||
let response = request
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.context(error_context.transport)?;
|
||||
ensure_upload_succeeded(response, error_context).await
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
async fn build_multipart_form(
|
||||
target: &NormalizedUploadTarget<'_>,
|
||||
body: impl UploadBody,
|
||||
precomputed_crc32c: Option<String>,
|
||||
) -> Result<reqwest::multipart::Form> {
|
||||
use reqwest::multipart::{Form, Part};
|
||||
|
||||
let needs_crc32c = target
|
||||
.fields
|
||||
.iter()
|
||||
.any(|field| matches!(field.value, NormalizedFieldValue::ContentCrc32C));
|
||||
let crc32c_base64 = match (precomputed_crc32c, needs_crc32c) {
|
||||
(Some(crc), _) => Some(crc),
|
||||
(None, true) => Some(body.compute_crc32c_base64().await?),
|
||||
(None, false) => None,
|
||||
};
|
||||
let content_length = body.length();
|
||||
let content_body = body.into_reqwest_body().await?;
|
||||
|
||||
let mut form = Form::new();
|
||||
let mut content_body_cell = Some((content_body, content_length));
|
||||
for field in &target.fields {
|
||||
let name = field.name.to_string();
|
||||
match &field.value {
|
||||
NormalizedFieldValue::Static(value) => {
|
||||
form = form.text(name, value.to_string());
|
||||
}
|
||||
NormalizedFieldValue::ContentCrc32C => {
|
||||
let crc = crc32c_base64.as_deref().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Internal error: ContentCrc32C field '{}' requires a computed checksum",
|
||||
field.name
|
||||
)
|
||||
})?;
|
||||
form = form.text(name, crc.to_string());
|
||||
}
|
||||
NormalizedFieldValue::ContentData => {
|
||||
let (body, length) = content_body_cell.take().ok_or_else(|| {
|
||||
anyhow!("Multipart upload must contain exactly one ContentData field")
|
||||
})?;
|
||||
form = form.part(name, Part::stream_with_length(body, length));
|
||||
}
|
||||
}
|
||||
}
|
||||
if content_body_cell.is_some() {
|
||||
return Err(anyhow!(
|
||||
"Multipart upload must contain exactly one ContentData field"
|
||||
));
|
||||
}
|
||||
Ok(form)
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
@@ -204,63 +398,33 @@ fn encode_crc32c_base64(crc32c: u32) -> String {
|
||||
STANDARD.encode(crc32c.to_be_bytes())
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn file_upload_stream(
|
||||
mut file: async_fs::File,
|
||||
path: PathBuf,
|
||||
checksum: SharedChecksumState,
|
||||
) -> impl futures::Stream<Item = std::io::Result<Bytes>> + Send + 'static {
|
||||
try_stream! {
|
||||
loop {
|
||||
let mut chunk = vec![0; FILE_UPLOAD_CHUNK_SIZE];
|
||||
let bytes_read = file.read(&mut chunk).await.map_err(|err| {
|
||||
std::io::Error::other(format!(
|
||||
"Failed to read artifact file '{}': {err}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
chunk.truncate(bytes_read);
|
||||
checksum.update(&chunk);
|
||||
yield Bytes::from(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
/// Upload a file artifact. Always computes the base64 CRC32C so callers can
|
||||
/// pass it to `confirmFileArtifactUpload`.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub(crate) async fn upload_file_to_target(
|
||||
http_client: &http_client::Client,
|
||||
target: &FileArtifactUploadTargetInfo,
|
||||
path: &Path,
|
||||
file_size: u64,
|
||||
body: impl UploadBody,
|
||||
) -> Result<String> {
|
||||
let file = async_fs::File::open(path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to open artifact file '{}'", path.display()))?;
|
||||
let checksum = SharedChecksumState::new();
|
||||
let body = reqwest::Body::wrap_stream(file_upload_stream(
|
||||
file,
|
||||
path.to_path_buf(),
|
||||
checksum.clone(),
|
||||
));
|
||||
let normalized = NormalizedUploadTarget::from(target);
|
||||
let error_context = UploadErrorContext {
|
||||
transport: "Failed to upload artifact bytes",
|
||||
failure: "Artifact upload",
|
||||
};
|
||||
|
||||
send_upload_request(
|
||||
// `confirmFileArtifactUpload` always needs the CRC32C, so we always compute
|
||||
// it up front and then hand it to the dispatcher so the multipart path
|
||||
// doesn't recompute it.
|
||||
let crc32c_base64 = body.compute_crc32c_base64().await?;
|
||||
send_upload(
|
||||
http_client,
|
||||
target.into(),
|
||||
&normalized,
|
||||
body,
|
||||
Some(file_size),
|
||||
UploadErrorContext {
|
||||
transport: "Failed to upload artifact bytes",
|
||||
failure: "Artifact upload",
|
||||
},
|
||||
Some(crc32c_base64.clone()),
|
||||
error_context,
|
||||
)
|
||||
.await?;
|
||||
|
||||
checksum.finalize()
|
||||
Ok(crc32c_base64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2,11 +2,21 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
||||
use futures::executor::block_on;
|
||||
use mockito::Server;
|
||||
use mockito::{Matcher, Server};
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
use crate::server::server_api::ai::{FileArtifactUploadHeaderInfo, FileArtifactUploadTargetInfo};
|
||||
use crate::server::server_api::harness_support::{UploadField, UploadFieldValue};
|
||||
|
||||
/// Drive a future to completion on a fresh Tokio runtime. Required for tests
|
||||
/// that exercise `FileUploadBody`, which hashes the file via `spawn_blocking`
|
||||
/// and therefore needs an actual Tokio reactor - `futures::executor::block_on`
|
||||
/// works for the other tests because they never touch Tokio's blocking pool.
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn block_on_tokio<F: std::future::Future>(fut: F) -> F::Output {
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(fut)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_crc32c_base64_matches_spec_example() {
|
||||
@@ -14,18 +24,29 @@ fn encode_crc32c_base64_matches_spec_example() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_checksum_state_finalize_returns_error_when_called_twice() {
|
||||
let checksum = SharedChecksumState::new();
|
||||
checksum.update(b"artifact payload");
|
||||
fn vec_upload_body_hashes_its_buffer() {
|
||||
block_on(async {
|
||||
let body: Vec<u8> = b"artifact payload".to_vec();
|
||||
let checksum = body.compute_crc32c_base64().await.unwrap();
|
||||
assert_eq!(
|
||||
checksum,
|
||||
encode_crc32c_base64(CRC32C.checksum(b"artifact payload"))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let finalized = checksum.finalize().unwrap();
|
||||
let err = checksum.finalize().unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
finalized,
|
||||
encode_crc32c_base64(CRC32C.checksum(b"artifact payload"))
|
||||
);
|
||||
assert!(err.to_string().contains("checksum already finalized"));
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn file_upload_body_hashes_without_buffering() {
|
||||
block_on_tokio(async {
|
||||
let tempdir = tempdir().unwrap();
|
||||
let path = tempdir.path().join("artifact.bin");
|
||||
let payload = b"streamed artifact payload";
|
||||
fs::write(&path, payload).unwrap();
|
||||
let body = FileUploadBody::new(path);
|
||||
let checksum = body.compute_crc32c_base64().await.unwrap();
|
||||
assert_eq!(checksum, encode_crc32c_base64(CRC32C.checksum(payload)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -44,9 +65,10 @@ fn upload_to_target_replays_headers_for_byte_uploads() {
|
||||
url: format!("{}/upload", server.url()),
|
||||
method: "POST".to_string(),
|
||||
headers: HashMap::from([("x-test-header".to_string(), "expected-header".to_string())]),
|
||||
fields: Vec::new(),
|
||||
};
|
||||
|
||||
upload_to_target(&client, &target, "serialized body".to_string())
|
||||
upload_to_target(&client, &target, b"serialized body".to_vec())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -54,72 +76,243 @@ fn upload_to_target_replays_headers_for_byte_uploads() {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn upload_file_to_target_replays_headers_sets_content_length_and_returns_checksum() {
|
||||
block_on(async {
|
||||
let tempdir = tempdir().unwrap();
|
||||
let path = tempdir.path().join("artifact.bin");
|
||||
let body = b"artifact payload";
|
||||
let content_length = body.len().to_string();
|
||||
fs::write(&path, body).unwrap();
|
||||
// mockito::Server spins its own Tokio runtime, so we build it here (on the
|
||||
// thread's current runtime-less state) and only enter our Tokio runtime
|
||||
// when we're ready to drive the async upload.
|
||||
let tempdir = tempdir().unwrap();
|
||||
let path = tempdir.path().join("artifact.bin");
|
||||
let body = b"artifact payload";
|
||||
let content_length = body.len().to_string();
|
||||
fs::write(&path, body).unwrap();
|
||||
|
||||
let mut server = Server::new();
|
||||
let mock = server
|
||||
.mock("POST", "/upload")
|
||||
.match_header("x-test-header", "expected-header")
|
||||
.match_header("content-length", content_length.as_str())
|
||||
.match_body(body.to_vec())
|
||||
.with_status(200)
|
||||
.create();
|
||||
|
||||
let client = http_client::Client::new_for_test();
|
||||
let target = FileArtifactUploadTargetInfo {
|
||||
url: format!("{}/upload", server.url()),
|
||||
method: "POST".to_string(),
|
||||
headers: vec![FileArtifactUploadHeaderInfo {
|
||||
name: "x-test-header".to_string(),
|
||||
value: "expected-header".to_string(),
|
||||
}],
|
||||
fields: Vec::new(),
|
||||
};
|
||||
|
||||
let checksum = block_on_tokio(upload_file_to_target(
|
||||
&client,
|
||||
&target,
|
||||
FileUploadBody::new(path),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
mock.assert();
|
||||
assert_eq!(checksum, encode_crc32c_base64(CRC32C.checksum(body)));
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn upload_file_to_target_returns_status_and_body_for_failed_uploads() {
|
||||
let tempdir = tempdir().unwrap();
|
||||
let path = tempdir.path().join("artifact.bin");
|
||||
fs::write(&path, b"artifact payload").unwrap();
|
||||
|
||||
let mut server = Server::new();
|
||||
let mock = server
|
||||
.mock("PUT", "/upload")
|
||||
.with_status(403)
|
||||
.with_body("denied")
|
||||
.create();
|
||||
|
||||
let client = http_client::Client::new_for_test();
|
||||
let target = FileArtifactUploadTargetInfo {
|
||||
url: format!("{}/upload", server.url()),
|
||||
method: "PUT".to_string(),
|
||||
headers: Vec::new(),
|
||||
fields: Vec::new(),
|
||||
};
|
||||
|
||||
let err = block_on_tokio(upload_file_to_target(
|
||||
&client,
|
||||
&target,
|
||||
FileUploadBody::new(path),
|
||||
))
|
||||
.unwrap_err();
|
||||
|
||||
mock.assert();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("Artifact upload failed with status 403 Forbidden: denied"));
|
||||
}
|
||||
|
||||
/// Mockito matcher that captures the raw request body so individual fields of
|
||||
/// a multipart payload can be asserted without depending on reqwest's boundary.
|
||||
fn matches_multipart_field(name: &'static str, expected: &'static str) -> Matcher {
|
||||
// mockito matches on the body as a raw byte slice; we look for the form-data
|
||||
// disposition header and the expected value within the same part.
|
||||
Matcher::Regex(format!(
|
||||
"name=\"{name}\"\r\n\r\n{expected}\r\n",
|
||||
name = regex::escape(name),
|
||||
expected = regex::escape(expected)
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_to_target_builds_multipart_post_with_static_crc_and_data_fields() {
|
||||
block_on(async {
|
||||
let mut server = Server::new();
|
||||
let body = b"multipart artifact payload";
|
||||
let expected_crc32c = encode_crc32c_base64(CRC32C.checksum(body));
|
||||
let expected_crc32c_escaped = expected_crc32c.clone();
|
||||
|
||||
let mock = server
|
||||
.mock("POST", "/upload")
|
||||
.match_header("x-test-header", "expected-header")
|
||||
.match_header("content-length", content_length.as_str())
|
||||
.match_body(body.to_vec())
|
||||
.with_status(200)
|
||||
.match_header(
|
||||
"content-type",
|
||||
Matcher::Regex("^multipart/form-data; boundary=.+".to_string()),
|
||||
)
|
||||
.match_body(Matcher::AllOf(vec![
|
||||
matches_multipart_field("key", "presigned/object/key"),
|
||||
matches_multipart_field("Content-Type", "application/octet-stream"),
|
||||
Matcher::Regex(format!(
|
||||
r#"name="x-amz-checksum-crc32c"\r\n\r\n{crc}\r\n"#,
|
||||
crc = regex::escape(&expected_crc32c_escaped)
|
||||
)),
|
||||
Matcher::Regex(r#"name="file"[\s\S]*multipart artifact payload"#.to_string()),
|
||||
]))
|
||||
.with_status(204)
|
||||
.create();
|
||||
|
||||
let client = http_client::Client::new_for_test();
|
||||
let target = FileArtifactUploadTargetInfo {
|
||||
let target = UploadTarget {
|
||||
url: format!("{}/upload", server.url()),
|
||||
method: "POST".to_string(),
|
||||
headers: vec![FileArtifactUploadHeaderInfo {
|
||||
name: "x-test-header".to_string(),
|
||||
value: "expected-header".to_string(),
|
||||
}],
|
||||
headers: HashMap::new(),
|
||||
fields: vec![
|
||||
UploadField {
|
||||
name: "key".to_string(),
|
||||
value: UploadFieldValue::Static {
|
||||
value: "presigned/object/key".to_string(),
|
||||
},
|
||||
},
|
||||
UploadField {
|
||||
name: "Content-Type".to_string(),
|
||||
value: UploadFieldValue::Static {
|
||||
value: "application/octet-stream".to_string(),
|
||||
},
|
||||
},
|
||||
UploadField {
|
||||
name: "x-amz-checksum-crc32c".to_string(),
|
||||
value: UploadFieldValue::ContentCrc32C,
|
||||
},
|
||||
UploadField {
|
||||
name: "file".to_string(),
|
||||
value: UploadFieldValue::ContentData,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let checksum = upload_file_to_target(&client, &target, &path, body.len() as u64)
|
||||
upload_to_target(&client, &target, body.to_vec())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
mock.assert();
|
||||
assert_eq!(checksum, encode_crc32c_base64(CRC32C.checksum(body)));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_file_to_target_returns_status_and_body_for_failed_uploads() {
|
||||
fn multipart_post_requires_content_data_field() {
|
||||
block_on(async {
|
||||
let tempdir = tempdir().unwrap();
|
||||
let path = tempdir.path().join("artifact.bin");
|
||||
fs::write(&path, b"artifact payload").unwrap();
|
||||
|
||||
let mut server = Server::new();
|
||||
let mock = server
|
||||
.mock("PUT", "/upload")
|
||||
.with_status(403)
|
||||
.with_body("denied")
|
||||
.create();
|
||||
|
||||
let client = http_client::Client::new_for_test();
|
||||
let target = FileArtifactUploadTargetInfo {
|
||||
url: format!("{}/upload", server.url()),
|
||||
method: "PUT".to_string(),
|
||||
headers: Vec::new(),
|
||||
let target = UploadTarget {
|
||||
url: "https://example.com/upload".to_string(),
|
||||
method: "POST".to_string(),
|
||||
headers: HashMap::new(),
|
||||
fields: vec![UploadField {
|
||||
name: "key".to_string(),
|
||||
value: UploadFieldValue::Static {
|
||||
value: "presigned/object/key".to_string(),
|
||||
},
|
||||
}],
|
||||
};
|
||||
let normalized = NormalizedUploadTarget::from(&target);
|
||||
|
||||
let err =
|
||||
upload_file_to_target(&client, &target, &path, fs::metadata(&path).unwrap().len())
|
||||
.await
|
||||
.unwrap_err();
|
||||
let err = build_multipart_form(&normalized, b"missing content data".to_vec(), None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
mock.assert();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("Artifact upload failed with status 403 Forbidden: denied"));
|
||||
.contains("Multipart upload must contain exactly one ContentData field"));
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
#[test]
|
||||
fn upload_file_to_target_builds_multipart_post_and_returns_computed_crc32c() {
|
||||
let tempdir = tempdir().unwrap();
|
||||
let path = tempdir.path().join("artifact.bin");
|
||||
let body = b"file-backed multipart payload";
|
||||
fs::write(&path, body).unwrap();
|
||||
let expected_crc32c = encode_crc32c_base64(CRC32C.checksum(body));
|
||||
let expected_crc32c_escaped = expected_crc32c.clone();
|
||||
|
||||
let mut server = Server::new();
|
||||
let mock = server
|
||||
.mock("POST", "/upload")
|
||||
.match_header(
|
||||
"content-type",
|
||||
Matcher::Regex("^multipart/form-data; boundary=.+".to_string()),
|
||||
)
|
||||
.match_body(Matcher::AllOf(vec![
|
||||
matches_multipart_field("key", "artifact/key"),
|
||||
Matcher::Regex(format!(
|
||||
r#"name="x-amz-checksum-crc32c"\r\n\r\n{crc}\r\n"#,
|
||||
crc = regex::escape(&expected_crc32c_escaped)
|
||||
)),
|
||||
Matcher::Regex(r#"name="file"[\s\S]*file-backed multipart payload"#.to_string()),
|
||||
]))
|
||||
.with_status(204)
|
||||
.create();
|
||||
|
||||
let client = http_client::Client::new_for_test();
|
||||
let target = FileArtifactUploadTargetInfo {
|
||||
url: format!("{}/upload", server.url()),
|
||||
method: "POST".to_string(),
|
||||
headers: Vec::new(),
|
||||
fields: vec![
|
||||
UploadField {
|
||||
name: "key".to_string(),
|
||||
value: UploadFieldValue::Static {
|
||||
value: "artifact/key".to_string(),
|
||||
},
|
||||
},
|
||||
UploadField {
|
||||
name: "x-amz-checksum-crc32c".to_string(),
|
||||
value: UploadFieldValue::ContentCrc32C,
|
||||
},
|
||||
UploadField {
|
||||
name: "file".to_string(),
|
||||
value: UploadFieldValue::ContentData,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let checksum = block_on_tokio(upload_file_to_target(
|
||||
&client,
|
||||
&target,
|
||||
FileUploadBody::new(path),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
mock.assert();
|
||||
assert_eq!(checksum, expected_crc32c);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
use super::ServerApi;
|
||||
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxy_graphql::{
|
||||
mutations::send_referral_invite_emails::{
|
||||
SendReferralInviteEmails, SendReferralInviteEmailsResult, SendReferralInviteEmailsVariables,
|
||||
},
|
||||
queries::get_referral_info::{GetReferralInfo, GetReferralInfoVariables},
|
||||
};
|
||||
#[cfg(test)]
|
||||
use mockall::{automock, predicate::*};
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxy_graphql::mutations::send_referral_invite_emails::{
|
||||
SendReferralInviteEmails, SendReferralInviteEmailsResult, SendReferralInviteEmailsVariables,
|
||||
};
|
||||
use galaxy_graphql::queries::get_referral_info::{GetReferralInfo, GetReferralInfoVariables};
|
||||
|
||||
use super::ServerApi;
|
||||
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
|
||||
|
||||
/// Referral information for the logged-in user
|
||||
pub struct ReferralInfo {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
use super::ServerApi;
|
||||
use crate::auth::UserUid;
|
||||
use crate::cloud_object::CloudObjectEventEntrypoint;
|
||||
use crate::workspaces::team::{DiscoverableTeam, MembershipRole};
|
||||
use crate::workspaces::workspace::Workspace;
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
#[cfg(test)]
|
||||
use mockall::{automock, predicate::*};
|
||||
use galaxy_graphql::mutations::add_invite_link_domain_restriction::{
|
||||
AddInviteLinkDomainRestriction, AddInviteLinkDomainRestrictionInput,
|
||||
AddInviteLinkDomainRestrictionResult, AddInviteLinkDomainRestrictionVariables,
|
||||
@@ -60,12 +57,14 @@ use galaxy_graphql::queries::get_workspaces_metadata_for_user::{
|
||||
GetWorkspacesMetadataForUser, GetWorkspacesMetadataForUserVariables, PricingInfoResult,
|
||||
};
|
||||
|
||||
use super::ServerApi;
|
||||
use crate::auth::UserUid;
|
||||
use crate::cloud_object::CloudObjectEventEntrypoint;
|
||||
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
|
||||
use crate::server::ids::ServerId;
|
||||
use crate::workspaces::team::{DiscoverableTeam, MembershipRole};
|
||||
use crate::workspaces::user_workspaces::{CreateTeamResponse, WorkspacesMetadataWithPricing};
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::{automock, predicate::*};
|
||||
use crate::workspaces::workspace::Workspace;
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
@@ -167,6 +166,7 @@ pub trait TeamClient: 'static + Send + Sync {
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
||||
impl TeamClient for ServerApi {
|
||||
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true))]
|
||||
async fn workspaces_metadata(&self) -> Result<WorkspacesMetadataWithPricing> {
|
||||
let variables = GetWorkspacesMetadataForUserVariables {
|
||||
request_context: get_request_context(),
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use super::{team::TeamClient, ServerApi};
|
||||
use crate::workspaces::user_workspaces::WorkspacesMetadataResponse;
|
||||
use crate::workspaces::workspace::AiOverages;
|
||||
use anyhow::{anyhow, Result};
|
||||
use async_trait::async_trait;
|
||||
use cynic::{MutationBuilder, QueryBuilder};
|
||||
#[cfg(test)]
|
||||
use mockall::{automock, predicate::*};
|
||||
use galaxy_graphql::error::UserFacingErrorInterface;
|
||||
use galaxy_graphql::mutations::purchase_addon_credits::{
|
||||
PurchaseAddonCredits, PurchaseAddonCreditsInput, PurchaseAddonCreditsResult,
|
||||
@@ -22,11 +21,12 @@ use galaxy_graphql::queries::get_ai_overages_for_workspace::{
|
||||
GetAiOveragesForWorkspace, GetAiOveragesForWorkspaceVariables, UserResult,
|
||||
};
|
||||
|
||||
use super::team::TeamClient;
|
||||
use super::ServerApi;
|
||||
use crate::server::graphql::{get_request_context, get_user_facing_error_message};
|
||||
use crate::server::ids::ServerId;
|
||||
|
||||
#[cfg(test)]
|
||||
use mockall::{automock, predicate::*};
|
||||
use crate::workspaces::user_workspaces::WorkspacesMetadataResponse;
|
||||
use crate::workspaces::workspace::AiOverages;
|
||||
|
||||
#[cfg_attr(test, automock)]
|
||||
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
||||
|
||||
+212
-136
@@ -1,53 +1,53 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
// Re-exported from cloud_objects.
|
||||
pub use cloud_objects::cloud_object::SerializedModel;
|
||||
use derivative::Derivative;
|
||||
use galaxy_graphql::scalars::time::ServerTimestamp;
|
||||
use galaxyui::{
|
||||
r#async::FutureId, Entity, ModelContext, RequestState, RetryOption, SingletonEntity,
|
||||
};
|
||||
use http::StatusCode;
|
||||
use std::borrow::Cow;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use uuid::Uuid;
|
||||
use warp_graphql::scalars::time::ServerTimestamp;
|
||||
use warpui::r#async::FutureId;
|
||||
use warpui::{Entity, ModelContext, RequestState, RetryOption, SingletonEntity};
|
||||
|
||||
use super::{
|
||||
graphql::GraphQLError,
|
||||
ids::{ClientId, HashableId, ObjectUid, ServerId, SyncId, ToServerId},
|
||||
server_api::{auth::UserAuthenticationError, object::ObjectClient},
|
||||
};
|
||||
|
||||
use super::graphql::GraphQLError;
|
||||
use super::ids::{ClientId, HashableId, ObjectUid, ServerId, SyncId, ToServerId};
|
||||
use super::server_api::auth::UserAuthenticationError;
|
||||
use super::server_api::object::ObjectClient;
|
||||
use crate::ai::ambient_agents::scheduled::CloudScheduledAmbientAgentModel;
|
||||
use crate::ai::cloud_agent_config::CloudAgentConfigModel;
|
||||
use crate::ai::cloud_environments::CloudAmbientAgentEnvironmentModel;
|
||||
use crate::ai::execution_profiles::CloudAIExecutionProfileModel;
|
||||
use crate::ai::facts::CloudAIFactModel;
|
||||
use crate::ai::mcp::templatable::CloudTemplatableMCPServerModel;
|
||||
use crate::server::cloud_objects::update_manager::InitiatedBy;
|
||||
use crate::{
|
||||
ai::cloud_agent_config::CloudAgentConfigModel,
|
||||
ai::cloud_environments::CloudAmbientAgentEnvironmentModel,
|
||||
ai::{
|
||||
ambient_agents::scheduled::CloudScheduledAmbientAgentModel,
|
||||
execution_profiles::CloudAIExecutionProfileModel, facts::CloudAIFactModel,
|
||||
mcp::CloudMCPServerModel,
|
||||
},
|
||||
cloud_object::{
|
||||
model::{
|
||||
actions::{ObjectAction, ObjectActionHistory, ObjectActionSubtype, ObjectActionType},
|
||||
generic_string_model::GenericStringObjectId,
|
||||
},
|
||||
BulkCreateCloudObjectResult, BulkCreateGenericStringObjectsRequest, CloudModelType,
|
||||
CloudObject, CloudObjectEventEntrypoint, CreateCloudObjectResult, CreateObjectRequest,
|
||||
GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey,
|
||||
JsonObjectType, ObjectType, Owner, Revision, RevisionAndLastEditor, ServerCloudObject,
|
||||
ServerCreationInfo, UpdateCloudObjectResult,
|
||||
},
|
||||
drive::{folders::CloudFolderModel, CloudObjectTypeAndId},
|
||||
env_vars::CloudEnvVarCollectionModel,
|
||||
notebooks::CloudNotebookModel,
|
||||
settings::cloud_preferences::CloudPreferenceModel,
|
||||
workflows::{workflow_enum::CloudWorkflowEnumModel, CloudWorkflowModel},
|
||||
use crate::ai::mcp::CloudMCPServerModel;
|
||||
use crate::cloud_object::model::actions::{
|
||||
ObjectAction, ObjectActionHistory, ObjectActionSubtype, ObjectActionType,
|
||||
};
|
||||
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
|
||||
use crate::cloud_object::{
|
||||
BulkCreateCloudObjectResult, BulkCreateGenericStringObjectsRequest, CloudModelType,
|
||||
CloudObject, CloudObjectEventEntrypoint, CreateCloudObjectResult, CreateObjectRequest,
|
||||
GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType,
|
||||
ObjectType, Owner, Revision, RevisionAndLastEditor, ServerCloudObject, ServerCreationInfo,
|
||||
UpdateCloudObjectResult,
|
||||
};
|
||||
use crate::drive::folders::CloudFolderModel;
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::env_vars::CloudEnvVarCollectionModel;
|
||||
use crate::notebooks::CloudNotebookModel;
|
||||
use crate::server::cloud_objects::update_manager::InitiatedBy;
|
||||
use crate::settings::cloud_preferences::CloudPreferenceModel;
|
||||
use crate::workflows::workflow_enum::CloudWorkflowEnumModel;
|
||||
use crate::workflows::CloudWorkflowModel;
|
||||
|
||||
lazy_static! {
|
||||
static ref DEFAULT_RETRY_OPTION: RetryOption =
|
||||
@@ -93,30 +93,6 @@ enum UpdateResponseType {
|
||||
Rejected { object: Box<ServerCloudObject> },
|
||||
}
|
||||
|
||||
// A newtype for a serialized model that wraps a plain string.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SerializedModel(String);
|
||||
|
||||
impl SerializedModel {
|
||||
pub fn new(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
|
||||
pub fn model_as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn take(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for SerializedModel {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub struct GenericStringObjectToCreate {
|
||||
pub id: ClientId,
|
||||
@@ -138,6 +114,15 @@ impl QueueItemId {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum QueueDependency {
|
||||
QueueItem(QueueItemId),
|
||||
BulkCreateGenericStringObject {
|
||||
queue_item_id: QueueItemId,
|
||||
client_id: ClientId,
|
||||
},
|
||||
}
|
||||
|
||||
/// Item that sync queue supports.
|
||||
#[derive(Derivative, Debug)]
|
||||
#[derivative(PartialEq, Eq, Clone)]
|
||||
@@ -170,7 +155,7 @@ pub enum QueueItem {
|
||||
objects: Vec<GenericStringObjectToCreate>,
|
||||
},
|
||||
// Note, we continue to do UpdateXXX items per object type here
|
||||
// beause it's the most type safe way to handle the different id types
|
||||
// because it's the most type safe way to handle the different id types
|
||||
// and the different update payloads. Most of the logic is still shared
|
||||
// via a single update_object method.
|
||||
UpdateNotebook {
|
||||
@@ -360,6 +345,7 @@ pub enum SyncQueueEvent {
|
||||
pub struct SyncQueue {
|
||||
queue: Vec<(QueueItemId, QueueItem)>,
|
||||
waiting_response: HashMap<String, HashSet<QueueItemId>>,
|
||||
in_flight_bulk_create_objects: HashMap<QueueItemId, HashSet<ClientId>>,
|
||||
object_client: Arc<dyn ObjectClient>,
|
||||
client_id_to_server: HashMap<ClientId, String>,
|
||||
server_id_to_client_hash: HashMap<String, String>,
|
||||
@@ -368,8 +354,8 @@ pub struct SyncQueue {
|
||||
// calls to `ctx#spawn` have finished before asserting.
|
||||
spawned_futures: Vec<FutureId>,
|
||||
|
||||
// For each QueueItem, store the set of QueueItems it depends upon finishing first
|
||||
queue_dependencies: HashMap<QueueItemId, HashSet<QueueItemId>>,
|
||||
// For each QueueItem, store the set of queue dependencies it depends upon finishing first.
|
||||
queue_dependencies: HashMap<QueueItemId, HashSet<QueueDependency>>,
|
||||
}
|
||||
|
||||
impl SyncQueue {
|
||||
@@ -395,6 +381,7 @@ impl SyncQueue {
|
||||
.map(|queue_item| (QueueItemId::new(), queue_item))
|
||||
.collect(),
|
||||
waiting_response: Default::default(),
|
||||
in_flight_bulk_create_objects: Default::default(),
|
||||
object_client,
|
||||
client_id_to_server: Default::default(),
|
||||
server_id_to_client_hash: Default::default(),
|
||||
@@ -424,6 +411,7 @@ impl SyncQueue {
|
||||
pub fn clear(&mut self) {
|
||||
self.queue.clear();
|
||||
self.waiting_response.clear();
|
||||
self.in_flight_bulk_create_objects.clear();
|
||||
self.queue_dependencies.clear();
|
||||
self.client_id_to_server.clear();
|
||||
self.server_id_to_client_hash.clear();
|
||||
@@ -454,14 +442,18 @@ impl SyncQueue {
|
||||
) {
|
||||
let mut dependencies = match item {
|
||||
// Update requests will depend on any existing create/updates to the same object
|
||||
QueueItem::UpdateNotebook { id, .. } => self.get_update_dependencies(id),
|
||||
QueueItem::UpdateFolder { id, .. } => self.get_update_dependencies(id),
|
||||
QueueItem::UpdateCloudPreferences { id, .. } => self.get_update_dependencies(id),
|
||||
QueueItem::UpdateEnvVarCollection { id, .. } => self.get_update_dependencies(id),
|
||||
QueueItem::UpdateWorkflowEnum { id, .. } => self.get_update_dependencies(id),
|
||||
QueueItem::UpdateCloudEnvironment { id, .. } => self.get_update_dependencies(id),
|
||||
QueueItem::UpdateScheduledAmbientAgent { id, .. } => self.get_update_dependencies(id),
|
||||
QueueItem::UpdateCloudAgentConfig { id, .. } => self.get_update_dependencies(id),
|
||||
QueueItem::UpdateNotebook { id, .. }
|
||||
| QueueItem::UpdateFolder { id, .. }
|
||||
| QueueItem::UpdateCloudPreferences { id, .. }
|
||||
| QueueItem::UpdateEnvVarCollection { id, .. }
|
||||
| QueueItem::UpdateWorkflowEnum { id, .. }
|
||||
| QueueItem::UpdateAIFact { id, .. }
|
||||
| QueueItem::UpdateMCPServer { id, .. }
|
||||
| QueueItem::UpdateAIExecutionProfile { id, .. }
|
||||
| QueueItem::UpdateTemplatableMCPServer { id, .. }
|
||||
| QueueItem::UpdateCloudEnvironment { id, .. }
|
||||
| QueueItem::UpdateScheduledAmbientAgent { id, .. }
|
||||
| QueueItem::UpdateCloudAgentConfig { id, .. } => self.get_update_dependencies(id),
|
||||
|
||||
// Update workflow requests should depend on existing requests to that object, as well as
|
||||
// any enums or env vars they reference.
|
||||
@@ -513,12 +505,14 @@ impl SyncQueue {
|
||||
dependencies
|
||||
}
|
||||
|
||||
// Other queue item types don't have dependencies supported right now
|
||||
_ => HashSet::new(),
|
||||
// These queue item types do not have inferred dependencies.
|
||||
QueueItem::CreateObject { .. }
|
||||
| QueueItem::BulkCreateGenericStringObjects { .. }
|
||||
| QueueItem::RecordObjectAction { .. } => HashSet::new(),
|
||||
};
|
||||
|
||||
// We never want an object to be dependent on itself, which is possible when we infer dependencies on startup from SQLite
|
||||
dependencies.remove(item_id);
|
||||
dependencies.remove(&QueueDependency::QueueItem(*item_id));
|
||||
|
||||
self.queue_dependencies.insert(*item_id, dependencies);
|
||||
}
|
||||
@@ -534,7 +528,7 @@ impl SyncQueue {
|
||||
}
|
||||
|
||||
/// Given an object ID, return the set of queue IDs of all queue items that operate on that object
|
||||
fn get_items_with_object_id(&self, item_id: String) -> HashSet<QueueItemId> {
|
||||
fn get_items_with_object_id(&self, item_id: String) -> HashSet<QueueDependency> {
|
||||
// Get a list of in flight QueueItems that operate on objects with `item_id`
|
||||
// We still want to map these in our dependencies, so that when they succeed or fail, we can respond accordingly
|
||||
// even if a subsequent request was enqueued after the relevant request was dequeued and is already in flight
|
||||
@@ -543,39 +537,75 @@ impl SyncQueue {
|
||||
.get(&item_id)
|
||||
.into_iter()
|
||||
.flat_map(|dependencies| dependencies.iter())
|
||||
.copied();
|
||||
.copied()
|
||||
.map(|queue_item_id| self.dependency_for_queue_item_id(queue_item_id, &item_id));
|
||||
|
||||
self.queue
|
||||
.iter()
|
||||
.filter(|(_, queue_item)| match queue_item {
|
||||
QueueItem::CreateObject { id, .. } => id.to_string() == item_id,
|
||||
QueueItem::CreateWorkflow { id, .. } => id.to_string() == item_id,
|
||||
QueueItem::BulkCreateGenericStringObjects { objects, .. } => {
|
||||
objects.iter().any(|data| data.id.to_string() == item_id)
|
||||
.filter_map(|(queue_item_id, queue_item)| match queue_item {
|
||||
QueueItem::CreateObject { id, .. } if id.to_string() == item_id => {
|
||||
Some(QueueDependency::QueueItem(*queue_item_id))
|
||||
}
|
||||
QueueItem::UpdateCloudPreferences { id, .. } => id.uid() == item_id,
|
||||
QueueItem::UpdateNotebook { id, .. } => id.uid() == item_id,
|
||||
QueueItem::UpdateWorkflow { id, .. } => id.uid() == item_id,
|
||||
QueueItem::UpdateFolder { id, .. } => id.uid() == item_id,
|
||||
QueueItem::UpdateEnvVarCollection { id, .. } => id.uid() == item_id,
|
||||
QueueItem::UpdateWorkflowEnum { id, .. } => id.uid() == item_id,
|
||||
QueueItem::UpdateAIFact { id, .. } => id.uid() == item_id,
|
||||
QueueItem::UpdateMCPServer { id, .. } => id.uid() == item_id,
|
||||
QueueItem::UpdateAIExecutionProfile { id, .. } => id.uid() == item_id,
|
||||
QueueItem::UpdateTemplatableMCPServer { id, .. } => id.uid() == item_id,
|
||||
QueueItem::UpdateCloudEnvironment { id, .. } => id.uid() == item_id,
|
||||
QueueItem::UpdateScheduledAmbientAgent { id, .. } => id.uid() == item_id,
|
||||
QueueItem::UpdateCloudAgentConfig { id, .. } => id.uid() == item_id,
|
||||
// We don't depend on object actions, since they don't affect an object's own content or metadata
|
||||
QueueItem::RecordObjectAction { .. } => false,
|
||||
QueueItem::CreateWorkflow { id, .. } if id.to_string() == item_id => {
|
||||
Some(QueueDependency::QueueItem(*queue_item_id))
|
||||
}
|
||||
QueueItem::BulkCreateGenericStringObjects { objects, .. } => {
|
||||
objects.iter().find_map(|data| {
|
||||
(data.id.to_string() == item_id).then_some(
|
||||
QueueDependency::BulkCreateGenericStringObject {
|
||||
queue_item_id: *queue_item_id,
|
||||
client_id: data.id,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
QueueItem::UpdateCloudPreferences { id, .. }
|
||||
| QueueItem::UpdateNotebook { id, .. }
|
||||
| QueueItem::UpdateWorkflow { id, .. }
|
||||
| QueueItem::UpdateFolder { id, .. }
|
||||
| QueueItem::UpdateEnvVarCollection { id, .. }
|
||||
| QueueItem::UpdateWorkflowEnum { id, .. }
|
||||
| QueueItem::UpdateAIFact { id, .. }
|
||||
| QueueItem::UpdateMCPServer { id, .. }
|
||||
| QueueItem::UpdateAIExecutionProfile { id, .. }
|
||||
| QueueItem::UpdateTemplatableMCPServer { id, .. }
|
||||
| QueueItem::UpdateCloudEnvironment { id, .. }
|
||||
| QueueItem::UpdateScheduledAmbientAgent { id, .. }
|
||||
| QueueItem::UpdateCloudAgentConfig { id, .. }
|
||||
if id.uid() == item_id =>
|
||||
{
|
||||
Some(QueueDependency::QueueItem(*queue_item_id))
|
||||
}
|
||||
// We don't depend on object actions, since they don't affect an object's own content or metadata.
|
||||
_ => None,
|
||||
})
|
||||
.map(|(queue_item_id, _)| *queue_item_id)
|
||||
.chain(in_flight_dependencies)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn dependency_for_queue_item_id(
|
||||
&self,
|
||||
queue_item_id: QueueItemId,
|
||||
item_id: &str,
|
||||
) -> QueueDependency {
|
||||
if let Some(client_id) = ClientId::from_hash(item_id) {
|
||||
if self
|
||||
.in_flight_bulk_create_objects
|
||||
.get(&queue_item_id)
|
||||
.is_some_and(|client_ids| client_ids.contains(&client_id))
|
||||
{
|
||||
return QueueDependency::BulkCreateGenericStringObject {
|
||||
queue_item_id,
|
||||
client_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
QueueDependency::QueueItem(queue_item_id)
|
||||
}
|
||||
|
||||
/// Get dependencies for an update request, which should be every create or update request to the same object that is already enqueued
|
||||
fn get_update_dependencies(&self, id: &SyncId) -> HashSet<QueueItemId> {
|
||||
fn get_update_dependencies(&self, id: &SyncId) -> HashSet<QueueDependency> {
|
||||
// Get all objects with the same ID as the update request
|
||||
let mut dependencies = self.get_items_with_object_id(id.uid());
|
||||
|
||||
@@ -602,7 +632,7 @@ impl SyncQueue {
|
||||
workflow_id: SyncId,
|
||||
item_id: &QueueItemId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<HashSet<QueueItemId>> {
|
||||
) -> anyhow::Result<HashSet<QueueDependency>> {
|
||||
let mut dependencies = HashSet::new();
|
||||
|
||||
let mut object_ids = workflow_model.data.get_enum_ids();
|
||||
@@ -622,7 +652,7 @@ impl SyncQueue {
|
||||
workflow_id.uid(),
|
||||
*item_id,
|
||||
// When adding the initiated_by parameter to this function call, InitiatedBy::User was set as a default value.
|
||||
// It can be changed to propogate initiated_by value from the queue object in the future if desired.
|
||||
// It can be changed to propagate initiated_by value from the queue object in the future if desired.
|
||||
InitiatedBy::User,
|
||||
ctx,
|
||||
);
|
||||
@@ -639,7 +669,18 @@ impl SyncQueue {
|
||||
fn update_items_with_new_revision(&mut self, server_id: &str, new_revision: Revision) {
|
||||
for (_item_id, item) in &mut self.queue {
|
||||
match item {
|
||||
QueueItem::UpdateNotebook { revision, id, .. } => {
|
||||
QueueItem::UpdateNotebook { id, revision, .. }
|
||||
| QueueItem::UpdateWorkflow { id, revision, .. }
|
||||
| QueueItem::UpdateCloudPreferences { id, revision, .. }
|
||||
| QueueItem::UpdateEnvVarCollection { id, revision, .. }
|
||||
| QueueItem::UpdateWorkflowEnum { id, revision, .. }
|
||||
| QueueItem::UpdateAIFact { id, revision, .. }
|
||||
| QueueItem::UpdateMCPServer { id, revision, .. }
|
||||
| QueueItem::UpdateAIExecutionProfile { id, revision, .. }
|
||||
| QueueItem::UpdateTemplatableMCPServer { id, revision, .. }
|
||||
| QueueItem::UpdateCloudEnvironment { id, revision, .. }
|
||||
| QueueItem::UpdateScheduledAmbientAgent { id, revision, .. }
|
||||
| QueueItem::UpdateCloudAgentConfig { id, revision, .. } => {
|
||||
Self::maybe_update_queue_item_with_new_revision(
|
||||
&self.client_id_to_server,
|
||||
id,
|
||||
@@ -648,17 +689,11 @@ impl SyncQueue {
|
||||
&new_revision,
|
||||
);
|
||||
}
|
||||
QueueItem::UpdateWorkflow { id, revision, .. } => {
|
||||
Self::maybe_update_queue_item_with_new_revision(
|
||||
&self.client_id_to_server,
|
||||
id,
|
||||
server_id,
|
||||
revision,
|
||||
&new_revision,
|
||||
);
|
||||
}
|
||||
// TODO: should we support this for generic string objects?
|
||||
_ => {}
|
||||
QueueItem::CreateObject { .. }
|
||||
| QueueItem::CreateWorkflow { .. }
|
||||
| QueueItem::BulkCreateGenericStringObjects { .. }
|
||||
| QueueItem::UpdateFolder { .. }
|
||||
| QueueItem::RecordObjectAction { .. } => {}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -913,7 +948,7 @@ impl SyncQueue {
|
||||
} => {
|
||||
let serialized_model = serialized_model
|
||||
.as_ref()
|
||||
.map(|data| SerializedModel(data.model_as_str().to_owned()));
|
||||
.map(|data| SerializedModel::new(data.model_as_str().to_owned()));
|
||||
|
||||
self.create_object(
|
||||
object_type,
|
||||
@@ -930,6 +965,9 @@ impl SyncQueue {
|
||||
);
|
||||
}
|
||||
QueueItem::BulkCreateGenericStringObjects { owner, objects } => {
|
||||
let in_flight_client_ids = objects.iter().map(|data| data.id).collect();
|
||||
self.in_flight_bulk_create_objects
|
||||
.insert(dequeued_item_id, in_flight_client_ids);
|
||||
for data in &objects {
|
||||
self.waiting_response
|
||||
.entry(data.id.to_string())
|
||||
@@ -964,7 +1002,7 @@ impl SyncQueue {
|
||||
BulkCreateGenericStringObjectsRequest {
|
||||
id: data.id,
|
||||
format: data.format,
|
||||
serialized_model: SerializedModel(
|
||||
serialized_model: SerializedModel::new(
|
||||
data.serialized_model
|
||||
.as_ref()
|
||||
.model_as_str()
|
||||
@@ -1297,7 +1335,7 @@ impl SyncQueue {
|
||||
|
||||
// After an object is created, go through any objects dependent on them and update the associated queue items accordingly.
|
||||
me.update_dependencies_on_creation(
|
||||
&queue_item_id,
|
||||
&QueueDependency::QueueItem(queue_item_id),
|
||||
id,
|
||||
created_cloud_object.server_id_and_type.id,
|
||||
object_type,
|
||||
@@ -1500,7 +1538,7 @@ impl SyncQueue {
|
||||
dependencies.remove(&queue_item_id);
|
||||
}
|
||||
|
||||
self.handle_dependency_success(&queue_item_id);
|
||||
self.handle_dependency_success(&QueueDependency::QueueItem(queue_item_id));
|
||||
|
||||
self.update_items_with_new_revision(uid, revision_and_editor.revision.clone());
|
||||
|
||||
@@ -1520,7 +1558,7 @@ impl SyncQueue {
|
||||
if let Some(dependencies) = self.waiting_response.get_mut(&uid.clone()) {
|
||||
dependencies.remove(&queue_item_id);
|
||||
}
|
||||
self.handle_dependency_success(&queue_item_id);
|
||||
self.handle_dependency_success(&QueueDependency::QueueItem(queue_item_id));
|
||||
}
|
||||
ResponseType::Creation {
|
||||
creation_result:
|
||||
@@ -1540,7 +1578,8 @@ impl SyncQueue {
|
||||
if let Some(dependencies) = self.waiting_response.get_mut(&client_id.to_string()) {
|
||||
dependencies.remove(&queue_item_id);
|
||||
}
|
||||
self.handle_dependency_success(&queue_item_id);
|
||||
let dependency = self.creation_dependency_for_client_id(queue_item_id, client_id);
|
||||
self.handle_dependency_success(&dependency);
|
||||
|
||||
self.update_items_with_new_revision(
|
||||
&server_creation_info.server_id_and_type.id.uid(),
|
||||
@@ -1560,7 +1599,8 @@ impl SyncQueue {
|
||||
if let Some(dependencies) = self.waiting_response.get_mut(uid) {
|
||||
dependencies.remove(&queue_item_id);
|
||||
}
|
||||
self.handle_dependency_success(&queue_item_id);
|
||||
let dependency = self.creation_dependency_for_id(queue_item_id, uid);
|
||||
self.handle_dependency_success(&dependency);
|
||||
ctx.emit(SyncQueueEvent::ObjectCreationFailure {
|
||||
reason: CreationFailureReason::Denied {
|
||||
message,
|
||||
@@ -1576,7 +1616,7 @@ impl SyncQueue {
|
||||
if let Some(dependencies) = self.waiting_response.get_mut(uid) {
|
||||
dependencies.remove(&queue_item_id);
|
||||
}
|
||||
self.handle_dependency_success(&queue_item_id);
|
||||
self.handle_dependency_success(&QueueDependency::QueueItem(queue_item_id));
|
||||
ctx.emit(SyncQueueEvent::ReportObjectActionSucceeded {
|
||||
uid: uid.to_string(),
|
||||
action_timestamp,
|
||||
@@ -1623,7 +1663,8 @@ impl SyncQueue {
|
||||
if let Some(dependencies) = self.waiting_response.get_mut(id) {
|
||||
dependencies.remove(&queue_item_id);
|
||||
}
|
||||
self.handle_dependency_failure(&queue_item_id, ctx);
|
||||
let dependency = self.creation_dependency_for_id(queue_item_id, id);
|
||||
self.handle_dependency_failure(&dependency, ctx);
|
||||
}
|
||||
|
||||
fn handle_update_failure_response(
|
||||
@@ -1641,7 +1682,7 @@ impl SyncQueue {
|
||||
if let Some(dependencies) = self.waiting_response.get_mut(&updated_sync_id.uid()) {
|
||||
dependencies.remove(&queue_item_id);
|
||||
}
|
||||
self.handle_dependency_failure(&queue_item_id, ctx);
|
||||
self.handle_dependency_failure(&QueueDependency::QueueItem(queue_item_id), ctx);
|
||||
ctx.emit(SyncQueueEvent::ObjectUpdateFailure {
|
||||
id: updated_sync_id,
|
||||
})
|
||||
@@ -1657,7 +1698,7 @@ impl SyncQueue {
|
||||
if let Some(dependencies) = self.waiting_response.get_mut(&uid) {
|
||||
dependencies.remove(&queue_item_id);
|
||||
}
|
||||
self.handle_dependency_failure(&queue_item_id, ctx);
|
||||
self.handle_dependency_failure(&QueueDependency::QueueItem(queue_item_id), ctx);
|
||||
ctx.emit(SyncQueueEvent::ReportObjectActionFailed {
|
||||
uid,
|
||||
action_timestamp: timestamp,
|
||||
@@ -1706,10 +1747,45 @@ impl SyncQueue {
|
||||
}
|
||||
}
|
||||
|
||||
fn creation_dependency_for_id(
|
||||
&mut self,
|
||||
queue_item_id: QueueItemId,
|
||||
id: &str,
|
||||
) -> QueueDependency {
|
||||
if let Some(client_id) = ClientId::from_hash(id) {
|
||||
return self.creation_dependency_for_client_id(queue_item_id, client_id);
|
||||
}
|
||||
|
||||
QueueDependency::QueueItem(queue_item_id)
|
||||
}
|
||||
|
||||
fn creation_dependency_for_client_id(
|
||||
&mut self,
|
||||
queue_item_id: QueueItemId,
|
||||
client_id: ClientId,
|
||||
) -> QueueDependency {
|
||||
let Some(client_ids) = self.in_flight_bulk_create_objects.get_mut(&queue_item_id) else {
|
||||
return QueueDependency::QueueItem(queue_item_id);
|
||||
};
|
||||
|
||||
if !client_ids.remove(&client_id) {
|
||||
return QueueDependency::QueueItem(queue_item_id);
|
||||
}
|
||||
|
||||
if client_ids.is_empty() {
|
||||
self.in_flight_bulk_create_objects.remove(&queue_item_id);
|
||||
}
|
||||
|
||||
QueueDependency::BulkCreateGenericStringObject {
|
||||
queue_item_id,
|
||||
client_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a successful object creation, update any dependent objects to refer to the object's new server ID
|
||||
fn update_dependencies_on_creation(
|
||||
&mut self,
|
||||
queue_item_id: &QueueItemId,
|
||||
dependency: &QueueDependency,
|
||||
client_id: ClientId,
|
||||
server_id: ServerId,
|
||||
object_type: ObjectType,
|
||||
@@ -1723,11 +1799,11 @@ impl SyncQueue {
|
||||
for (item_id, queue_item) in self.queue.iter_mut() {
|
||||
match queue_item {
|
||||
QueueItem::CreateWorkflow { model, .. } => {
|
||||
// Only update the workflow if it depends on `queue_item_id`
|
||||
// Only update the workflow if it depends on this dependency.
|
||||
if self
|
||||
.queue_dependencies
|
||||
.get(item_id)
|
||||
.is_some_and(|deps| deps.contains(queue_item_id))
|
||||
.is_some_and(|deps| deps.contains(dependency))
|
||||
{
|
||||
let workflow_model = Arc::make_mut(model);
|
||||
workflow_model.data.replace_object_id(
|
||||
@@ -1737,11 +1813,11 @@ impl SyncQueue {
|
||||
}
|
||||
}
|
||||
QueueItem::UpdateWorkflow { model, .. } => {
|
||||
// Only update the workflow if it depends on `queue_item_id`
|
||||
// Only update the workflow if it depends on this dependency.
|
||||
if self
|
||||
.queue_dependencies
|
||||
.get(item_id)
|
||||
.is_some_and(|deps| deps.contains(queue_item_id))
|
||||
.is_some_and(|deps| deps.contains(dependency))
|
||||
{
|
||||
let workflow_model = Arc::make_mut(model);
|
||||
workflow_model.data.replace_object_id(
|
||||
@@ -1757,16 +1833,16 @@ impl SyncQueue {
|
||||
}
|
||||
|
||||
/// If a request succeeds, update the dependency map by removing it from all dependency sets
|
||||
fn handle_dependency_success(&mut self, item_id: &QueueItemId) {
|
||||
fn handle_dependency_success(&mut self, dependency: &QueueDependency) {
|
||||
for (_, dependencies) in self.queue_dependencies.iter_mut() {
|
||||
dependencies.remove(item_id);
|
||||
dependencies.remove(dependency);
|
||||
}
|
||||
}
|
||||
|
||||
/// If a request fails, emit a failure for every dependent QueueItem
|
||||
fn handle_dependency_failure(
|
||||
&mut self,
|
||||
queue_item_id: &QueueItemId,
|
||||
failed_dependency: &QueueDependency,
|
||||
ctx: &mut ModelContext<SyncQueue>,
|
||||
) {
|
||||
// Filter the queue to get a list of all items dependent on this queue item
|
||||
@@ -1776,7 +1852,7 @@ impl SyncQueue {
|
||||
.filter(|(item_id, _)| {
|
||||
self.queue_dependencies
|
||||
.get(item_id)
|
||||
.is_some_and(|dependency_set| dependency_set.contains(queue_item_id))
|
||||
.is_some_and(|dependency_set| dependency_set.contains(failed_dependency))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
@@ -1894,7 +1970,7 @@ impl SyncQueue {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn queue_dependencies(&self) -> &HashMap<QueueItemId, HashSet<QueueItemId>> {
|
||||
fn queue_dependencies(&self) -> &HashMap<QueueItemId, HashSet<QueueDependency>> {
|
||||
&self.queue_dependencies
|
||||
}
|
||||
|
||||
@@ -1911,5 +1987,5 @@ impl Entity for SyncQueue {
|
||||
impl SingletonEntity for SyncQueue {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sync_queue_test.rs"]
|
||||
#[path = "sync_queue_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
use std::collections::HashSet;
|
||||
use std::ops::Index;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use cloud_object_client::MockObjectClient;
|
||||
use cloud_objects::cloud_object::ServerPermissions;
|
||||
use firebase::FirebaseError;
|
||||
use itertools::Itertools;
|
||||
use warpui::r#async::Timer;
|
||||
use warpui::{App, Entity, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::QueueDependency;
|
||||
use crate::ai::facts::{AIFact, AIMemory, CloudAIFactModel};
|
||||
use crate::cloud_object::model::actions::{
|
||||
ObjectAction, ObjectActionHistory, ObjectActionSubtype, ObjectActionType,
|
||||
};
|
||||
@@ -7,31 +22,20 @@ use crate::cloud_object::{
|
||||
GenericStringObjectFormat, JsonObjectType, ObjectIdType, ObjectType, Owner, Revision,
|
||||
RevisionAndLastEditor, ServerCreationInfo, UpdateCloudObjectResult,
|
||||
};
|
||||
|
||||
use crate::drive::CloudObjectTypeAndId;
|
||||
use crate::notebooks::{CloudNotebookModel, NotebookId};
|
||||
use crate::server::cloud_objects::update_manager::InitiatedBy;
|
||||
use crate::server::ids::{ClientId, HashableId, ServerId, ServerIdAndType, SyncId};
|
||||
use crate::server::server_api::auth::UserAuthenticationError;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::server::sync_queue::{
|
||||
CreationFailureReason, GenericStringObjectToCreate, QueueItemId, SerializedModel,
|
||||
SyncQueueEvent,
|
||||
};
|
||||
use crate::system::SystemStats;
|
||||
use crate::workflows::workflow::{Argument, ArgumentType, Workflow};
|
||||
use crate::workflows::CloudWorkflowModel;
|
||||
use std::collections::HashSet;
|
||||
use std::ops::Index;
|
||||
|
||||
use crate::server::ids::{ClientId, HashableId, ServerId, ServerIdAndType, SyncId};
|
||||
use crate::server::sync_queue::{CreationFailureReason, QueueItemId, SyncQueueEvent};
|
||||
use crate::{NetworkStatus, QueueItem, SyncQueue};
|
||||
use anyhow::anyhow;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use firebase::FirebaseError;
|
||||
use galaxy_server_client::cloud_object::ServerPermissions;
|
||||
use galaxyui::{r#async::Timer, App, Entity, ModelHandle, SingletonEntity};
|
||||
use itertools::Itertools;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::server::server_api::object::MockObjectClient;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Events(Vec<SyncQueueEvent>);
|
||||
@@ -54,6 +58,22 @@ fn initialize_app(app: &mut App) {
|
||||
app.add_singleton_model(|_| SystemStats::new());
|
||||
}
|
||||
|
||||
fn queue_item_dependencies<const N: usize>(
|
||||
queue_item_ids: [QueueItemId; N],
|
||||
) -> HashSet<QueueDependency> {
|
||||
queue_item_ids
|
||||
.into_iter()
|
||||
.map(QueueDependency::QueueItem)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bulk_create_dependency(queue_item_id: QueueItemId, client_id: ClientId) -> QueueDependency {
|
||||
QueueDependency::BulkCreateGenericStringObject {
|
||||
queue_item_id,
|
||||
client_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_sync_queue(
|
||||
app: &mut App,
|
||||
queue_items: Vec<QueueItem>,
|
||||
@@ -115,7 +135,7 @@ fn test_create_notebook() {
|
||||
let sync_queue_events = app.add_model(|_ctx| Events::default());
|
||||
|
||||
sync_queue_events.update(&mut app, |_, ctx| {
|
||||
ctx.subscribe_to_model(&sync_queue, |me, event, _ctx| me.0.push(event.clone()))
|
||||
ctx.subscribe_to_model(&sync_queue, |me, _, event, _ctx| me.0.push(event.clone()))
|
||||
});
|
||||
|
||||
// Enqueue a single item and wait for the response to complete.
|
||||
@@ -246,7 +266,7 @@ fn test_generic_string_object_unique_key_failure() {
|
||||
|
||||
let sync_queue_events = app.add_model(|_ctx| Events::default());
|
||||
sync_queue_events.update(&mut app, |_, ctx| {
|
||||
ctx.subscribe_to_model(&sync_queue, |me, event, _ctx| me.0.push(event.clone()))
|
||||
ctx.subscribe_to_model(&sync_queue, |me, _, event, _ctx| me.0.push(event.clone()))
|
||||
});
|
||||
|
||||
// Await the first future.
|
||||
@@ -405,7 +425,7 @@ fn test_dequeue_after_transient_failure() {
|
||||
|
||||
let sync_queue_events = app.add_model(|_ctx| Events::default());
|
||||
sync_queue_events.update(&mut app, |_, ctx| {
|
||||
ctx.subscribe_to_model(&sync_queue, |me, event, _ctx| me.0.push(event.clone()))
|
||||
ctx.subscribe_to_model(&sync_queue, |me, _, event, _ctx| me.0.push(event.clone()))
|
||||
});
|
||||
|
||||
sync_queue
|
||||
@@ -555,7 +575,7 @@ fn test_no_dequeue_after_intransient_failure() {
|
||||
|
||||
let sync_queue_events = app.add_model(|_ctx| Events::default());
|
||||
sync_queue_events.update(&mut app, |_, ctx| {
|
||||
ctx.subscribe_to_model(&sync_queue, |me, event, _ctx| me.0.push(event.clone()))
|
||||
ctx.subscribe_to_model(&sync_queue, |me, _, event, _ctx| me.0.push(event.clone()))
|
||||
});
|
||||
|
||||
sync_queue
|
||||
@@ -695,7 +715,7 @@ fn test_create_and_update_notebook() {
|
||||
let events = app.add_model(|_ctx| Events::default());
|
||||
|
||||
events.update(&mut app, |_, ctx| {
|
||||
ctx.subscribe_to_model(&sync_queue, |me, event, _ctx| me.0.push(event.clone()))
|
||||
ctx.subscribe_to_model(&sync_queue, |me, _, event, _ctx| me.0.push(event.clone()))
|
||||
});
|
||||
|
||||
sync_queue
|
||||
@@ -897,7 +917,7 @@ fn test_record_object_action() {
|
||||
let sync_queue_events = app.add_model(|_ctx| Events::default());
|
||||
|
||||
sync_queue_events.update(&mut app, |_, ctx| {
|
||||
ctx.subscribe_to_model(&sync_queue, |me, event, _ctx| me.0.push(event.clone()))
|
||||
ctx.subscribe_to_model(&sync_queue, |me, _, event, _ctx| me.0.push(event.clone()))
|
||||
});
|
||||
|
||||
// Enqueue a single item and wait for the response to complete.
|
||||
@@ -1032,15 +1052,15 @@ fn test_sync_queue_dependency_successes() {
|
||||
// Assert initial state of the queue dependencies
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&create_id).unwrap(),
|
||||
&HashSet::<QueueItemId>::new()
|
||||
&HashSet::<QueueDependency>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&update_id).unwrap(),
|
||||
&HashSet::<QueueItemId>::from([create_id])
|
||||
&queue_item_dependencies([create_id])
|
||||
);
|
||||
|
||||
// Simulate success of the create request
|
||||
sync_queue.handle_dependency_success(&create_id);
|
||||
sync_queue.handle_dependency_success(&QueueDependency::QueueItem(create_id));
|
||||
|
||||
// We should no longer store a dependency on the update request
|
||||
assert_eq!(
|
||||
@@ -1075,7 +1095,7 @@ fn test_sync_queue_dependency_failure() {
|
||||
let sync_queue_events = app.add_model(|_ctx| Events::default());
|
||||
|
||||
sync_queue_events.update(&mut app, |_, ctx| {
|
||||
ctx.subscribe_to_model(&sync_queue, |me, event, _ctx| me.0.push(event.clone()))
|
||||
ctx.subscribe_to_model(&sync_queue, |me, _, event, _ctx| me.0.push(event.clone()))
|
||||
});
|
||||
|
||||
sync_queue.update(&mut app, |sync_queue, ctx| {
|
||||
@@ -1126,15 +1146,15 @@ fn test_sync_queue_dependency_failure() {
|
||||
// Assert initial state of the queue dependencies
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&create_id).unwrap(),
|
||||
&HashSet::<QueueItemId>::new()
|
||||
&HashSet::<QueueDependency>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&update_id).unwrap(),
|
||||
&HashSet::<QueueItemId>::from([create_id])
|
||||
&queue_item_dependencies([create_id])
|
||||
);
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&update_id_2).unwrap(),
|
||||
&HashSet::<QueueItemId>::from([create_id, update_id])
|
||||
&queue_item_dependencies([create_id, update_id])
|
||||
);
|
||||
|
||||
// Simulate failure of the create request
|
||||
@@ -1200,7 +1220,7 @@ fn test_sync_queue_dependency_mixed_ids() {
|
||||
let sync_queue_events = app.add_model(|_ctx| Events::default());
|
||||
|
||||
sync_queue_events.update(&mut app, |_, ctx| {
|
||||
ctx.subscribe_to_model(&sync_queue, |me, event, _ctx| me.0.push(event.clone()))
|
||||
ctx.subscribe_to_model(&sync_queue, |me, _, event, _ctx| me.0.push(event.clone()))
|
||||
});
|
||||
|
||||
sync_queue.update(&mut app, |sync_queue, ctx| {
|
||||
@@ -1236,11 +1256,11 @@ fn test_sync_queue_dependency_mixed_ids() {
|
||||
// Assert initial state of the queue dependencies
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&create_id).unwrap(),
|
||||
&HashSet::<QueueItemId>::new()
|
||||
&HashSet::<QueueDependency>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&update_id).unwrap(),
|
||||
&HashSet::<QueueItemId>::from([create_id])
|
||||
&queue_item_dependencies([create_id])
|
||||
);
|
||||
|
||||
// Simulate success of create request
|
||||
@@ -1273,7 +1293,7 @@ fn test_sync_queue_dependency_mixed_ids() {
|
||||
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&update_id).unwrap(),
|
||||
&HashSet::<QueueItemId>::new()
|
||||
&HashSet::<QueueDependency>::new()
|
||||
);
|
||||
|
||||
let update_id_2 = sync_queue.enqueue(
|
||||
@@ -1294,12 +1314,298 @@ fn test_sync_queue_dependency_mixed_ids() {
|
||||
// Even though one request uses client ID and one uses server ID, we should find a dependency here
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&update_id_2).unwrap(),
|
||||
&HashSet::<QueueItemId>::from([update_id])
|
||||
&queue_item_dependencies([update_id])
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_queue_generic_string_object_update_depends_on_pending_create() {
|
||||
let client_id = ClientId::new();
|
||||
let server_id = SyncId::ServerId(GenericStringObjectId::from(123).into());
|
||||
let revision_after_create = Revision::from(DateTime::<Utc>::default());
|
||||
let revision_after_update = Revision::from(DateTime::<Utc>::default() + Duration::minutes(1));
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let cloud_objects_client_mock = MockObjectClient::new();
|
||||
initialize_app(&mut app);
|
||||
let sync_queue = create_sync_queue(&mut app, vec![], cloud_objects_client_mock, false);
|
||||
|
||||
sync_queue.update(&mut app, |sync_queue, ctx| {
|
||||
let create_id = sync_queue.enqueue(
|
||||
QueueItem::CreateObject {
|
||||
object_type: ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
|
||||
JsonObjectType::AIFact,
|
||||
)),
|
||||
owner: Owner::mock_current_user(),
|
||||
id: client_id,
|
||||
title: None,
|
||||
serialized_model: None,
|
||||
initial_folder_id: None,
|
||||
entrypoint: CloudObjectEventEntrypoint::Unknown,
|
||||
initiated_by: InitiatedBy::User,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
let update_id = sync_queue.enqueue(
|
||||
QueueItem::UpdateAIFact {
|
||||
model: CloudAIFactModel::new(AIFact::Memory(AIMemory {
|
||||
name: Some("new rule".to_string()),
|
||||
content: "remember this".to_string(),
|
||||
is_autogenerated: false,
|
||||
suggested_logging_id: None,
|
||||
}))
|
||||
.into(),
|
||||
id: SyncId::ClientId(client_id),
|
||||
revision: Some(revision_after_create.clone()),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&create_id).unwrap(),
|
||||
&HashSet::<QueueDependency>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&update_id).unwrap(),
|
||||
&queue_item_dependencies([create_id])
|
||||
);
|
||||
|
||||
sync_queue.remove_id_from_queue(&create_id);
|
||||
sync_queue.queue_dependencies.remove(&create_id);
|
||||
sync_queue.handle_success_response(
|
||||
&server_id.uid(),
|
||||
super::ResponseType::Creation {
|
||||
creation_result: super::CreationResponseType::Success {
|
||||
client_id,
|
||||
revision_and_editor: super::RevisionAndLastEditor {
|
||||
revision: revision_after_create.clone(),
|
||||
last_editor_uid: None,
|
||||
},
|
||||
metadata_ts: DateTime::<Utc>::default().into(),
|
||||
server_creation_info: ServerCreationInfo {
|
||||
server_id_and_type: ServerIdAndType {
|
||||
id: server_id.into_server().expect("Expect server id"),
|
||||
id_type: ObjectIdType::GenericStringObject,
|
||||
},
|
||||
creator_uid: Default::default(),
|
||||
permissions: ServerPermissions::mock_personal(),
|
||||
},
|
||||
},
|
||||
},
|
||||
create_id,
|
||||
InitiatedBy::User,
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&update_id).unwrap(),
|
||||
&HashSet::<QueueDependency>::new()
|
||||
);
|
||||
|
||||
let update_id_2 = sync_queue.enqueue(
|
||||
QueueItem::UpdateAIFact {
|
||||
model: CloudAIFactModel::new(AIFact::Memory(AIMemory {
|
||||
name: Some("final rule".to_string()),
|
||||
content: "remember this too".to_string(),
|
||||
is_autogenerated: false,
|
||||
suggested_logging_id: None,
|
||||
}))
|
||||
.into(),
|
||||
id: server_id,
|
||||
revision: Some(revision_after_create),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&update_id_2).unwrap(),
|
||||
&queue_item_dependencies([update_id])
|
||||
);
|
||||
|
||||
sync_queue.remove_id_from_queue(&update_id);
|
||||
sync_queue.queue_dependencies.remove(&update_id);
|
||||
sync_queue.handle_success_response(
|
||||
&server_id.uid(),
|
||||
super::ResponseType::Update {
|
||||
update_result: super::UpdateResponseType::Success {
|
||||
revision_and_editor: super::RevisionAndLastEditor {
|
||||
revision: revision_after_update.clone(),
|
||||
last_editor_uid: None,
|
||||
},
|
||||
},
|
||||
},
|
||||
update_id,
|
||||
InitiatedBy::User,
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&update_id_2).unwrap(),
|
||||
&HashSet::<QueueDependency>::new()
|
||||
);
|
||||
let queued_revision = sync_queue.queue().iter().find_map(|(_, item)| match item {
|
||||
QueueItem::UpdateAIFact { id, revision, .. } if *id == server_id => {
|
||||
Some(revision.clone())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(queued_revision, Some(Some(revision_after_update)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync_queue_bulk_generic_string_object_update_waits_for_matching_create() {
|
||||
let client_id_a = ClientId::new();
|
||||
let client_id_b = ClientId::new();
|
||||
let server_id_a = SyncId::ServerId(GenericStringObjectId::from(123).into());
|
||||
let server_id_b = SyncId::ServerId(GenericStringObjectId::from(456).into());
|
||||
let revision_after_create_a = Revision::from(DateTime::<Utc>::default());
|
||||
let revision_after_create_b = Revision::from(DateTime::<Utc>::default() + Duration::minutes(1));
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
let cloud_objects_client_mock = MockObjectClient::new();
|
||||
initialize_app(&mut app);
|
||||
let sync_queue = create_sync_queue(&mut app, vec![], cloud_objects_client_mock, false);
|
||||
|
||||
sync_queue.update(&mut app, |sync_queue, ctx| {
|
||||
let object_to_create = |id| GenericStringObjectToCreate {
|
||||
id,
|
||||
format: GenericStringObjectFormat::Json(JsonObjectType::AIFact),
|
||||
serialized_model: Arc::new(SerializedModel::new("{}".to_string())),
|
||||
initial_folder_id: None,
|
||||
entrypoint: CloudObjectEventEntrypoint::Unknown,
|
||||
uniqueness_key: None,
|
||||
initiated_by: InitiatedBy::User,
|
||||
};
|
||||
|
||||
let bulk_create_id = sync_queue.enqueue(
|
||||
QueueItem::BulkCreateGenericStringObjects {
|
||||
owner: Owner::mock_current_user(),
|
||||
objects: vec![object_to_create(client_id_a), object_to_create(client_id_b)],
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
// Simulate the state after the bulk create has been dequeued and is waiting
|
||||
// on a single server response for both client IDs.
|
||||
sync_queue.remove_id_from_queue(&bulk_create_id);
|
||||
sync_queue.queue_dependencies.remove(&bulk_create_id);
|
||||
sync_queue
|
||||
.in_flight_bulk_create_objects
|
||||
.insert(bulk_create_id, HashSet::from([client_id_a, client_id_b]));
|
||||
for client_id in [client_id_a, client_id_b] {
|
||||
sync_queue
|
||||
.waiting_response
|
||||
.entry(client_id.to_string())
|
||||
.or_default()
|
||||
.insert(bulk_create_id);
|
||||
}
|
||||
|
||||
let update_b_id = sync_queue.enqueue(
|
||||
QueueItem::UpdateAIFact {
|
||||
model: CloudAIFactModel::new(AIFact::Memory(AIMemory {
|
||||
name: Some("updated rule".to_string()),
|
||||
content: "remember this".to_string(),
|
||||
is_autogenerated: false,
|
||||
suggested_logging_id: None,
|
||||
}))
|
||||
.into(),
|
||||
id: SyncId::ClientId(client_id_b),
|
||||
revision: Some(revision_after_create_a.clone()),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&update_b_id).unwrap(),
|
||||
&HashSet::from([bulk_create_dependency(bulk_create_id, client_id_b)])
|
||||
);
|
||||
|
||||
sync_queue.handle_success_response(
|
||||
&server_id_a.uid(),
|
||||
super::ResponseType::Creation {
|
||||
creation_result: super::CreationResponseType::Success {
|
||||
client_id: client_id_a,
|
||||
revision_and_editor: super::RevisionAndLastEditor {
|
||||
revision: revision_after_create_a,
|
||||
last_editor_uid: None,
|
||||
},
|
||||
metadata_ts: DateTime::<Utc>::default().into(),
|
||||
server_creation_info: ServerCreationInfo {
|
||||
server_id_and_type: ServerIdAndType {
|
||||
id: server_id_a.into_server().expect("Expect server id"),
|
||||
id_type: ObjectIdType::GenericStringObject,
|
||||
},
|
||||
creator_uid: Default::default(),
|
||||
permissions: ServerPermissions::mock_personal(),
|
||||
},
|
||||
},
|
||||
},
|
||||
bulk_create_id,
|
||||
InitiatedBy::User,
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&update_b_id).unwrap(),
|
||||
&HashSet::from([bulk_create_dependency(bulk_create_id, client_id_b)])
|
||||
);
|
||||
assert_eq!(
|
||||
sync_queue
|
||||
.in_flight_bulk_create_objects
|
||||
.get(&bulk_create_id)
|
||||
.unwrap(),
|
||||
&HashSet::from([client_id_b])
|
||||
);
|
||||
|
||||
sync_queue.handle_success_response(
|
||||
&server_id_b.uid(),
|
||||
super::ResponseType::Creation {
|
||||
creation_result: super::CreationResponseType::Success {
|
||||
client_id: client_id_b,
|
||||
revision_and_editor: super::RevisionAndLastEditor {
|
||||
revision: revision_after_create_b.clone(),
|
||||
last_editor_uid: None,
|
||||
},
|
||||
metadata_ts: DateTime::<Utc>::default().into(),
|
||||
server_creation_info: ServerCreationInfo {
|
||||
server_id_and_type: ServerIdAndType {
|
||||
id: server_id_b.into_server().expect("Expect server id"),
|
||||
id_type: ObjectIdType::GenericStringObject,
|
||||
},
|
||||
creator_uid: Default::default(),
|
||||
permissions: ServerPermissions::mock_personal(),
|
||||
},
|
||||
},
|
||||
},
|
||||
bulk_create_id,
|
||||
InitiatedBy::User,
|
||||
ctx,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&update_b_id).unwrap(),
|
||||
&HashSet::<QueueDependency>::new()
|
||||
);
|
||||
assert!(!sync_queue
|
||||
.in_flight_bulk_create_objects
|
||||
.contains_key(&bulk_create_id));
|
||||
let queued_revision = sync_queue.queue().iter().find_map(|(_, item)| match item {
|
||||
QueueItem::UpdateAIFact { id, revision, .. }
|
||||
if *id == SyncId::ClientId(client_id_b) =>
|
||||
{
|
||||
Some(revision.clone())
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(queued_revision, Some(Some(revision_after_create_b)));
|
||||
});
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
fn test_sync_queue_enum_dependency() {
|
||||
let enum_id_1 = ClientId::new();
|
||||
@@ -1453,7 +1759,7 @@ fn test_sync_queue_enum_dependency() {
|
||||
// Assert initial state of the queue dependencies
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&create_id).unwrap(),
|
||||
&HashSet::<QueueItemId>::from([create_enum_2])
|
||||
&queue_item_dependencies([create_enum_2])
|
||||
);
|
||||
|
||||
// Assert that the workflow that was enqueued has the enum_id that is a server id
|
||||
@@ -1477,7 +1783,7 @@ fn test_sync_queue_enum_dependency() {
|
||||
sync_queue.remove_id_from_queue(&create_enum_2);
|
||||
sync_queue.queue_dependencies.remove(&create_enum_2);
|
||||
sync_queue.update_dependencies_on_creation(
|
||||
&create_enum_2,
|
||||
&QueueDependency::QueueItem(create_enum_2),
|
||||
enum_id_2,
|
||||
enum_server_id_2.into_server().expect("Expect server id"),
|
||||
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
|
||||
@@ -1512,7 +1818,7 @@ fn test_sync_queue_enum_dependency() {
|
||||
// Assert updated state of the queue dependencies
|
||||
assert_eq!(
|
||||
sync_queue.queue_dependencies().get(&create_id).unwrap(),
|
||||
&HashSet::<QueueItemId>::new()
|
||||
&HashSet::<QueueDependency>::new()
|
||||
);
|
||||
|
||||
// Assert that the workflow was updated to reference the server ID of the second enum
|
||||
@@ -1,18 +1,90 @@
|
||||
use std::fs::remove_file;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use super::{clear_event_queue, rudder_event_file_path, RUDDER_TELEMETRY_EVENTS_FILE_NAME};
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::channel::ChannelState;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::server::server_api::ServerApi;
|
||||
use crate::settings::{PrivacySettings, PrivacySettingsChangedEvent};
|
||||
|
||||
/// No-op telemetry collector. Telemetry has been removed from Galaxy.
|
||||
// How often we send Active Usage signals.
|
||||
const ACTIVE_USAGE_DURATION: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Duration to wait before flushing the event queue to Rudderstack.
|
||||
const TELEMETRY_FLUSH_DURATION: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Max telemetry events to write to disk. This is bounded to limit the size of the file as well
|
||||
/// as latency of writing the file.
|
||||
const MAX_TELEMETRY_EVENTS_TO_STORE: usize = 20;
|
||||
|
||||
/// Maximum time to wait for the telemetry flush network request during shutdown.
|
||||
/// If the network is unavailable or slow, we don't want the CLI process to hang indefinitely.
|
||||
const TELEMETRY_SHUTDOWN_FLUSH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// App singleton responsible for scheduling periodic background tasks for sending batches of
|
||||
/// telemetry events to Rudderstack. This model respects the user's telemetry enablement setting.
|
||||
pub struct TelemetryCollector {
|
||||
_server_api: Arc<ServerApi>,
|
||||
}
|
||||
|
||||
impl TelemetryCollector {
|
||||
pub fn new(server_api: Arc<ServerApi>) -> Self {
|
||||
Self {
|
||||
_server_api: server_api,
|
||||
Self { server_api }
|
||||
}
|
||||
|
||||
pub fn initialize_telemetry_collection(&self, ctx: &mut ModelContext<TelemetryCollector>) {
|
||||
// Start a background thread to periodically flush events from the telemetry event queue.
|
||||
if ChannelState::is_release_bundle() || FeatureFlag::WithSandboxTelemetry.is_enabled() {
|
||||
// Flush the events to Rudderstack that were persisted into a file the last time the app was
|
||||
// quit.
|
||||
self.flush_persisted_events_from_disk(ctx);
|
||||
}
|
||||
|
||||
// Send Active App Usage signals
|
||||
if FeatureFlag::RecordAppActiveEvents.is_enabled()
|
||||
&& (ChannelState::is_release_bundle() || FeatureFlag::WithSandboxTelemetry.is_enabled())
|
||||
{
|
||||
self.schedule_send_active_usage_event(ctx);
|
||||
}
|
||||
|
||||
// Start a background thread to periodically flush events from the telemetry event queue.
|
||||
if ChannelState::is_release_bundle()
|
||||
|| FeatureFlag::WithSandboxTelemetry.is_enabled()
|
||||
|| FeatureFlag::SendTelemetryToFile.is_enabled()
|
||||
{
|
||||
self.schedule_event_queue_flush(ctx);
|
||||
}
|
||||
|
||||
// Clear queued telemetry events when telemetry is enabled or disabled. If telemetry is
|
||||
// enabled, we will start sending Rudderstack requests when the event queue is periodically
|
||||
// flushed. The initial request should not contain any events recorded when the user was
|
||||
// previously opted-out of telemetry. In the case where the user turns the telemetry from
|
||||
// on to off, we should not send another request with any telemetry, even if the event was
|
||||
// initially recorded prior to the user turning telemetry off.`
|
||||
ctx.subscribe_to_model(&PrivacySettings::handle(ctx), |_me, _, event, _ctx| {
|
||||
if let PrivacySettingsChangedEvent::UpdateIsTelemetryEnabled { .. } = event {
|
||||
clear_event_queue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Writes all queued but unsent telemetry telemetry events to disk so that they may be sent
|
||||
/// on the next app startup.
|
||||
pub fn write_telemetry_events_to_disk(&self, ctx: &mut ModelContext<TelemetryCollector>) {
|
||||
match self.server_api.persist_telemetry_events(
|
||||
MAX_TELEMETRY_EVENTS_TO_STORE,
|
||||
PrivacySettings::as_ref(ctx).get_snapshot(ctx),
|
||||
) {
|
||||
Ok(()) => {
|
||||
log::info!("Successfully wrote telemetry events to disk")
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to write telemetry events to disk {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
use serde_json::{json, Value};
|
||||
//! Module that builds a static context to attach to each of our events that are sent to Rudderstack.
|
||||
//! This is needed so we know the backing operating system and version of each telemetry event.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
#[cfg(target_family = "wasm")]
|
||||
use galaxyui::platform::wasm;
|
||||
|
||||
use super::rudder_message::Message as RudderMessage;
|
||||
use crate::server::OperatingSystemInfo;
|
||||
|
||||
static TELEMETRY_CONTEXT: OnceLock<TelemetryContext> = OnceLock::new();
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TelemetryContextInfo {
|
||||
/// Info about the operating system of the client.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
os: Option<&'static OperatingSystemInfo>,
|
||||
/// The user agent provided by the browser, if running on Web. If not on
|
||||
/// Web, this is always `None`.
|
||||
#[serde(rename = "userAgent", skip_serializing_if = "Option::is_none")]
|
||||
user_agent: Option<String>,
|
||||
}
|
||||
|
||||
/// Newtype representing a [`Value`] with a serialized version of the context that we send to
|
||||
/// Rudderstack.
|
||||
/// See https://www.rudderstack.com/docs/event-spec/standard-events/common-fields/#contextual-fields.
|
||||
pub struct TelemetryContext(Value);
|
||||
|
||||
impl TelemetryContext {
|
||||
@@ -9,7 +36,6 @@ impl TelemetryContext {
|
||||
}
|
||||
|
||||
pub fn telemetry_context() -> &'static TelemetryContext {
|
||||
use std::sync::OnceLock;
|
||||
static TELEMETRY_CONTEXT: OnceLock<TelemetryContext> = OnceLock::new();
|
||||
TELEMETRY_CONTEXT.get_or_init(|| TelemetryContext(json!({})))
|
||||
}
|
||||
|
||||
@@ -24,29 +24,3 @@ impl TelemetryContextProvider for AppTelemetryContextProvider {
|
||||
auth_state.anonymous_id()
|
||||
}
|
||||
}
|
||||
|
||||
/// A no-op telemetry context provider for headless contexts (e.g. the remote
|
||||
/// server daemon) that run without authentication. Telemetry events that
|
||||
/// require a user/anonymous ID will silently produce empty identifiers,
|
||||
/// preventing panics from an unregistered `TelemetryContextModel` singleton.
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub struct NoopTelemetryContextProvider;
|
||||
|
||||
impl NoopTelemetryContextProvider {
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
pub fn new_context_provider(
|
||||
_ctx: &mut ModelContext<TelemetryContextModel>,
|
||||
) -> TelemetryContextModel {
|
||||
Box::new(Self)
|
||||
}
|
||||
}
|
||||
|
||||
impl TelemetryContextProvider for NoopTelemetryContextProvider {
|
||||
fn user_id(&self, _ctx: &AppContext) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn anonymous_id(&self, _ctx: &AppContext) -> String {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
+567
-224
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,47 @@ mod macros;
|
||||
pub mod rudder_message;
|
||||
pub mod secret_redaction;
|
||||
|
||||
use std::fs::File;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::fs::OpenOptions;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Utc;
|
||||
pub use collector::*;
|
||||
pub use context::telemetry_context;
|
||||
pub use events::*;
|
||||
use futures::FutureExt;
|
||||
use rudder_message::{
|
||||
Batch as RudderBatch, BatchMessage as RudderBatchMessageWithMetadata,
|
||||
BatchMessageItem as RudderBatchMessage, Message as RudderMessage,
|
||||
};
|
||||
use galaxy_core::channel::RudderStackDestination;
|
||||
use warpui::telemetry::Event;
|
||||
|
||||
use crate::auth::UserUid;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::server::telemetry::context::AttachContext;
|
||||
use crate::server::telemetry_ext::TelemetryExt;
|
||||
use crate::settings::PrivacySettingsSnapshot;
|
||||
use crate::ChannelState;
|
||||
|
||||
/// Filename for file where telemetry events are written on app quit.
|
||||
const RUDDER_TELEMETRY_EVENTS_FILE_NAME: &str = "rudder_telemetry_events.json";
|
||||
|
||||
/// Filepath where the Rudder events should be written on app quit.
|
||||
fn rudder_event_file_path() -> PathBuf {
|
||||
galaxy_core::paths::secure_state_dir()
|
||||
.unwrap_or_else(galaxy_core::paths::state_dir)
|
||||
.join(RUDDER_TELEMETRY_EVENTS_FILE_NAME)
|
||||
}
|
||||
|
||||
/// Removes all telemetry events from the app telemetry event queue.
|
||||
pub fn clear_event_queue() {
|
||||
let _ = galaxyui::telemetry::flush_events();
|
||||
}
|
||||
|
||||
/// No-op stub. Telemetry has been removed from Galaxy.
|
||||
pub struct TelemetryApi {
|
||||
pub client: http_client::Client,
|
||||
}
|
||||
@@ -30,11 +66,166 @@ impl TelemetryApi {
|
||||
|
||||
pub async fn send_telemetry_event(
|
||||
&self,
|
||||
_user_id: Option<crate::auth::UserUid>,
|
||||
_anonymous_id: String,
|
||||
_event: impl galaxy_core::telemetry::TelemetryEvent,
|
||||
_settings_snapshot: crate::settings::PrivacySettingsSnapshot,
|
||||
) -> anyhow::Result<()> {
|
||||
user_id: Option<UserUid>,
|
||||
anonymous_id: String,
|
||||
event: impl galaxy_core::telemetry::TelemetryEvent,
|
||||
settings_snapshot: PrivacySettingsSnapshot,
|
||||
) -> Result<()> {
|
||||
let event = galaxyui::telemetry::create_event(
|
||||
user_id.map(|uid| uid.as_string()),
|
||||
anonymous_id,
|
||||
event.name().into(),
|
||||
event.payload(),
|
||||
event.contains_ugc(),
|
||||
galaxyui::time::get_current_time(),
|
||||
);
|
||||
|
||||
self.send_telemetry_event_internal(event, settings_snapshot)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Internal implementation for sending telemetry events. This reduces code size, since
|
||||
// we:
|
||||
// 1. Return a boxed future, so calling `async` functions don't need to inline this one.
|
||||
// 2. Don't have to monomorphize for each telemetry event implementation.
|
||||
fn send_telemetry_event_internal(
|
||||
&self,
|
||||
event: Event,
|
||||
settings_snapshot: PrivacySettingsSnapshot,
|
||||
) -> impl Future<Output = Result<()>> + '_ {
|
||||
let work = async move {
|
||||
if settings_snapshot.should_disable_telemetry() {
|
||||
log::info!("Not sending telemetry event because telemetry is disabled.");
|
||||
return Result::Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if FeatureFlag::SendTelemetryToFile.is_enabled() {
|
||||
self.persist_events_to_telemetry_log_file(vec![event.clone()])?;
|
||||
}
|
||||
|
||||
if !(ChannelState::is_release_bundle()
|
||||
|| FeatureFlag::WithSandboxTelemetry.is_enabled())
|
||||
{
|
||||
return Result::Ok(());
|
||||
}
|
||||
|
||||
let rudder_batch = vec![event.to_rudder_batch_message()];
|
||||
|
||||
let result = self
|
||||
.send_batch_messages_to_rudder(rudder_batch, settings_snapshot)
|
||||
.await;
|
||||
|
||||
// This is only conditionally compiled because `is_connect` is not
|
||||
// available on wasm. If additional checks are made against the
|
||||
// `reqwest::Error`, this condition should be performed specifically
|
||||
// against `is_connect` and not the whole loop.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if let Err(error) = &result {
|
||||
for cause in error.chain() {
|
||||
if let Some(err) = cause.downcast_ref::<reqwest::Error>() {
|
||||
if err.is_connect() {
|
||||
log::warn!("Failed to send telemetry event: {error}");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
};
|
||||
|
||||
// On WASM, the work future is non-Send, because the HTTP request future contains a reference to a JS
|
||||
// value (which is fine, since our WASM executor is single-threaded). On all other platforms, we must
|
||||
// return a Send future in order to use the background executor.
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_family = "wasm")] {
|
||||
work.boxed_local()
|
||||
} else {
|
||||
work.boxed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a batch of RudderStack messages to their HTTP API.
|
||||
/// Note that the rudderanalytics SDK provides a client, but we don't
|
||||
/// use it for a few reasons:
|
||||
/// 1. It only supports a blocking HTTP client instead of an async one
|
||||
/// 2. We want to use our own HTTP client which has before/after request logging hooks
|
||||
#[cfg_attr(target_family = "wasm", allow(clippy::question_mark))]
|
||||
async fn send_batch_messages_to_rudder(
|
||||
&self,
|
||||
messages: Vec<RudderBatchMessageWithMetadata>,
|
||||
settings_snapshot: PrivacySettingsSnapshot,
|
||||
) -> Result<()> {
|
||||
if messages.is_empty() {
|
||||
log::debug!("Dropping empty RudderStack telemetry batch");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if settings_snapshot.should_disable_telemetry() {
|
||||
log::info!("Not sending batched messages because telemetry is disabled.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!("Start to send telemetry events to RudderStack");
|
||||
|
||||
let (mut messages_with_ugc, messages_without_ugc): (Vec<_>, Vec<_>) = messages
|
||||
.into_iter()
|
||||
.partition(|message| message.contains_ugc);
|
||||
|
||||
// If we shouldn't collect UGC telemetry, forcibly clear any messages with UGC before trying to send.
|
||||
if !settings_snapshot.should_collect_ai_ugc_telemetry() {
|
||||
messages_with_ugc.clear();
|
||||
}
|
||||
|
||||
for (messages, rudder_stack_destination) in [
|
||||
(
|
||||
messages_with_ugc,
|
||||
ChannelState::rudderstack_ugc_destination(),
|
||||
),
|
||||
(
|
||||
messages_without_ugc,
|
||||
ChannelState::rudderstack_non_ugc_destination(),
|
||||
),
|
||||
] {
|
||||
if messages.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Note that timestamp and context are already included in the individual RudderBatchMessages
|
||||
// and these are the most important ones,
|
||||
// but we also add them to the RudderMessage::Batch wrapper.
|
||||
let rudder_message = RudderMessage::Batch(RudderBatch {
|
||||
batch: messages
|
||||
.into_iter()
|
||||
.map(|message| message.message)
|
||||
.collect(),
|
||||
original_timestamp: Some(Utc::now()),
|
||||
..Default::default()
|
||||
});
|
||||
if let Err(e) = self
|
||||
.send_rudder_request(rudder_message, rudder_stack_destination)
|
||||
.await
|
||||
{
|
||||
// Don't treat a connection issue as an error as these are outside of our control.
|
||||
//
|
||||
// This is only conditionally compiled because `is_connect` is not
|
||||
// available on wasm. If additional checks are made against the
|
||||
// `reqwest::Error`, this condition should be performed specifically
|
||||
// against `is_connect` and not the whole loop.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
for cause in e.chain() {
|
||||
if let Some(err) = cause.downcast_ref::<reqwest::Error>() {
|
||||
if err.is_connect() {
|
||||
log::warn!("Failed to send event to RudderStack: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
use regex::Regex;
|
||||
//! Best-effort secret redaction for telemetry payloads.
|
||||
//!
|
||||
//! Unlike the AI-side secret redaction in `app/src/ai/blocklist/block/secret_redaction.rs`,
|
||||
//! which is gated on the user's secret-redaction (a.k.a. "safe mode") setting and is used
|
||||
//! for visual obfuscation in the terminal, the redaction in this module is unconditional:
|
||||
//! we always do a redaction pass on telemetry payloads that may contain user-generated
|
||||
//! content, regardless of the user's safe-mode setting. The two settings are deliberately
|
||||
//! decoupled — visual obfuscation is a UX preference, while telemetry-side redaction is a
|
||||
//! defence-in-depth measure for data leaving the device.
|
||||
//!
|
||||
//! The regex used for redaction always includes the default patterns defined in
|
||||
//! `crate::terminal::model::secrets::regexes::DEFAULT_REGEXES_WITH_NAMES`. Any custom
|
||||
//! patterns the user has configured (or that their organization has configured via
|
||||
//! enterprise secret redaction) are layered on top of those defaults.
|
||||
//!
|
||||
//! This module is intentionally lightweight: it does byte-range matching only and does
|
||||
//! not track `SecretLevel`s or character ranges, since the telemetry path doesn't need
|
||||
//! either.
|
||||
use std::collections::HashSet;
|
||||
use std::ops::Range;
|
||||
|
||||
pub fn update_telemetry_secrets_regex<'a>(
|
||||
_user_secrets: impl Iterator<Item = &'a Regex>,
|
||||
_enterprise_secrets: impl Iterator<Item = &'a Regex>,
|
||||
) {
|
||||
use lazy_static::lazy_static;
|
||||
use parking_lot::RwLock;
|
||||
use regex_automata::meta::Regex;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::terminal::model::secrets::regexes::DEFAULT_REGEXES_WITH_NAMES;
|
||||
const REDACTION_REPLACEMENT_CHARACTER: &str = "*";
|
||||
lazy_static! {
|
||||
/// Regex used to redact secrets from telemetry payloads. Initialized with the
|
||||
/// default patterns so that redaction works even before the user's privacy
|
||||
/// settings are loaded (and even for users who have never configured any
|
||||
/// custom patterns).
|
||||
static ref TELEMETRY_SECRETS_REGEX: RwLock<Regex> = RwLock::new(build_default_regex());
|
||||
}
|
||||
|
||||
@@ -1 +1,127 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::{json, Value};
|
||||
use galaxy_core::channel::{Channel, ChannelState};
|
||||
use galaxy_core::execution_mode;
|
||||
use galaxyui::telemetry::EventPayload;
|
||||
|
||||
use super::telemetry::rudder_message::{
|
||||
BatchMessage as RudderBatchMessage, BatchMessageItem as RudderBatchMessageItem,
|
||||
Identify as RudderIdentify, Track as RudderTrack,
|
||||
};
|
||||
use super::telemetry::secret_redaction::redact_secrets_in_value;
|
||||
use super::telemetry::telemetry_context;
|
||||
use crate::auth::UserUid;
|
||||
|
||||
pub trait TelemetryExt {
|
||||
fn to_rudder_batch_message(self) -> RudderBatchMessage;
|
||||
}
|
||||
|
||||
impl TelemetryExt for galaxyui::telemetry::Event {
|
||||
fn to_rudder_batch_message(self) -> RudderBatchMessage {
|
||||
let message = match self.payload {
|
||||
EventPayload::IdentifyUser {
|
||||
user_id,
|
||||
anonymous_id,
|
||||
} => RudderBatchMessageItem::Identify(RudderIdentify {
|
||||
user_id: Some(UserUid::new(user_id.as_str())),
|
||||
anonymous_id: Some(anonymous_id),
|
||||
original_timestamp: Some(self.timestamp),
|
||||
integrations: Some(json!({
|
||||
"Amplitude": {
|
||||
"session_id": self.session_created_at.timestamp(),
|
||||
}
|
||||
})),
|
||||
context: Some(telemetry_context().as_value()),
|
||||
..Default::default()
|
||||
}),
|
||||
EventPayload::AppActive {
|
||||
user_id,
|
||||
anonymous_id,
|
||||
} => form_rudder_track_message(
|
||||
user_id.map(|uid| UserUid::new(uid.as_str())),
|
||||
anonymous_id,
|
||||
"Active App Usage".to_string(),
|
||||
None,
|
||||
self.timestamp,
|
||||
self.session_created_at,
|
||||
),
|
||||
EventPayload::NamedEvent {
|
||||
user_id,
|
||||
anonymous_id,
|
||||
name,
|
||||
mut value,
|
||||
} => {
|
||||
// For events that may contain user-generated content, run a
|
||||
// best-effort secret-redaction pass on the payload before
|
||||
// sending. This is independent of the user's safe-mode setting:
|
||||
// visual obfuscation is a UX preference, while telemetry-side
|
||||
// redaction is a defence-in-depth measure for data leaving the
|
||||
// device. See `secret_redaction.rs` for details.
|
||||
if self.contains_ugc {
|
||||
if let Some(value) = value.as_mut() {
|
||||
redact_secrets_in_value(value);
|
||||
}
|
||||
}
|
||||
form_rudder_track_message(
|
||||
user_id.map(|uid| UserUid::new(uid.as_str())),
|
||||
anonymous_id,
|
||||
name.to_string(),
|
||||
value,
|
||||
self.timestamp,
|
||||
self.session_created_at,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
RudderBatchMessage {
|
||||
message,
|
||||
contains_ugc: self.contains_ugc,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn form_rudder_track_message(
|
||||
user_id: Option<UserUid>,
|
||||
anonymous_id: String,
|
||||
name: String,
|
||||
payload: Option<Value>,
|
||||
timestamp: DateTime<Utc>,
|
||||
session_created_at: DateTime<Utc>,
|
||||
) -> RudderBatchMessageItem {
|
||||
RudderBatchMessageItem::Track(RudderTrack {
|
||||
user_id,
|
||||
anonymous_id: Some(anonymous_id),
|
||||
event: name,
|
||||
properties: Some(json!({
|
||||
"release_mode": release_mode(ChannelState::channel()),
|
||||
"tag": ChannelState::app_version().unwrap_or("<no tag>"),
|
||||
"client_id": execution_mode::current_client_id(),
|
||||
"payload": payload
|
||||
})),
|
||||
original_timestamp: Some(timestamp),
|
||||
integrations: Some(json!({
|
||||
"Amplitude": {
|
||||
"session_id": session_created_at.timestamp(),
|
||||
}
|
||||
})),
|
||||
context: Some(telemetry_context().as_value()),
|
||||
})
|
||||
}
|
||||
|
||||
fn release_mode(channel: Channel) -> &'static str {
|
||||
match channel {
|
||||
Channel::Stable => "stable_release",
|
||||
Channel::Preview => "preview_release",
|
||||
Channel::Local => "local",
|
||||
Channel::Integration => "integration_test",
|
||||
Channel::Dev => "dev_release",
|
||||
// We don't ever expect to send telemetry for the OSS build, but
|
||||
// until we have some time to clean things up here, we'll set a valid
|
||||
// value that we never intend to receive.
|
||||
Channel::Oss => "oss_release",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "telemetry_ext_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -3,11 +3,10 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use galaxyui::{Entity, SingletonEntity};
|
||||
|
||||
use super::server_api::{ServerApi, TranscribeError};
|
||||
use crate::ai::voice::transcribe::{Provider, TranscribeRequest};
|
||||
use crate::voice::transcriber::Transcriber;
|
||||
|
||||
use super::server_api::{ServerApi, TranscribeError};
|
||||
|
||||
pub struct ServerVoiceTranscriber {
|
||||
server_api: Arc<ServerApi>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user