first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+4 -5
View File
@@ -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();
+28 -76
View File
@@ -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>,
) {
+18 -32
View File
@@ -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;
+147 -124
View File
@@ -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;
@@ -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,
&notebook_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();
-13
View File
@@ -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())
}
}
-12
View File
@@ -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.
+3 -36
View File
@@ -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
}
+6 -5
View File
@@ -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,
+2 -1
View File
@@ -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 -13
View File
@@ -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};
+108 -310
View File
@@ -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()),
},
})
}
+36 -61
View File
@@ -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,
})
}
+6 -4
View File
@@ -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() {
-2
View File
@@ -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;
+7 -10
View File
@@ -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";
-168
View File
@@ -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;
-70
View File
@@ -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);
});
});
}
+81 -2
View File
@@ -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})"
))
}
File diff suppressed because it is too large Load Diff
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"
);
}
+6 -351
View File
@@ -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"
);
}
+18 -21
View File
@@ -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()
));
+33
View File
@@ -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(())
}
+207 -2
View File
@@ -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"
}
})
);
}
+5 -6
View File
@@ -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 {}
+57
View File
@@ -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"
)),
}
}
}
+49 -21
View File
@@ -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
View File
@@ -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));
+291 -127
View File
@@ -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);
}
+8 -9
View File
@@ -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 {
+8 -8
View File
@@ -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(),
+6 -6
View File
@@ -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
View File
@@ -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
+75 -3
View File
@@ -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:#}");
}
}
}
+28 -2
View File
@@ -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()
}
}
File diff suppressed because it is too large Load Diff
+197 -6
View File
@@ -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(())
}
+33 -5
View File
@@ -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());
}
+126
View File
@@ -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;
+1 -2
View File
@@ -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>,
}