diff --git a/Cargo.lock b/Cargo.lock index 37b49d2929..2efc7ce848 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2822,6 +2822,52 @@ dependencies = [ "error-code", ] +[[package]] +name = "cloud_object_client" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-channel", + "async-trait", + "chrono", + "cloud_object_models", + "cloud_objects", + "mockall", + "serde", + "serde_json", + "uuid", + "warp_core", + "warp_graphql", +] + +[[package]] +name = "cloud_object_models" +version = "0.1.0" +dependencies = [ + "ai", + "anyhow", + "cfg-if", + "chrono", + "cloud_objects", + "handlebars", + "lazy_static", + "log", + "regex", + "schemars", + "serde", + "serde_json", + "serde_regex", + "session-sharing-protocol", + "settings", + "settings_value", + "uuid", + "warp-workflows", + "warp_cli", + "warp_core", + "warp_graphql", + "warp_util", +] + [[package]] name = "cloud_objects" version = "0.1.0" @@ -14347,6 +14393,8 @@ dependencies = [ "channel_versions", "chrono", "clap", + "cloud_object_client", + "cloud_object_models", "cloud_objects", "cocoa 0.26.0", "comfy-table", @@ -14985,6 +15033,8 @@ dependencies = [ "anyhow", "bincode", "chrono", + "cloud_object_client", + "cloud_object_models", "cloud_objects", "cynic", "derivative", diff --git a/Cargo.toml b/Cargo.toml index 13275c0464..bb0c56dceb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ channel_versions = { path = "crates/channel_versions", default-features = false command = { path = "crates/command" } command-signatures-v2 = { path = "crates/command-signatures-v2" } cloud_objects = { path = "crates/cloud_objects" } +cloud_object_client = { path = "crates/cloud_object_client" } +cloud_object_models = { path = "crates/cloud_object_models" } computer_use = { path = "crates/computer_use" } field_mask = { path = "crates/field_mask" } firebase = { path = "crates/firebase" } diff --git a/app/Cargo.toml b/app/Cargo.toml index 943203c967..f2027f3481 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -81,6 +81,8 @@ cfg-if.workspace = true channel_versions.workspace = true chrono.workspace = true clap.workspace = true +cloud_object_client.workspace = true +cloud_object_models.workspace = true cloud_objects.workspace = true command = { workspace = true } command-corrections.workspace = true @@ -397,6 +399,7 @@ jsonschema = { workspace = true, default-features = false } async-executor = "1.5.1" command = { workspace = true, features = ["test-util"] } ctor = "0.1.18" +cloud_object_client = { workspace = true, features = ["test-util"] } http_client = { workspace = true, features = ["test-util"] } mockall = "0.13.1" mockito.workspace = true @@ -797,6 +800,7 @@ voice_input = ["dep:voice_input"] system_theme = [] tab_close_button_on_left = [] team_features_override = [] +test-util = ["cloud_object_client/test-util"] team_workflows = ["team_features_override"] toggle_bootstrap_block = [] # Feature enabled only when app is compiled for integration tests. diff --git a/app/src/ai/agent/api.rs b/app/src/ai/agent/api.rs index 288dbe281d..7fb406f8e0 100644 --- a/app/src/ai/agent/api.rs +++ b/app/src/ai/agent/api.rs @@ -27,6 +27,7 @@ use crate::ai::agent::conversation::AIConversationId; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::blocklist::{BlocklistAIPermissions, RequestInput, SessionContext}; use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; +use crate::ai::execution_profiles::AIExecutionProfileAppExt; use crate::ai::llms::LLMId; use crate::ai::mcp::templatable_manager::TemplatableMCPServerInfo; use crate::ai::mcp::TemplatableMCPServerManager; diff --git a/app/src/ai/agent/mod.rs b/app/src/ai/agent/mod.rs index eaa95dea58..a27c23dd5d 100644 --- a/app/src/ai/agent/mod.rs +++ b/app/src/ai/agent/mod.rs @@ -3030,25 +3030,7 @@ pub struct RequestMetadata { pub is_auto_resume_after_error: bool, } -/// A globally unique ID for a suggested objects. -/// -/// This is used for telemetry purposes to track and connect both: -/// - Suggested objects generated by the AI agent -/// - The corresponding objects stored in the cloud (if the suggestion was accepted) -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] -pub struct SuggestedLoggingId(String); - -impl Display for SuggestedLoggingId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From for SuggestedLoggingId { - fn from(value: String) -> Self { - Self(value) - } -} +pub use cloud_object_models::SuggestedLoggingId; #[derive(Debug, Clone, Eq, PartialEq)] pub struct SuggestedRule { diff --git a/app/src/ai/ambient_agents/scheduled.rs b/app/src/ai/ambient_agents/scheduled.rs index 32af394a35..ba25fcd4f6 100644 --- a/app/src/ai/ambient_agents/scheduled.rs +++ b/app/src/ai/ambient_agents/scheduled.rs @@ -1,22 +1,21 @@ use std::collections::HashMap; use std::future::Future; +pub use cloud_object_models::{ + CloudScheduledAmbientAgent, CloudScheduledAmbientAgentModel, ScheduledAmbientAgent, +}; use futures::channel::oneshot; use futures::FutureExt; -use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use warp_graphql::queries::get_scheduled_agent_history::ScheduledAgentHistory; use warpui::{AppContext, Entity, ModelContext, SingletonEntity}; -use super::AgentConfigSnapshot; -use crate::cloud_object::model::generic_string_model::{ - GenericStringModel, GenericStringObjectId, StringModel, -}; -use crate::cloud_object::model::json_model::{JsonModel, JsonSerializer}; +use crate::cloud_object::model::generic_string_model::StringModel; +use crate::cloud_object::model::json_model::JsonModel; use crate::cloud_object::model::persistence::CloudModel; use crate::cloud_object::{ - CloudObjectLookup as _, GenericCloudObject, GenericStringObjectFormat, - GenericStringObjectUniqueKey, JsonObjectType, Owner, Revision, + CloudObjectLookup as _, GenericStringObjectFormat, GenericStringObjectUniqueKey, + JsonObjectType, Owner, Revision, }; use crate::drive::CloudObjectTypeAndId; use crate::server::cloud_objects::update_manager::{ @@ -26,47 +25,6 @@ use crate::server::ids::{ClientId, SyncId}; use crate::server::server_api::ServerApiProvider; use crate::server::sync_queue::QueueItem; -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -/// A ScheduledAmbientAgent represents configuration for ambient agents that run on a cron schedule. -pub struct ScheduledAmbientAgent { - /// Agent name - #[serde(default)] - pub name: String, - /// Cron schedule expression - #[serde(default)] - pub cron_schedule: String, - /// Whether the scheduled agent is enabled - #[serde(default)] - pub enabled: bool, - /// The prompt to use for the scheduled agent - #[serde(default)] - pub prompt: String, - /// The latest failure to execute this scheduled agent. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_spawn_error: Option, - /// Configuration for how the ambient agent should run. - #[serde(default, skip_serializing_if = "AgentConfigSnapshot::is_empty")] - pub agent_config: AgentConfigSnapshot, -} - -pub type CloudScheduledAmbientAgent = - GenericCloudObject; -pub type CloudScheduledAmbientAgentModel = - GenericStringModel; - -impl ScheduledAmbientAgent { - pub fn new(name: String, cron_schedule: String, enabled: bool, prompt: String) -> Self { - Self { - name, - cron_schedule, - enabled, - prompt, - last_spawn_error: None, - agent_config: Default::default(), - } - } -} - impl StringModel for ScheduledAmbientAgent { type CloudObjectType = CloudScheduledAmbientAgent; diff --git a/app/src/ai/ambient_agents/task.rs b/app/src/ai/ambient_agents/task.rs index b1fa6e4ad8..1185bc8e2a 100644 --- a/app/src/ai/ambient_agents/task.rs +++ b/app/src/ai/ambient_agents/task.rs @@ -2,10 +2,12 @@ use anyhow::anyhow; use chrono::{DateTime, Utc}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +pub use cloud_object_models::{ + AgentConfigSnapshot, HarnessAuthSecretsConfig, HarnessConfig, HarnessModelConfig, +}; +use serde::{Deserialize, Serialize}; use session_sharing_protocol::common::SessionId; use url::Url; -use warp_cli::agent::Harness; use warp_core::report_error; use warp_core::ui::theme::WarpTheme; use warpui::color::ColorU; @@ -18,99 +20,6 @@ use crate::ui_components::icons::Icon; use crate::view_components::DismissibleToast; use crate::workspace::ToastStack; -/// Runtime configuration snapshot for agent execution. -/// -/// This is the merged/resolved config used when spawning or running an agent. -/// It combines settings from config files and CLI args. -/// Unlike `AgentConfig` (the cloud model), field names here use the runtime format -/// (e.g. `model_id` instead of `base_model_id`). -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -pub struct AgentConfigSnapshot { - /// Config name for searchability/traceability. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub environment_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_prompt: Option, - /// MCP server configuration map (unwrapped; no `mcpServers` wrapper). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mcp_servers: Option>, - /// Profile ID for local agent runs. This configures the terminal session - /// with the specified execution profile. Only used for local runs, not cloud runs. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile_id: Option, - /// Self-hosted worker ID that should execute this task. - /// If None or Some("warp"), the task will be dispatched to Warp-hosted (Namespace) workers. - /// Otherwise, the task will only be assigned to a connected self-hosted worker with matching ID. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub worker_host: Option, - /// Skill spec to use as the base prompt for the agent. - /// Format: "skill_name", "repo:skill_name", or "org/repo:skill_name". - /// The skill is resolved at runtime in the agent environment. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub skill_spec: Option, - /// Whether computer use is enabled for this agent run. - /// If None, the default behavior is used. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub computer_use_enabled: Option, - /// Execution harness for the agent run. - /// If None, we use Warp's default ("oz"). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub harness: Option, - /// Authentication secrets for third-party harnesses. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub harness_auth_secrets: Option, -} - -/// Configuration for a third-party execution harness. -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -pub struct HarnessConfig { - /// The harness type, e.g. [`Harness::Claude`]. - #[serde( - rename = "type", - serialize_with = "serialize_harness", - deserialize_with = "deserialize_harness" - )] - pub harness_type: Harness, - /// The model to use with this harness. None means use the harness default. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model_id: Option, - /// Optional reasoning level for harnesses that support it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_level: Option, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub struct HarnessModelConfig { - pub model_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub reasoning_level: Option, -} - -impl HarnessConfig { - /// Builds a harness config from just the harness type. - pub fn from_harness_type(harness_type: Harness) -> Self { - Self { - harness_type, - model_id: None, - reasoning_level: None, - } - } - - pub fn model_config(&self) -> Option { - self.model_id - .as_ref() - .filter(|id| !id.is_empty()) - .map(|model_id| HarnessModelConfig { - model_id: model_id.clone(), - reasoning_level: self.reasoning_level.clone(), - }) - } -} - fn parse_session_id_from_link(session_link: &str) -> Option { Url::parse(session_link).ok().and_then(|url| { url.path_segments() @@ -127,61 +36,6 @@ fn parse_execution_session_id(execution: RunExecution<'_>) -> Option .and_then(|id| id.parse().ok()) .or_else(|| execution.session_link.and_then(parse_session_id_from_link)) } - -fn serialize_harness(harness: &Harness, serializer: S) -> Result { - serializer.serialize_str(harness.config_name()) -} - -fn deserialize_harness<'de, D: Deserializer<'de>>(deserializer: D) -> Result { - let name = String::deserialize(deserializer)?; - Ok(Harness::from_config_name(&name).unwrap_or_else(|| { - log::warn!("Unknown harness config name: {name:?}; treating as Unknown"); - Harness::Unknown - })) -} - -/// Authentication secrets for third-party harnesses. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub struct HarnessAuthSecretsConfig { - /// Name of a managed secret for Claude Code harness authentication. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub claude_auth_secret_name: Option, - /// Name of a managed secret for Codex harness authentication. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub codex_auth_secret_name: Option, -} - -impl AgentConfigSnapshot { - /// Returns true if this config is empty (no options are set). - pub fn is_empty(&self) -> bool { - let Self { - name, - environment_id, - model_id, - base_prompt, - mcp_servers, - profile_id, - worker_host, - skill_spec, - computer_use_enabled, - harness, - harness_auth_secrets, - } = self; - - name.is_none() - && environment_id.is_none() - && model_id.is_none() - && base_prompt.is_none() - && mcp_servers.is_none() - && profile_id.is_none() - && worker_host.is_none() - && skill_spec.is_none() - && computer_use_enabled.is_none() - && harness.is_none() - && harness_auth_secrets.is_none() - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum AgentSource { Linear, diff --git a/app/src/ai/blocklist/inline_action/requested_command_attribution.rs b/app/src/ai/blocklist/inline_action/requested_command_attribution.rs index ea1fa1efff..dfebb7390b 100644 --- a/app/src/ai/blocklist/inline_action/requested_command_attribution.rs +++ b/app/src/ai/blocklist/inline_action/requested_command_attribution.rs @@ -6,7 +6,7 @@ use warpui::{AppContext, SingletonEntity}; use crate::ai::agent::AIAgentCitation; use crate::cloud_object::model::persistence::CloudModel; -use crate::env_vars::{EnvVarCollection, EnvVarValue}; +use crate::env_vars::{EnvVarCollection, EnvVarCollectionExt, EnvVarExt, EnvVarValue}; use crate::notebooks::CloudNotebookModel; use crate::terminal::shell::ShellType; use crate::workflows::command_parser::command_matches_workflow; diff --git a/app/src/ai/cloud_agent_config/mod.rs b/app/src/ai/cloud_agent_config/mod.rs index 594aad41ff..513a687930 100644 --- a/app/src/ai/cloud_agent_config/mod.rs +++ b/app/src/ai/cloud_agent_config/mod.rs @@ -1,60 +1,12 @@ -use std::collections::HashMap; +pub use cloud_object_models::{AgentConfig, CloudAgentConfig, CloudAgentConfigModel}; -use serde::{Deserialize, Serialize}; - -use crate::cloud_object::model::generic_string_model::{ - GenericStringModel, GenericStringObjectId, StringModel, -}; -use crate::cloud_object::model::json_model::{JsonModel, JsonSerializer}; +use crate::cloud_object::model::generic_string_model::StringModel; +use crate::cloud_object::model::json_model::JsonModel; use crate::cloud_object::{ - GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, - Revision, + GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, Revision, }; -use crate::server::server_api::ai::AgentConfigSnapshot; use crate::server::sync_queue::QueueItem; -/// A CloudAgentConfig represents a saved agent configuration that can be referenced -/// when running agents via `--agent-id`. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] -pub struct AgentConfig { - /// Configuration name - pub name: String, - /// Base model ID to use for the agent - #[serde(skip_serializing_if = "Option::is_none")] - pub base_model_id: Option, - /// Base prompt to prepend to user prompts - #[serde(skip_serializing_if = "Option::is_none")] - pub base_prompt: Option, - /// MCP servers configuration - #[serde(skip_serializing_if = "Option::is_none")] - pub mcp_servers: Option>, -} - -pub type CloudAgentConfig = GenericCloudObject; -pub type CloudAgentConfigModel = GenericStringModel; - -impl AgentConfig { - /// Convert to AgentConfigSnapshot for use in agent execution. - /// - /// Note: `AgentConfig` matches the server's JSON format (e.g. `base_model_id`), - /// while `AgentConfigSnapshot` is the runtime config format (e.g. `model_id`). - pub fn to_ambient_config(&self) -> AgentConfigSnapshot { - AgentConfigSnapshot { - name: Some(self.name.clone()), - environment_id: None, - model_id: self.base_model_id.clone(), - base_prompt: self.base_prompt.clone(), - mcp_servers: self.mcp_servers.clone().map(|m| m.into_iter().collect()), - profile_id: None, - worker_host: None, - skill_spec: None, - computer_use_enabled: None, - harness: None, - harness_auth_secrets: None, - } - } -} - impl StringModel for AgentConfig { type CloudObjectType = CloudAgentConfig; diff --git a/app/src/ai/cloud_environments/mod.rs b/app/src/ai/cloud_environments/mod.rs index 80bcf0e235..ecd30becd8 100644 --- a/app/src/ai/cloud_environments/mod.rs +++ b/app/src/ai/cloud_environments/mod.rs @@ -1,25 +1,19 @@ -pub use warp_server_client::cloud_object::models::{ - AmbientAgentEnvironment, AwsProviderConfig, BaseImage, GcpProviderConfig, GithubRepo, - ProvidersConfig, +pub use cloud_object_models::{ + AmbientAgentEnvironment, AwsProviderConfig, BaseImage, CloudAmbientAgentEnvironment, + CloudAmbientAgentEnvironmentModel, GcpProviderConfig, GithubRepo, ProvidersConfig, }; -use warp_server_client::cloud_object::{ - GenericCloudObject, GenericStringModel, GenericStringObjectFormat, - GenericStringObjectUniqueKey, JsonObjectType, Owner, Revision, -}; -use warp_server_client::ids::GenericStringObjectId; +use warp_server_client::cloud_object::Owner; use warpui::{AppContext, SingletonEntity as _}; use crate::auth::AuthStateProvider; use crate::cloud_object::model::generic_string_model::StringModel; -use crate::cloud_object::model::json_model::{JsonModel, JsonSerializer}; +use crate::cloud_object::model::json_model::JsonModel; +use crate::cloud_object::{ + GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, Revision, +}; use crate::server::sync_queue::QueueItem; use crate::workspaces::user_workspaces::UserWorkspaces; -pub type CloudAmbientAgentEnvironment = - GenericCloudObject; -pub type CloudAmbientAgentEnvironmentModel = - GenericStringModel; - impl StringModel for AmbientAgentEnvironment { type CloudObjectType = CloudAmbientAgentEnvironment; @@ -92,7 +86,3 @@ pub fn owner_for_new_personal_environment(ctx: &AppContext) -> Option { let user_id = AuthStateProvider::as_ref(ctx).get().user_id()?; Some(Owner::User { user_uid: user_id }) } - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod tests; diff --git a/app/src/ai/execution_profiles/editor/mod.rs b/app/src/ai/execution_profiles/editor/mod.rs index bdc9b5257a..4e98fd1d15 100644 --- a/app/src/ai/execution_profiles/editor/mod.rs +++ b/app/src/ai/execution_profiles/editor/mod.rs @@ -25,7 +25,8 @@ use crate::ai::execution_profiles::profiles::{ AIExecutionProfilesModel, AIExecutionProfilesModelEvent, ClientProfileId, }; use crate::ai::execution_profiles::{ - AIExecutionProfile, ActionPermission, RunAgentsPermission, WriteToPtyPermission, + AIExecutionProfile, AIExecutionProfileAppExt as _, ActionPermission, RunAgentsPermission, + WriteToPtyPermission, }; use crate::ai::llms::{ DisableReason, LLMContextWindow, LLMId, LLMInfo, LLMPreferences, LLMPreferencesEvent, diff --git a/app/src/ai/execution_profiles/mod.rs b/app/src/ai/execution_profiles/mod.rs index 5aad9802c4..4b25bbd220 100644 --- a/app/src/ai/execution_profiles/mod.rs +++ b/app/src/ai/execution_profiles/mod.rs @@ -1,106 +1,25 @@ -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; -use warp_core::channel::ChannelState; +pub use cloud_object_models::{ + AIExecutionProfile, ActionPermission, AskUserQuestionPermission, CloudAIExecutionProfile, + CloudAIExecutionProfileModel, ComputerUsePermission, RunAgentsPermission, WriteToPtyPermission, + PROFILE_NAME_MAX_LENGTH, +}; use warp_core::features::FeatureFlag; use warpui::{AppContext, SingletonEntity}; -use super::llms::{LLMContextWindow, LLMId, LLMPreferences}; -use crate::cloud_object::model::generic_string_model::{ - GenericStringModel, GenericStringObjectId, StringModel, -}; -use crate::cloud_object::model::json_model::{JsonModel, JsonSerializer}; +use super::llms::{LLMContextWindow, LLMPreferences}; +use crate::cloud_object::model::generic_string_model::StringModel; +use crate::cloud_object::model::json_model::JsonModel; use crate::cloud_object::{ - GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, - Revision, UniquePer, + GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, Revision, UniquePer, }; use crate::server::sync_queue::QueueItem; -use crate::settings::{ - AISettings, AgentModeCommandExecutionPredicate, DEFAULT_COMMAND_EXECUTION_ALLOWLIST, - DEFAULT_COMMAND_EXECUTION_DENYLIST, -}; +use crate::settings::{AISettings, DEFAULT_COMMAND_EXECUTION_ALLOWLIST}; use crate::workspaces::user_workspaces::UserWorkspaces; -pub const PROFILE_NAME_MAX_LENGTH: usize = 50; - pub mod editor; pub mod model_menu_items; pub mod profiles; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ActionPermission { - AgentDecides, - AlwaysAllow, - AlwaysAsk, - - // This is intended to catch deserialization errors whenever we add new variants to this enum. Say we - // want to add a "Never" variant. Without this catch-all, old clients wouldn't be able to deserialize - // a "Never" into one of the existing options. - #[serde(other)] - Unknown, -} - -impl ActionPermission { - pub fn description(&self) -> &'static str { - match self { - ActionPermission::AgentDecides | ActionPermission::Unknown => "The Agent chooses the safest path: acting on its own when confident, and asking for approval when uncertain.", - ActionPermission::AlwaysAllow => "Give the Agent full autonomy — no manual approval ever required.", - ActionPermission::AlwaysAsk => "Require explicit approval before the Agent takes any action.", - } - } - - pub fn is_always_ask(&self) -> bool { - matches!(self, Self::AlwaysAsk) - } - - pub fn is_always_allow(&self) -> bool { - matches!(self, Self::AlwaysAllow) - } -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum WriteToPtyPermission { - // This is for backwards compatibility with the old "Never" value. - #[serde(alias = "Never")] - AlwaysAllow, - #[default] - AlwaysAsk, - AskOnFirstWrite, - - // This is intended to catch deserialization errors whenever we add new variants to this enum. - #[serde(other)] - Unknown, -} - -impl WriteToPtyPermission { - pub fn description(&self) -> &'static str { - match self { - WriteToPtyPermission::AlwaysAllow => ActionPermission::AlwaysAllow.description(), - WriteToPtyPermission::AskOnFirstWrite => { - "The agent will ask for permission the first time it needs to interact with a running command. After that, it will continue automatically for the rest of that command." - } - WriteToPtyPermission::AlwaysAsk => "The agent will always ask for permission to interact with a running command.", - WriteToPtyPermission::Unknown => ActionPermission::Unknown.description(), - } - } - - pub fn is_always_allow(&self) -> bool { - matches!(self, Self::AlwaysAllow) - } -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ComputerUsePermission { - #[default] - Never, - AlwaysAsk, - AlwaysAllow, - - // This is intended to catch deserialization errors whenever we add new variants to this enum. - #[serde(other)] - Unknown, -} - /// Result of resolving the cloud agent computer use setting. /// Contains both the effective value and whether it's forced by organization policy. pub struct CloudAgentComputerUseState { @@ -110,341 +29,76 @@ pub struct CloudAgentComputerUseState { pub is_forced_by_org: bool, } -impl ComputerUsePermission { - pub fn description(&self) -> &'static str { - match self { - ComputerUsePermission::Never => { - "Computer use tools are disabled and will not be available to the Agent." - } - ComputerUsePermission::AlwaysAsk => { - "Require explicit approval before the Agent uses computer use tools." - } - ComputerUsePermission::AlwaysAllow => { - "Give the Agent full autonomy to use computer use tools without approval." - } - ComputerUsePermission::Unknown => "Unknown setting.", - } - } - - pub fn is_enabled(&self) -> bool { - !matches!(self, Self::Never | Self::Unknown) - } - - pub fn is_always_allow(&self) -> bool { - matches!(self, Self::AlwaysAllow) - } - - /// Resolves the effective cloud agent computer use state by reading the workspace - /// autonomy setting and user's local preference from their respective singletons. - pub fn resolve_cloud_agent_state(ctx: &AppContext) -> CloudAgentComputerUseState { - if !FeatureFlag::AgentModeComputerUse.is_enabled() { - return CloudAgentComputerUseState { - enabled: false, - is_forced_by_org: false, - }; - } - - let autonomy_setting = UserWorkspaces::as_ref(ctx) - .ai_autonomy_settings() - .computer_use_setting; - let user_preference = *AISettings::as_ref(ctx).cloud_agent_computer_use_enabled; - - match autonomy_setting { - Some(ComputerUsePermission::Never) => CloudAgentComputerUseState { - enabled: false, - is_forced_by_org: true, - }, - Some(ComputerUsePermission::AlwaysAllow) => CloudAgentComputerUseState { - enabled: true, - is_forced_by_org: true, - }, - // TODO(QUALITY-297): Currently this case should never be hit because the - // AlwaysAsk variant isn't accessible in the admin console. We need to figure - // out how to handle it when it eventually becomes available. For now, I'm - // treating this conservatively and marking computer use as disabled. - Some(ComputerUsePermission::AlwaysAsk) => CloudAgentComputerUseState { - enabled: false, - is_forced_by_org: true, - }, - Some(ComputerUsePermission::Unknown) | None => CloudAgentComputerUseState { - enabled: user_preference, - is_forced_by_org: false, - }, - } - } -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum RunAgentsPermission { - NeverAllow, - AlwaysAllow, - #[default] - AlwaysAsk, - - // This is intended to catch deserialization errors whenever we add new variants to this enum. - #[serde(other)] - Unknown, -} - -impl RunAgentsPermission { - pub fn description(&self) -> &'static str { - match self { - RunAgentsPermission::NeverAllow => { - "The Agent cannot run child agents and the run_agents tool will not be available." - } - RunAgentsPermission::AlwaysAllow => { - "Give the Agent full autonomy to run child agents without approval." - } - RunAgentsPermission::AlwaysAsk => { - "Require explicit approval before the Agent runs child agents." - } - RunAgentsPermission::Unknown => "Unknown setting.", - } - } - - pub fn is_enabled(&self) -> bool { - matches!(self, Self::AlwaysAllow | Self::AlwaysAsk) - } - - pub fn is_always_allow(&self) -> bool { - matches!(self, Self::AlwaysAllow) - } - - pub fn is_never_allow(&self) -> bool { - matches!(self, Self::NeverAllow | Self::Unknown) - } -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum AskUserQuestionPermission { - /// Never pause; skip questions and continue with best judgment. - Never, - /// Pause and wait for the user, unless auto-approve mode is enabled. - AskExceptInAutoApprove, - /// Always pause and wait for the user to answer before continuing, even in auto-approve mode. - #[default] - AlwaysAsk, - - // This is intended to catch deserialization errors whenever we add new variants to this enum. - #[serde(other)] - Unknown, -} - -impl AskUserQuestionPermission { - pub fn label(&self) -> &'static str { - match self { - AskUserQuestionPermission::Never => "Never ask", - AskUserQuestionPermission::AskExceptInAutoApprove => "Ask unless auto-approve", - AskUserQuestionPermission::AlwaysAsk | AskUserQuestionPermission::Unknown => { - "Always ask" - } - } - } - - pub fn description(&self) -> &'static str { - match self { - AskUserQuestionPermission::AskExceptInAutoApprove - | AskUserQuestionPermission::Unknown => { - "The Agent may ask a question and pause for your response, but will continue automatically when auto-approve is on." - } - AskUserQuestionPermission::Never => { - "The Agent will not ask questions and will continue with its best judgment." - } - AskUserQuestionPermission::AlwaysAsk => { - "The Agent may ask a question and will pause for your response even when auto-approve is on." - } - } - } -} - -/// Core data structure representing an AI execution profile, which includes model configuration, -/// behavior settings, and permissions. -/// -/// NOTE: `planning_model` was removed after planning via subagent was deprecated; serialized legacy -/// profiles may include a `planning_model` field and this field name should remain reserved -/// indefinitely. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct AIExecutionProfile { - pub name: String, - pub is_default_profile: bool, - pub apply_code_diffs: ActionPermission, - pub read_files: ActionPermission, - - pub execute_commands: ActionPermission, - pub write_to_pty: WriteToPtyPermission, - pub mcp_permissions: ActionPermission, - pub ask_user_question: AskUserQuestionPermission, - pub run_agents: RunAgentsPermission, - - /// Always ask for permission for these commands - pub command_denylist: Vec, - - /// When the execute_commands is set to AlwaysAsk, autoexecute these commands - pub command_allowlist: Vec, - - /// When the read_files is set to AlwaysAsk, autoread from these directories - pub directory_allowlist: Vec, - - pub mcp_allowlist: Vec, - pub mcp_denylist: Vec, - - pub computer_use: ComputerUsePermission, - - pub base_model: Option, - pub coding_model: Option, - pub cli_agent_model: Option, - pub computer_use_model: Option, - - pub context_window_limit: Option, - - /// Whether plans created by the agent should be automatically synced to Warp Drive - pub autosync_plans_to_warp_drive: bool, - - /// Whether the agent may use web search when helpful for completing tasks - pub web_search_enabled: bool, -} - -impl Default for AIExecutionProfile { - fn default() -> Self { - Self { - name: Default::default(), - is_default_profile: false, - apply_code_diffs: ActionPermission::AgentDecides, - read_files: ActionPermission::AgentDecides, - execute_commands: ActionPermission::AlwaysAsk, - write_to_pty: WriteToPtyPermission::AlwaysAsk, - mcp_permissions: ActionPermission::AgentDecides, - ask_user_question: AskUserQuestionPermission::AlwaysAsk, - run_agents: RunAgentsPermission::AlwaysAsk, - command_denylist: DEFAULT_COMMAND_EXECUTION_DENYLIST.clone(), - command_allowlist: Vec::new(), - directory_allowlist: Vec::new(), - mcp_allowlist: Vec::new(), - mcp_denylist: Vec::new(), - computer_use: ComputerUsePermission::Never, - base_model: None, - coding_model: None, - cli_agent_model: None, - computer_use_model: None, - context_window_limit: None, - autosync_plans_to_warp_drive: true, - web_search_enabled: true, - } - } -} - -impl AIExecutionProfile { - pub fn create_default_from_legacy_settings(app: &AppContext) -> Self { - // Note that the legacy "Autonomy" and "Code Access" settings are not imported here. - // The "Code Access" setting defaulted to "Always Ask", which is the most restrictive, so - // it's impossible for us to infer some hesitancy about autonomy from the setting and we should - // ignore it. The same applies to "Autonomy". - let ai_settings = AISettings::as_ref(app); - Self { - name: "Default".to_string(), - is_default_profile: true, - command_denylist: ai_settings.agent_mode_command_execution_denylist.clone(), - // We initialize the command allowlist to be anything the user added, excluding all - // the pre-populated defaults. - command_allowlist: ai_settings - .agent_mode_command_execution_allowlist - .iter() - .filter(|cmd| !DEFAULT_COMMAND_EXECUTION_ALLOWLIST.contains(cmd)) - .cloned() - .collect(), - directory_allowlist: ai_settings.agent_mode_coding_file_read_allowlist.clone(), - ..Default::default() - } - } - - #[cfg(feature = "agent_mode_evals")] - pub fn create_agent_mode_eval_profile() -> Self { - Self { - name: "Agent Mode Eval".to_string(), - is_default_profile: false, - apply_code_diffs: ActionPermission::AlwaysAllow, - read_files: ActionPermission::AlwaysAllow, - execute_commands: ActionPermission::AlwaysAllow, - write_to_pty: WriteToPtyPermission::AlwaysAllow, - mcp_permissions: ActionPermission::AlwaysAllow, - ask_user_question: AskUserQuestionPermission::Never, - run_agents: RunAgentsPermission::AlwaysAllow, - command_denylist: Vec::new(), - command_allowlist: Vec::new(), - directory_allowlist: Vec::new(), - mcp_allowlist: Vec::new(), - mcp_denylist: Vec::new(), - computer_use: ComputerUsePermission::Never, - base_model: None, - coding_model: None, - cli_agent_model: None, - computer_use_model: None, - context_window_limit: None, - autosync_plans_to_warp_drive: false, - web_search_enabled: true, - } - } - - /// This creates a CLI-specific profile that will never ask the user for permission, - /// since we cannot do so in a non-interactive setting. - pub fn create_default_cli_profile( - is_sandboxed: bool, - computer_use_override: Option, - ) -> Self { - let command_denylist = if is_sandboxed { - Vec::new() - } else { - DEFAULT_COMMAND_EXECUTION_DENYLIST.to_vec() +/// Resolves the effective cloud agent computer use state by reading the workspace +/// autonomy setting and user's local preference from their respective singletons. +pub fn resolve_cloud_agent_computer_use_state(ctx: &AppContext) -> CloudAgentComputerUseState { + if !FeatureFlag::AgentModeComputerUse.is_enabled() { + return CloudAgentComputerUseState { + enabled: false, + is_forced_by_org: false, }; - - let computer_use_permission = match computer_use_override { - Some(true) => { - if is_sandboxed || FeatureFlag::LocalComputerUse.is_enabled() { - ComputerUsePermission::AlwaysAllow - } else { - ComputerUsePermission::Never - } - } - Some(false) => ComputerUsePermission::Never, - None => { - if is_sandboxed && ChannelState::channel().is_dogfood() { - ComputerUsePermission::AlwaysAllow - } else { - ComputerUsePermission::Never - } - } - }; - - Self { - name: "Default (CLI)".to_owned(), - is_default_profile: true, - apply_code_diffs: ActionPermission::AlwaysAllow, - read_files: ActionPermission::AlwaysAllow, - execute_commands: ActionPermission::AlwaysAllow, - mcp_permissions: ActionPermission::AlwaysAllow, - write_to_pty: WriteToPtyPermission::AlwaysAllow, - ask_user_question: AskUserQuestionPermission::Never, - run_agents: RunAgentsPermission::AlwaysAllow, - command_denylist, - command_allowlist: DEFAULT_COMMAND_EXECUTION_ALLOWLIST.to_vec(), - directory_allowlist: Vec::new(), - mcp_allowlist: Vec::new(), - mcp_denylist: Vec::new(), - computer_use: computer_use_permission, - base_model: None, - coding_model: None, - cli_agent_model: None, - computer_use_model: None, - context_window_limit: None, - autosync_plans_to_warp_drive: FeatureFlag::SyncAmbientPlans.is_enabled(), - web_search_enabled: true, - } } -} -impl AIExecutionProfile { - pub fn configurable_context_window(&self, app: &AppContext) -> Option { + let autonomy_setting = UserWorkspaces::as_ref(ctx) + .ai_autonomy_settings() + .computer_use_setting; + let user_preference = *AISettings::as_ref(ctx).cloud_agent_computer_use_enabled; + + match autonomy_setting { + Some(ComputerUsePermission::Never) => CloudAgentComputerUseState { + enabled: false, + is_forced_by_org: true, + }, + Some(ComputerUsePermission::AlwaysAllow) => CloudAgentComputerUseState { + enabled: true, + is_forced_by_org: true, + }, + // TODO(QUALITY-297): Currently this case should never be hit because the + // AlwaysAsk variant isn't accessible in the admin console. We need to figure + // out how to handle it when it eventually becomes available. For now, I'm + // treating this conservatively and marking computer use as disabled. + Some(ComputerUsePermission::AlwaysAsk) => CloudAgentComputerUseState { + enabled: false, + is_forced_by_org: true, + }, + Some(ComputerUsePermission::Unknown) | None => CloudAgentComputerUseState { + enabled: user_preference, + is_forced_by_org: false, + }, + } +} + +pub fn create_default_from_legacy_settings(app: &AppContext) -> AIExecutionProfile { + // Note that the legacy "Autonomy" and "Code Access" settings are not imported here. + // The "Code Access" setting defaulted to "Always Ask", which is the most restrictive, so + // it's impossible for us to infer some hesitancy about autonomy from the setting and we should + // ignore it. The same applies to "Autonomy". + let ai_settings = AISettings::as_ref(app); + AIExecutionProfile { + name: "Default".to_string(), + is_default_profile: true, + command_denylist: ai_settings.agent_mode_command_execution_denylist.clone(), + // We initialize the command allowlist to be anything the user added, excluding all + // the pre-populated defaults. + command_allowlist: ai_settings + .agent_mode_command_execution_allowlist + .iter() + .filter(|cmd| !DEFAULT_COMMAND_EXECUTION_ALLOWLIST.contains(cmd)) + .cloned() + .collect(), + directory_allowlist: ai_settings.agent_mode_coding_file_read_allowlist.clone(), + ..Default::default() + } +} + +pub trait AIExecutionProfileAppExt { + fn configurable_context_window(&self, app: &AppContext) -> Option; + + fn context_window_display_value(&self, app: &AppContext) -> Option; +} + +impl AIExecutionProfileAppExt for AIExecutionProfile { + fn configurable_context_window(&self, app: &AppContext) -> Option { let prefs = LLMPreferences::as_ref(app); let cw = self .base_model @@ -459,16 +113,12 @@ impl AIExecutionProfile { } } - pub fn context_window_display_value(&self, app: &AppContext) -> Option { + fn context_window_display_value(&self, app: &AppContext) -> Option { let cw = self.configurable_context_window(app)?; Some(self.context_window_limit.unwrap_or(cw.default_max)) } } -pub type CloudAIExecutionProfile = - GenericCloudObject; -pub type CloudAIExecutionProfileModel = GenericStringModel; - impl StringModel for AIExecutionProfile { type CloudObjectType = CloudAIExecutionProfile; diff --git a/app/src/ai/execution_profiles/profiles.rs b/app/src/ai/execution_profiles/profiles.rs index 06361add83..99ec291cda 100644 --- a/app/src/ai/execution_profiles/profiles.rs +++ b/app/src/ai/execution_profiles/profiles.rs @@ -10,7 +10,8 @@ use warp_core::user_preferences::GetUserPreferences; use warpui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity}; use super::{ - AIExecutionProfile, ActionPermission, CloudAIExecutionProfileModel, WriteToPtyPermission, + create_default_from_legacy_settings, AIExecutionProfile, ActionPermission, + CloudAIExecutionProfileModel, WriteToPtyPermission, }; use crate::ai::llms::LLMId; use crate::ai::mcp::templatable_manager::TemplatableMCPServerManagerEvent; @@ -166,7 +167,7 @@ impl AIExecutionProfilesModel { } None => DefaultProfileState::Unsynced { id: ClientProfileId::new(), - profile: AIExecutionProfile::create_default_from_legacy_settings(ctx), + profile: create_default_from_legacy_settings(ctx), }, }, // When running as a CLI, we ignore the GUI default and use a more permissive default. @@ -182,7 +183,7 @@ impl AIExecutionProfilesModel { // exhaustively. LaunchMode::RemoteServerProxy | LaunchMode::RemoteServerDaemon { .. } => DefaultProfileState::Unsynced { id: ClientProfileId::new(), - profile: AIExecutionProfile::create_default_from_legacy_settings(ctx), + profile: create_default_from_legacy_settings(ctx), }, }; } diff --git a/app/src/ai/facts/mod.rs b/app/src/ai/facts/mod.rs index c66c9d777d..7527108801 100644 --- a/app/src/ai/facts/mod.rs +++ b/app/src/ai/facts/mod.rs @@ -1,14 +1,10 @@ -use serde::{Deserialize, Serialize}; +pub use cloud_object_models::{AIFact, AIMemory, CloudAIFact, CloudAIFactModel}; use warp_core::ui::appearance::Appearance; -use crate::ai::agent::SuggestedLoggingId; -use crate::cloud_object::model::generic_string_model::{ - GenericStringModel, GenericStringObjectId, StringModel, -}; -use crate::cloud_object::model::json_model::{JsonModel, JsonSerializer}; +use crate::cloud_object::model::generic_string_model::StringModel; +use crate::cloud_object::model::json_model::JsonModel; use crate::cloud_object::{ - GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, - Revision, + GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, Revision, }; use crate::drive::items::ai_fact::WarpDriveAIFact; use crate::drive::items::WarpDriveItem; @@ -21,35 +17,6 @@ pub mod view; pub use manager::AIFactManager; pub use view::{AIFactView, AIFactViewEvent}; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum AIFact { - #[serde(rename = "memory")] - Memory(AIMemory), -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AIMemory { - #[serde(default)] - pub name: Option, - pub content: String, - // Deprecated: This field is no longer used and will be removed in the future. - #[serde(default)] - pub is_autogenerated: bool, - /// If this rule was created from a suggested rule, record the suggestion's logging_id - /// so we can suppress re-surfacing the same suggestion in future responses. - #[serde(default)] - pub suggested_logging_id: Option, -} - -impl AIFact { - pub fn is_memory(&self) -> bool { - matches!(self, AIFact::Memory { .. }) - } -} - -pub type CloudAIFact = GenericCloudObject; -pub type CloudAIFactModel = GenericStringModel; - impl StringModel for AIFact { type CloudObjectType = CloudAIFact; diff --git a/app/src/ai/mcp/mod.rs b/app/src/ai/mcp/mod.rs index 2d83c73974..78c3b95c95 100644 --- a/app/src/ai/mcp/mod.rs +++ b/app/src/ai/mcp/mod.rs @@ -1,23 +1,19 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -#[cfg(not(target_family = "wasm"))] use chrono::DateTime; #[cfg(not(target_family = "wasm"))] use diesel::{QueryDsl, RunQueryDsl, SqliteConnection}; -use serde::{Deserialize, Serialize}; use strum::IntoEnumIterator; use strum_macros::EnumIter; use warp_core::ui::appearance::Appearance; use warp_core::ui::Icon; -use crate::cloud_object::model::generic_string_model::{ - GenericStringModel, GenericStringObjectId, StringModel, -}; -use crate::cloud_object::model::json_model::{JsonModel, JsonSerializer}; +use crate::cloud_object::model::generic_string_model::StringModel; +use crate::cloud_object::model::json_model::JsonModel; use crate::cloud_object::{ - CloudObjectUuid, GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey, - JsonObjectType, Revision, + CloudObjectUuid, GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, + Revision, }; use crate::drive::items::mcp_server::WarpDriveMCPServer; use crate::drive::items::WarpDriveItem; @@ -25,6 +21,7 @@ use crate::drive::CloudObjectTypeAndId; #[cfg(not(target_family = "wasm"))] use crate::persistence::model::MCPEnvironmentVariables; #[cfg(not(target_family = "wasm"))] +#[cfg(not(target_family = "wasm"))] use crate::server::datetime_ext::DateTimeExt; use crate::server::ids::SyncId; use crate::server::sync_queue::QueueItem; @@ -56,6 +53,10 @@ cfg_if::cfg_if! { pub mod gallery; pub use gallery::MCPGalleryManager; pub mod templatable; +pub use cloud_object_models::{ + CLIServer, CloudMCPServer, CloudMCPServerModel, JSONMCPServer, JSONTransportType, MCPServer, + MCPServerState, ServerSentEvents, StaticEnvVar, StaticHeader, TransportType, +}; pub use templatable::{JsonTemplate, TemplatableMCPServer, TemplateVariable}; pub mod logs; pub mod templatable_installation; @@ -69,97 +70,6 @@ pub mod http_client; #[cfg(not(target_family = "wasm"))] pub mod reconnecting_peer; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[cfg_attr(target_family = "wasm", expect(dead_code))] -pub struct JSONMCPServer { - #[serde(flatten)] - pub transport_type: JSONTransportType, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum JSONTransportType { - CLIServer { - command: String, - #[serde(default)] - args: Vec, - #[serde(default)] - env: HashMap, - #[serde(default)] - working_directory: Option, - }, - SSEServer { - #[serde(alias = "serverUrl")] - url: String, - #[serde(default)] - headers: HashMap, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MCPServer { - pub transport_type: TransportType, - pub name: String, - #[serde(default)] - pub uuid: uuid::Uuid, -} - -#[derive(Debug, Clone, Copy)] -#[cfg_attr(target_family = "wasm", allow(dead_code))] -pub enum MCPServerState { - NotRunning, - Starting, - Authenticating, - Running, - ShuttingDown, - FailedToStart, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum TransportType { - CLIServer(CLIServer), - ServerSentEvents(ServerSentEvents), -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CLIServer { - pub command: String, - #[serde(default)] - pub args: Vec, - pub cwd_parameter: Option, - /// Static env vars added via editor inputs. - pub static_env_vars: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct StaticEnvVar { - pub name: String, - /// To avoid leaking environment variables, we ensure that values are not - /// serialized before being sent to our servers - #[serde(skip_serializing, default)] - pub value: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct StaticHeader { - pub name: String, - /// To avoid leaking header values (which may contain secrets), we ensure that values are not - /// serialized before being sent to our servers - #[serde(skip_serializing, default)] - pub value: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ServerSentEvents { - pub url: String, - /// Static headers added via editor inputs. - #[serde(default)] - pub headers: Vec, -} - -pub type CloudMCPServer = GenericCloudObject; -pub type CloudMCPServerModel = GenericStringModel; - impl CloudObjectUuid for MCPServer { fn uuid(&self) -> uuid::Uuid { self.uuid @@ -289,6 +199,7 @@ fn items_from_hashmap(map: &HashMap) -> Vec /// Converts a slice of name/value pair items to a HashMap. #[cfg(not(target_family = "wasm"))] +#[allow(dead_code)] fn items_to_hashmap(items: &[T]) -> HashMap { items .iter() @@ -344,53 +255,65 @@ fn apply_values(items: &mut [T], values: &HashMap serde_json::Result> { - // We want to be quite permissive in parsing user input. They may specify more than one - // server. They might paste things in Claude Desktop style or VSCode style. All are - // accepted here. - // - // VSCode: - // { - // "mcp": { - // "servers": { - // [map of mcp servers] - // } - // } - // } - // --- OR --- - // { - // "servers": { - // [map of mcp servers] - // } - // } - // - // Claude Desktop: - // { - // "mcpServers": { - // [map of mcp servers] - // } - // } - // Also allowed: - // { - // [map of mcp servers] - // } - - let pointers = ["/mcp/servers", "/servers", "/mcpServers"]; - for pointer in pointers.into_iter() { - if let Some(value) = config.pointer(pointer) { - if let Ok(servers) = - serde_json::from_value::>(value.clone()) - { - return Ok(servers); - } +fn find_server_map( + config: serde_json::Value, +) -> serde_json::Result> { + // We want to be quite permissive in parsing user input. They may specify more than one + // server. They might paste things in Claude Desktop style or VSCode style. All are + // accepted here. + // + // VSCode: + // { + // "mcp": { + // "servers": { + // [map of mcp servers] + // } + // } + // } + // --- OR --- + // { + // "servers": { + // [map of mcp servers] + // } + // } + // + // Claude Desktop: + // { + // "mcpServers": { + // [map of mcp servers] + // } + // } + // Also allowed: + // { + // [map of mcp servers] + // } + + let pointers = ["/mcp/servers", "/servers", "/mcpServers"]; + for pointer in pointers.into_iter() { + if let Some(value) = config.pointer(pointer) { + if let Ok(servers) = + serde_json::from_value::>(value.clone()) + { + return Ok(servers); } } - serde_json::from_value::>(config) } - pub fn from_user_json(json: &str) -> serde_json::Result> { + serde_json::from_value::>(config) +} + +pub trait MCPServerExt { + fn from_user_json(json: &str) -> serde_json::Result>; + #[allow(dead_code)] + fn to_user_json(&self) -> String; + fn to_parsed_templatable_mcp_server_result(&self) -> ParsedTemplatableMCPServerResult; + + #[cfg(not(target_family = "wasm"))] + fn fill_environment_variables(&mut self, conn: &mut SqliteConnection); +} + +#[cfg(not(target_family = "wasm"))] +impl MCPServerExt for MCPServer { + fn from_user_json(json: &str) -> serde_json::Result> { // Some docs don't show curly braces around the json object, so add them if necessary. let json = json.trim(); let json = if json.starts_with("{") { @@ -401,7 +324,7 @@ impl MCPServer { let config: serde_json::Value = serde_json::from_str(&json)?; - let servers = Self::find_server_map(config)?; + let servers = find_server_map(config)?; Ok(servers .iter() .map(|(name, server)| { @@ -435,7 +358,7 @@ impl MCPServer { /// Includes the environment variable values, should only be shown to users, /// not sent to our servers. - pub fn to_user_json(&self) -> String { + fn to_user_json(&self) -> String { let transport_type = match &self.transport_type { TransportType::CLIServer(cli_server) => JSONTransportType::CLIServer { command: cli_server.command.clone(), @@ -460,7 +383,7 @@ impl MCPServer { }) } - pub fn to_parsed_templatable_mcp_server_result(&self) -> ParsedTemplatableMCPServerResult { + fn to_parsed_templatable_mcp_server_result(&self) -> ParsedTemplatableMCPServerResult { let (transport_type, variables, variable_values) = match &self.transport_type { TransportType::CLIServer(cli_server) => { let (env, vars, vals) = extract_template_variables(&cli_server.static_env_vars); @@ -520,7 +443,7 @@ impl MCPServer { } } - pub fn fill_environment_variables(&mut self, conn: &mut SqliteConnection) { + fn fill_environment_variables(&mut self, conn: &mut SqliteConnection) { if let TransportType::CLIServer(ref mut cli_server) = self.transport_type { let uuid = self.uuid.as_bytes().to_vec(); match crate::persistence::schema::mcp_environment_variables::dsl::mcp_environment_variables @@ -541,16 +464,16 @@ impl MCPServer { } #[cfg(target_family = "wasm")] -impl MCPServer { - pub fn from_user_json(_json: &str) -> serde_json::Result> { +impl MCPServerExt for MCPServer { + fn from_user_json(_json: &str) -> serde_json::Result> { Ok(Vec::new()) } - pub fn to_user_json(&self) -> String { + fn to_user_json(&self) -> String { Default::default() } - pub fn to_parsed_templatable_mcp_server_result(&self) -> ParsedTemplatableMCPServerResult { + fn to_parsed_templatable_mcp_server_result(&self) -> ParsedTemplatableMCPServerResult { ParsedTemplatableMCPServerResult { templatable_mcp_server: TemplatableMCPServer::default(), templatable_mcp_server_installation: None, diff --git a/app/src/ai/mcp/mod_tests.rs b/app/src/ai/mcp/mod_tests.rs index 8307e9a729..2416469e81 100644 --- a/app/src/ai/mcp/mod_tests.rs +++ b/app/src/ai/mcp/mod_tests.rs @@ -7,7 +7,7 @@ use warp_managed_secrets::ManagedSecretValue; use crate::ai::mcp::parsing::normalize_codex_toml_to_json; use crate::ai::mcp::parsing::resolve_json; use crate::ai::mcp::{ - mcp_provider_from_file_path, CLIServer, JsonTemplate, MCPProvider, MCPServer, + mcp_provider_from_file_path, CLIServer, JsonTemplate, MCPProvider, MCPServer, MCPServerExt, ParsedTemplatableMCPServerResult, ServerSentEvents, StaticEnvVar, StaticHeader, TemplatableMCPServer, TemplatableMCPServerInstallation, TemplateVariable, TransportType, VariableType, VariableValue, @@ -24,67 +24,6 @@ fn mcp_provider_from_file_path_recognizes_warp_home_path() { } } -#[test] -fn test_mcp_server_config_serialization_excludes_secret_env_values() { - // Create a CLI server with environment variables containing secrets - let cli_server = CLIServer { - command: "npx".to_string(), - args: vec!["@modelcontextprotocol/server-postgres".to_string()], - cwd_parameter: Some("/tmp".to_string()), - static_env_vars: vec![ - StaticEnvVar { - name: "API_KEY".to_string(), - value: "SOME_LEAKED_SECRET".to_string(), - }, - StaticEnvVar { - name: "DATABASE_URL".to_string(), - value: "postgresql://user:password@localhost/db".to_string(), - }, - StaticEnvVar { - name: "PUBLIC_CONFIG".to_string(), - value: "not-secret-value".to_string(), - }, - ], - }; - - let mcp_server = MCPServer { - transport_type: TransportType::CLIServer(cli_server), - name: "test-server".to_string(), - uuid: uuid::Uuid::new_v4(), - }; - - // Test direct serde serialization - let serialized = serde_json::to_string(&mcp_server).expect("Failed to serialize MCP server"); - - // The serialized config should NOT contain the secret values - assert!( - !serialized.contains("SOME_LEAKED_SECRET"), - "Serialized config contains leaked secret value: {serialized}", - ); - assert!( - !serialized.contains("password"), - "Serialized config contains password: {serialized}", - ); - assert!( - !serialized.contains("not-secret-value"), - "Serialized config contains env var value: {serialized}", - ); - - // But should contain the environment variable names/keys - assert!( - serialized.contains("API_KEY"), - "Serialized config should contain env var key 'API_KEY': {serialized}", - ); - assert!( - serialized.contains("DATABASE_URL"), - "Serialized config should contain env var key 'DATABASE_URL': {serialized}", - ); - assert!( - serialized.contains("PUBLIC_CONFIG"), - "Serialized config should contain env var key 'PUBLIC_CONFIG': {serialized}", - ); -} - /// Helper function to create a test TemplatableMCPServerInstallation with custom values fn create_test_installation( name: &str, @@ -131,65 +70,6 @@ fn create_test_installation( ) } -#[test] -fn test_static_env_var_direct_serialization() { - // Test direct serialization of StaticEnvVar to ensure skip_serializing works - let env_var = StaticEnvVar { - name: "TEST_SECRET".to_string(), - value: "SOME_LEAKED_SECRET".to_string(), - }; - - let serialized = serde_json::to_string(&env_var).expect("Failed to serialize env var"); - - // Should contain the name but not the value due to skip_serializing - assert!( - serialized.contains("TEST_SECRET"), - "Serialized env var should contain name: {serialized}", - ); - assert!( - !serialized.contains("SOME_LEAKED_SECRET"), - "Serialized env var should not contain value due to skip_serializing: {serialized}", - ); -} - -#[test] -fn test_static_env_var_deserialization_with_default() { - // Test that StaticEnvVar can be deserialized properly with default value - let json = r#"{"name": "API_KEY"}"#; - - let env_var: StaticEnvVar = serde_json::from_str(json).expect("Failed to deserialize env var"); - - assert_eq!(env_var.name, "API_KEY"); - assert_eq!(env_var.value, ""); // Should default to empty string -} - -#[test] -fn test_sse_server_serialization() { - // Test that ServerSentEvents transport type serializes correctly - let sse_server = ServerSentEvents { - url: "https://example.com/sse".to_string(), - headers: Default::default(), - }; - - let mcp_server = MCPServer { - transport_type: TransportType::ServerSentEvents(sse_server), - name: "sse-server".to_string(), - uuid: uuid::Uuid::new_v4(), - }; - - let serialized = serde_json::to_string(&mcp_server).expect("Failed to serialize MCP server"); - - // Should contain the URL since it's not a secret field - assert!( - serialized.contains("https://example.com/sse"), - "Serialized SSE server should contain URL: {serialized}", - ); - assert!( - serialized.contains("sse-server"), - "Serialized SSE server should contain name: {serialized}", - ); -} - #[test] fn test_sse_server_with_headers() { // Test that ServerSentEvents transport type with headers serializes correctly diff --git a/app/src/ai/mcp/templatable.rs b/app/src/ai/mcp/templatable.rs index ebd3eb4115..037de1f243 100644 --- a/app/src/ai/mcp/templatable.rs +++ b/app/src/ai/mcp/templatable.rs @@ -1,208 +1,21 @@ -use std::collections::HashMap; - -use chrono::DateTime; -use handlebars::get_arguments; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; +pub use cloud_object_models::{ + CloudTemplatableMCPServer, CloudTemplatableMCPServerModel, GalleryData, JsonTemplate, + TemplatableMCPServer, TemplateVariable, +}; use warp_core::ui::appearance::Appearance; -use crate::cloud_object::model::generic_string_model::{ - GenericStringModel, GenericStringObjectId, StringModel, -}; -use crate::cloud_object::model::json_model::{JsonModel, JsonSerializer}; +use crate::cloud_object::model::generic_string_model::StringModel; +use crate::cloud_object::model::json_model::JsonModel; use crate::cloud_object::{ - CloudObjectUuid, GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey, - JsonObjectType, Revision, UniquePer, + CloudObjectUuid, GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, + Revision, UniquePer, }; use crate::drive::items::WarpDriveItem; -use crate::server::datetime_ext::DateTimeExt; use crate::server::ids::SyncId; use crate::server::sync_queue::QueueItem; const UNIQUENESS_KEY_PREFIX: &str = "templatable_mcp_server"; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, Hash)] -pub struct JsonTemplate { - pub json: String, - pub variables: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] -pub struct TemplateVariable { - pub key: String, - /// When present, the variable should be filled via a dropdown of these values - /// instead of a freetext input. - #[serde(default)] - pub allowed_values: Option>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct GalleryData { - pub gallery_item_id: Uuid, - pub version: i32, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct TemplatableMCPServer { - pub uuid: uuid::Uuid, - pub name: String, - pub description: Option, - pub template: JsonTemplate, - #[serde(default)] - pub version: i64, // This will default to 0 if stored objects have no version - pub gallery_data: Option, -} - -#[derive(Debug)] -pub enum FromStoredJsonError { - NoServersFound, - TooManyServersFound, - ParseError(serde_json::Error), -} - -impl TemplatableMCPServer { - /// Looks for MCP servers under known wrapper keys (`mcpServers`, `servers`, - /// `mcp.servers`, `mcp_servers`). Returns `None` if no known key is found. - fn find_servers_under_known_keys( - config: &serde_json::Value, - ) -> Option> { - const POINTERS: [&str; 4] = ["/mcp/servers", "/servers", "/mcpServers", "/mcp_servers"]; - for pointer in POINTERS { - if let Some(value) = config.pointer(pointer) { - if let Ok(servers) = - serde_json::from_value::>(value.clone()) - { - return Some(servers); - } - } - } - None - } - - /// Permissively parses MCP servers from JSON. - /// - /// Accepts servers under known wrapper keys (VSCode, Claude Desktop, etc.) - /// and also falls back to treating the entire object as a bare server map. - /// This is appropriate for user-pasted input. - pub fn find_template_map( - config: serde_json::Value, - ) -> serde_json::Result> { - if let Some(servers) = Self::find_servers_under_known_keys(&config) { - return Ok(servers); - } - // Fallback: treat the entire object as a bare map of servers. - serde_json::from_value::>(config) - } - - /// Like [`find_template_map`], but without the bare-object fallback. - /// - /// Returns servers only when found under a known wrapper key. This prevents - /// misinterpreting unrelated JSON files (e.g. Claude Code's `~/.claude.json` - /// settings) as MCP config. - pub fn find_template_map_strict( - config: &serde_json::Value, - ) -> HashMap { - Self::find_servers_under_known_keys(config).unwrap_or_default() - } - - pub fn to_user_json(&self) -> String { - let value: serde_json::Value = serde_json::from_str(&self.template.json) - // All templates should be valid JSON - this should never fail - // Ones that are not should not have been saved in the first place - .unwrap_or_else(|err| { - log::error!("Could not parse MCP server template to json: {err:?}"); - Default::default() - }); - - serde_json::to_string_pretty(&value) - // serde_json::to_string_pretty should never fail on this value since we just parsed it as valid json - .unwrap_or_else(|err| { - log::error!("Could not serialize MCP server to user json: {err:?}"); - Default::default() - }) - } - - // Uses from_user_json to parse the json and then returns the first TemplatableMCPServer - // This is meant to be used for stored json from the database, which should only contain - // a single server and already checked for json validity - pub fn from_stored_json( - json: &str, - uuid: uuid::Uuid, - ) -> Result { - let templates = Self::from_user_json(json); - match templates { - Ok(templates) => { - if templates.is_empty() { - // This should never happen for stored json from the database - log::error!("No templatable MCP servers found in stored json: {uuid}"); - Err(FromStoredJsonError::NoServersFound) - } else if templates.len() > 1 { - Err(FromStoredJsonError::TooManyServersFound) - } else { - // templates should always contain exactly one server for stored json from the database - let mut templatable_mcp_server = templates[0].clone(); - templatable_mcp_server.uuid = uuid; - Ok(templatable_mcp_server) - } - } - Err(err) => Err(FromStoredJsonError::ParseError(err)), - } - } - - pub fn from_user_json(json: &str) -> serde_json::Result> { - // Some docs don't show curly braces around the json object, so add them if necessary. - let json = json.trim(); - let json = if json.starts_with("{") { - json.to_owned() - } else { - format!("{{{json}}}") - }; - - let config: serde_json::Value = serde_json::from_str(&json)?; - - let template_jsons = Self::find_template_map(config)?; - Ok(template_jsons - .iter() - .map(|(name, json)| { - // Each template_json is the nested config for a single MCP server - // We need to re-wrap it in a top level object so that we can - // reuse from_user_json to read it later - let normalized_map = - serde_json::Map::from_iter(vec![(name.to_owned(), json.clone())]); - let normalized_json = serde_json::Value::Object(normalized_map).to_string(); - - let description: Option = json - .get("description") - .and_then(|value| value.as_str().map(|s| s.to_owned())); - let arguments = get_arguments(&normalized_json); - let variables = arguments - .iter() - .map(|argument| TemplateVariable { - key: argument.to_owned(), - allowed_values: None, - }) - .collect::>(); - - TemplatableMCPServer { - uuid: uuid::Uuid::new_v4(), - name: name.to_owned(), - description, - template: JsonTemplate { - json: normalized_json, - variables, - }, - version: DateTime::now().timestamp(), - gallery_data: None, - } - }) - .collect()) - } -} - -pub type CloudTemplatableMCPServer = - GenericCloudObject; -pub type CloudTemplatableMCPServerModel = GenericStringModel; - impl CloudObjectUuid for TemplatableMCPServer { fn uuid(&self) -> uuid::Uuid { self.uuid diff --git a/app/src/ai/mcp/templatable_manager/native.rs b/app/src/ai/mcp/templatable_manager/native.rs index 67e1fd752c..64e50a67f8 100644 --- a/app/src/ai/mcp/templatable_manager/native.rs +++ b/app/src/ai/mcp/templatable_manager/native.rs @@ -38,8 +38,8 @@ use crate::ai::mcp::templatable_manager::oauth::{ use crate::ai::mcp::templatable_manager::FigmaMcpStatus; use crate::ai::mcp::{ logs, Author, CloudMCPServer, FileBasedMCPManager, JsonTemplate, MCPGalleryManager, MCPServer, - MCPServerUpdate, ParsedTemplatableMCPServerResult, StaticEnvVar, TemplatableMCPServer, - TemplatableMCPServerInstallation, TransportType, + MCPServerExt, MCPServerUpdate, ParsedTemplatableMCPServerResult, StaticEnvVar, + TemplatableMCPServer, TemplatableMCPServerInstallation, TransportType, }; use crate::auth::AuthStateProvider; use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent}; diff --git a/app/src/cloud_object/mod.rs b/app/src/cloud_object/mod.rs index 1e9fb426bb..ed3dac9caf 100644 --- a/app/src/cloud_object/mod.rs +++ b/app/src/cloud_object/mod.rs @@ -22,32 +22,19 @@ use self::model::generic_string_model::{ GenericStringModel, GenericStringObjectId, Serializer, StringModel, }; use self::model::persistence::CloudModel; -use crate::ai::ambient_agents::scheduled::CloudScheduledAmbientAgentModel; -use crate::ai::cloud_agent_config::CloudAgentConfigModel; -use crate::ai::cloud_environments::CloudAmbientAgentEnvironmentModel; -use crate::ai::document::ai_document_model::AIDocumentId; -use crate::ai::execution_profiles::CloudAIExecutionProfileModel; -use crate::ai::facts::CloudAIFactModel; -use crate::ai::mcp::templatable::CloudTemplatableMCPServerModel; -use crate::ai::mcp::CloudMCPServerModel; use crate::appearance::Appearance; use crate::auth::UserUid; use crate::channel::ChannelState; -use crate::drive::folders::{CloudFolderModel, FolderId}; use crate::drive::items::WarpDriveItem; use crate::drive::{CloudObjectTypeAndId, OpenWarpDriveObjectArgs, OpenWarpDriveObjectSettings}; -use crate::env_vars::CloudEnvVarCollectionModel; -use crate::notebooks::{CloudNotebookModel, NotebookId}; use crate::persistence::ModelEvent; use crate::server::cloud_objects::update_manager::InitiatedBy; use crate::server::ids::{HashableId, HashedSqliteId, ObjectUid, ServerId, SyncId, ToServerId}; use crate::server::server_api::object::ObjectClient; use crate::server::sync_queue::{QueueItem, SerializedModel}; -use crate::settings::cloud_preferences::CloudPreferenceModel; use crate::util::time_format::format_approx_duration_from_now_utc; -use crate::workflows::workflow_enum::CloudWorkflowEnumModel; -use crate::workflows::{CloudWorkflow, CloudWorkflowModel, WorkflowId, WorkflowSource}; -use crate::workspaces::user_profiles::{UserProfileWithUID, UserProfiles}; +use crate::workflows::{CloudWorkflow, WorkflowSource}; +use crate::workspaces::user_profiles::UserProfiles; use crate::workspaces::user_workspaces::UserWorkspaces; pub mod breadcrumbs; @@ -808,23 +795,6 @@ where } } -impl ServerObjectModel for CloudFolderModel { - fn object_type(&self) -> ObjectType { - ::object_type(self) - } -} - -impl ServerObjectModel for CloudNotebookModel { - fn object_type(&self) -> ObjectType { - ::object_type(self) - } -} - -impl ServerObjectModel for CloudWorkflowModel { - fn object_type(&self) -> ObjectType { - ::object_type(self) - } -} /// Extracts the server id and object type from a (caller validated) Drive link. /// Intended use is deriving metadata from links such that Warp objects /// can be opened natively in Warp with no web interaction. @@ -1007,253 +977,15 @@ fn get_top_folder_trashed_ts( None } -#[derive(Clone, Debug)] -pub enum ObjectPermissionUpdateResult { - Success, // TODO: we should return the full permissions here - Failure, -} - -#[derive(Clone, Debug)] -pub struct ObjectPermissionsUpdateData { - /// Updated permissions for the modified object. - pub permissions: ServerPermissions, - /// Relevant user profiles for the permissions change. This is not *all* profiles that the user - /// should have access to. - pub profiles: Vec, -} - -#[derive(Clone, Debug)] -pub enum ObjectMetadataUpdateResult { - Success { metadata: Box }, - Failure, -} - -pub enum ObjectDeleteResult { - Success { deleted_ids: Vec }, - Failure, -} - -/// A cloud object from the server. -#[derive(Clone, Debug)] -pub enum ServerCloudObject { - Notebook(ServerNotebook), - Workflow(Box), - Folder(ServerFolder), - Preference(ServerPreference), - EnvVarCollection(ServerEnvVarCollection), - WorkflowEnum(ServerWorkflowEnum), - AIFact(ServerAIFact), - MCPServer(ServerMCPServer), - AIExecutionProfile(ServerAIExecutionProfile), - TemplatableMCPServer(ServerTemplatableMCPServer), - AmbientAgentEnvironment(ServerAmbientAgentEnvironment), - ScheduledAmbientAgent(ServerScheduledAmbientAgent), - CloudAgentConfig(ServerCloudAgentConfig), -} - -impl ServerCloudObject { - pub fn metadata(&self) -> &ServerMetadata { - match self { - ServerCloudObject::Notebook(notebook) => ¬ebook.metadata, - ServerCloudObject::Workflow(workflow) => &workflow.metadata, - ServerCloudObject::Folder(folder) => &folder.metadata, - ServerCloudObject::Preference(preferences) => &preferences.metadata, - ServerCloudObject::EnvVarCollection(env_var_collection) => &env_var_collection.metadata, - ServerCloudObject::WorkflowEnum(workflow_enum) => &workflow_enum.metadata, - ServerCloudObject::AIFact(aifact) => &aifact.metadata, - ServerCloudObject::MCPServer(mcp_server) => &mcp_server.metadata, - ServerCloudObject::TemplatableMCPServer(templatable_mcp_server) => { - &templatable_mcp_server.metadata - } - ServerCloudObject::AIExecutionProfile(ai_execution_profile) => { - &ai_execution_profile.metadata - } - ServerCloudObject::AmbientAgentEnvironment(ambient_agent_environment) => { - &ambient_agent_environment.metadata - } - ServerCloudObject::ScheduledAmbientAgent(scheduled_ambient_agent) => { - &scheduled_ambient_agent.metadata - } - ServerCloudObject::CloudAgentConfig(cloud_agent_config) => &cloud_agent_config.metadata, - } - } - - pub fn uid(&self) -> ObjectUid { - match self { - ServerCloudObject::Notebook(notebook) => notebook.id.uid(), - ServerCloudObject::Workflow(workflow) => workflow.id.uid(), - ServerCloudObject::Folder(folder) => folder.id.uid(), - ServerCloudObject::Preference(preferences) => preferences.id.uid(), - ServerCloudObject::EnvVarCollection(env_var_collection) => env_var_collection.id.uid(), - ServerCloudObject::WorkflowEnum(workflow_enum) => workflow_enum.id.uid(), - ServerCloudObject::AIFact(aifact) => aifact.id.uid(), - ServerCloudObject::MCPServer(mcp_server) => mcp_server.id.uid(), - ServerCloudObject::AIExecutionProfile(ai_execution_profile) => { - ai_execution_profile.id.uid() - } - ServerCloudObject::TemplatableMCPServer(templatable_mcp_server) => { - templatable_mcp_server.id.uid() - } - ServerCloudObject::AmbientAgentEnvironment(ambient_agent_environment) => { - ambient_agent_environment.id.uid() - } - ServerCloudObject::ScheduledAmbientAgent(scheduled_ambient_agent) => { - scheduled_ambient_agent.id.uid() - } - ServerCloudObject::CloudAgentConfig(cloud_agent_config) => cloud_agent_config.id.uid(), - } - } -} - -impl From<&GenericServerObject> for ServerCloudObject -where - K: HashableId + ToServerId + Debug + Into + Clone + 'static, - M: CloudModelType + 'static, -{ - fn from(value: &GenericServerObject) -> Self { - let value = value as &dyn Any; - if let Some(server_notebook) = value.downcast_ref::() { - ServerCloudObject::Notebook(server_notebook.clone()) - } else if let Some(server_workflow) = value.downcast_ref::() { - ServerCloudObject::Workflow(Box::new(server_workflow.clone())) - } else if let Some(server_folder) = value.downcast_ref::() { - ServerCloudObject::Folder(server_folder.clone()) - } else if let Some(server_preferences) = value.downcast_ref::() { - ServerCloudObject::Preference(server_preferences.clone()) - } else if let Some(server_env_var_collection) = - value.downcast_ref::() - { - ServerCloudObject::EnvVarCollection(server_env_var_collection.clone()) - } else if let Some(server_workflow_enum) = value.downcast_ref::() { - ServerCloudObject::WorkflowEnum(server_workflow_enum.clone()) - } else if let Some(server_aifact) = value.downcast_ref::() { - ServerCloudObject::AIFact(server_aifact.clone()) - } else if let Some(server_mcp_server) = value.downcast_ref::() { - ServerCloudObject::MCPServer(server_mcp_server.clone()) - } else if let Some(server_ai_execution_profile) = - value.downcast_ref::() - { - ServerCloudObject::AIExecutionProfile(server_ai_execution_profile.clone()) - } else if let Some(server_templatable_mcp_server) = - value.downcast_ref::() - { - ServerCloudObject::TemplatableMCPServer(server_templatable_mcp_server.clone()) - } else if let Some(server_ambient_agent_environment) = - value.downcast_ref::() - { - ServerCloudObject::AmbientAgentEnvironment(server_ambient_agent_environment.clone()) - } else if let Some(server_scheduled_ambient_agent) = - value.downcast_ref::() - { - ServerCloudObject::ScheduledAmbientAgent(server_scheduled_ambient_agent.clone()) - } else if let Some(server_cloud_agent_config) = - value.downcast_ref::() - { - ServerCloudObject::CloudAgentConfig(server_cloud_agent_config.clone()) - } else { - panic!("Unknown server object type"); - } - } -} - -pub type ServerPreference = GenericServerObject; -pub type ServerFolder = GenericServerObject; -pub type ServerWorkflow = GenericServerObject; -pub type ServerNotebook = GenericServerObject; -pub type ServerEnvVarCollection = - GenericServerObject; -pub type ServerWorkflowEnum = GenericServerObject; -pub type ServerAIFact = GenericServerObject; -pub type ServerMCPServer = GenericServerObject; -pub type ServerAIExecutionProfile = - GenericServerObject; -pub type ServerTemplatableMCPServer = - GenericServerObject; -pub type ServerAmbientAgentEnvironment = - GenericServerObject; -pub type ServerScheduledAmbientAgent = - GenericServerObject; -pub type ServerCloudAgentConfig = GenericServerObject; - -/// Tries to convert a GraphQL object payload into a local server object. -pub trait TryFromGql: Sized { - type GqlType; - - fn try_from_gql(value: Self::GqlType) -> Result; -} - -impl TryFromGql for GenericServerObject> -where - T: StringModel< - CloudObjectType = GenericCloudObject>, - >, - S: Serializer, -{ - type GqlType = warp_graphql::generic_string_object::GenericStringObject; - - fn try_from_gql(value: Self::GqlType) -> Result { - let uid = ServerId::from_string_lossy(value.metadata.uid.inner()); - let model = GenericStringModel::::deserialize_owned(&value.serialized_model)?; - Ok(Self::new( - SyncId::ServerId(uid), - model, - value.metadata.try_into()?, - value.permissions.try_into()?, - )) - } -} - -impl TryFromGql for ServerFolder { - type GqlType = warp_graphql::folder::Folder; - - fn try_from_gql(value: Self::GqlType) -> Result { - let uid = ServerId::from_string_lossy(value.metadata.uid.inner()); - Ok(Self::new( - SyncId::ServerId(uid), - CloudFolderModel::new(&value.name, value.is_warp_pack), - value.metadata.try_into()?, - value.permissions.try_into()?, - )) - } -} - -impl TryFromGql for ServerNotebook { - type GqlType = warp_graphql::notebook::Notebook; - - fn try_from_gql(value: Self::GqlType) -> Result { - let uid = ServerId::from_string_lossy(value.metadata.uid.inner()); - let ai_document_id: Option = value - .ai_document_id - .map(|id| AIDocumentId::try_from(&id[..])) - .transpose()?; - Ok(Self::new( - SyncId::ServerId(uid), - CloudNotebookModel { - title: value.title, - data: value.data, - ai_document_id, - conversation_id: None, - }, - value.metadata.try_into()?, - value.permissions.try_into()?, - )) - } -} - -impl TryFromGql for ServerWorkflow { - type GqlType = warp_graphql::workflow::Workflow; - - fn try_from_gql(value: Self::GqlType) -> Result { - let uid = ServerId::from_string_lossy(value.metadata.uid.inner()); - let workflow = serde_json::from_str(value.data.as_str())?; - Ok(Self::new( - SyncId::ServerId(uid), - CloudWorkflowModel { data: workflow }, - value.metadata.try_into()?, - value.permissions.try_into()?, - )) - } -} +pub use cloud_object_client::{ + ObjectDeleteResult, ObjectMetadataUpdateResult, ObjectPermissionsUpdateData, +}; +pub use cloud_object_models::{ + ServerAIExecutionProfile, ServerAIFact, ServerAmbientAgentEnvironment, ServerCloudAgentConfig, + ServerCloudObject, ServerEnvVarCollection, ServerFolder, ServerMCPServer, ServerNotebook, + ServerPreference, ServerScheduledAmbientAgent, ServerTemplatableMCPServer, ServerWorkflow, + ServerWorkflowEnum, TryFromGql, +}; #[derive(Default, Clone, Copy, Debug, Eq, Derivative)] #[derivative(PartialEq, Hash)] diff --git a/app/src/cloud_object/model/actions.rs b/app/src/cloud_object/model/actions.rs index 8486f0be62..f08bb3ac14 100644 --- a/app/src/cloud_object/model/actions.rs +++ b/app/src/cloud_object/model/actions.rs @@ -1,6 +1,9 @@ use std::collections::HashMap; use chrono::{DateTime, Duration, Utc}; +pub use cloud_object_client::{ + ObjectAction, ObjectActionHistory, ObjectActionSubtype, ObjectActionType, +}; use warpui::{Entity, ModelContext, SingletonEntity}; use crate::persistence::model::PersistedObjectAction; @@ -8,180 +11,74 @@ use crate::server::ids::{parse_sqlite_id_to_uid, HashedSqliteId, ObjectUid}; pub enum ObjectActionsEvent {} -/// The type of action that occurred on an object, such as an execution, selection, so on -/// and so forth. -#[derive(Clone, Debug, PartialEq)] -pub enum ObjectActionType { - Execute, -} - -// In order to convert from a graphql type and from a SQLite read, the action type -// implements to_string(). -// -// Temporarily suppress clippy warnings about the `ToString` impl until we -// move `ObjectType` away from using `std::fmt::Display` for serialization. -#[allow(clippy::to_string_trait_impl)] -impl ToString for ObjectActionType { - fn to_string(&self) -> String { - match self { - ObjectActionType::Execute => String::from("EXECUTE"), - } - } -} - -impl ObjectActionType { - fn singular(&self) -> String { - match self { - ObjectActionType::Execute => "run".to_string(), +pub fn object_action_from_persisted(other: PersistedObjectAction) -> Result { + // Each persisted object action is either a single action or a bundled action. + // If there's any inconsistencies from the SQL row, we return an error. + let action_subtype = if let Some(count) = other.count { + let oldest_timestamp = other + .oldest_timestamp + .as_ref() + .map(|time| time.and_utc()) + .ok_or(())?; + let latest_timestamp = other + .latest_timestamp + .as_ref() + .map(|time| time.and_utc()) + .ok_or(())?; + + // When the db row is a bundled action, the processed_at_timestamp field refers + // to the latest processed_at_timestamp in the bundle. Because bundled actions come + // from the server, this is a value, not an option. + let latest_processed_at_timestamp = other + .processed_at_timestamp + .as_ref() + .map(|time| time.and_utc()) + .ok_or(())?; + ObjectActionSubtype::BundledActions { + count, + oldest_timestamp, + latest_timestamp, + latest_processed_at_timestamp, } - } - - fn plural(&self) -> String { - match self { - ObjectActionType::Execute => "runs".to_string(), - } - } -} - -/// We track object actions, both those that have been sent to the server and not, through this -/// type. A single ObjectAction represents an object_id, action pair and a subtype that contains data -/// about the action(s). Each ObjectAction either represents one action or a summary of identical actions -/// that occurred at different times. We summarize old actions in order to save memory footprint on the client. -#[derive(Clone, Debug, PartialEq)] -pub struct ObjectAction { - pub action_type: ObjectActionType, - pub uid: ObjectUid, - pub hashed_sqlite_id: HashedSqliteId, - // This action either represents one action or a consolidation of multiple actions. - pub action_subtype: ObjectActionSubtype, -} - -impl ObjectAction { - pub fn is_pending(&self) -> bool { - match self.action_subtype { - ObjectActionSubtype::SingleAction { pending, .. } => pending, - _ => false, + } else { + let timestamp = other + .timestamp + .as_ref() + .map(|time| time.and_utc()) + .ok_or(())?; + let pending = other.pending.ok_or(())?; + + // The processed_at_timestamp is still None when the action hasn't been synced. + let processed_at_timestamp = other + .processed_at_timestamp + .as_ref() + .map(|time| time.and_utc()); + ObjectActionSubtype::SingleAction { + timestamp, + data: other.data, + pending, + processed_at_timestamp, } - } -} - -impl TryFrom for ObjectAction { - type Error = (); - - fn try_from(other: PersistedObjectAction) -> Result { - // Each persisted object action is either a single action or a bundled action. - // If there's any inconsistencies from the SQL row, we return an error. - let action_subtype = if let Some(count) = other.count { - let oldest_timestamp = other - .oldest_timestamp - .as_ref() - .map(|time| time.and_utc()) - .ok_or(())?; - let latest_timestamp = other - .latest_timestamp - .as_ref() - .map(|time| time.and_utc()) - .ok_or(())?; - - // When the db row is a bundled action, the processed_at_timestamp field refers - // to the latest processed_at_timestamp in the bundle. Because bundled actions come - // from the server, this is a value, not an option. - let latest_processed_at_timestamp = other - .processed_at_timestamp - .as_ref() - .map(|time| time.and_utc()) - .ok_or(())?; - ObjectActionSubtype::BundledActions { - count, - oldest_timestamp, - latest_timestamp, - latest_processed_at_timestamp, - } - } else { - let timestamp = other - .timestamp - .as_ref() - .map(|time| time.and_utc()) - .ok_or(())?; - let pending = other.pending.ok_or(())?; - - // The processed_at_timestamp is still None when the action hasn't been synced. - let processed_at_timestamp = other - .processed_at_timestamp - .as_ref() - .map(|time| time.and_utc()); - ObjectActionSubtype::SingleAction { - timestamp, - data: other.data, - pending, - processed_at_timestamp, - } - }; - - // The object_sync_id stored in SQLite is the hashed id that's used to index into the ObjectActions - // model. - let hashed_object_id = other.hashed_object_id; - let action_type = match other.action.as_str() { - s if s == ObjectActionType::Execute.to_string() => ObjectActionType::Execute, - _ => return Err(()), - }; - - // NOTE: This is needed since we only store the sqlite hash, but we need the uid (the second part of the hash) - // to index into CloudModel and store the object actions in memory. - let uid = parse_sqlite_id_to_uid(hashed_object_id.clone())?; - - Ok(ObjectAction { - uid: uid.to_string(), - hashed_sqlite_id: hashed_object_id, - action_type, - action_subtype, - }) - } -} - -/// The server communicates the action history of an object via an "ObjectActionHistory" type that -/// contains the uid, a list of actions (single or bundled), and the timestamp of the most recent action -/// (which is redundant from the list of actions). We use this type to convert from the graphql layer into -/// an identical type the sync_queue and update_manager can pass around. -#[derive(Clone, Debug, PartialEq)] -pub struct ObjectActionHistory { - pub uid: ObjectUid, - pub hashed_sqlite_id: HashedSqliteId, - pub latest_processed_at_timestamp: DateTime, - pub actions: Vec, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum ObjectActionSubtype { - SingleAction { - // When the action occurred. - timestamp: DateTime, - - // When the action was processed by the server (used to order actions against eachother). - // None if the action has not been synced. - processed_at_timestamp: Option>, - - // A JSON representation of anything else we might want to track about the action. - // For example, the exit code of a workflow execution. - data: Option, - - // Whether or not this action has been successfully synced to the server. - pending: bool, - }, - BundledActions { - // The number of distinct actions that are coalesced into one entry here. - count: i32, - - // The timestamp of the oldest action within this bundle. - oldest_timestamp: DateTime, - - // The timestamp of the most recent action within the bundle. - latest_timestamp: DateTime, - - // The most recent processed_at timestamp contained in the bundle (used to order actions and determine - // how up-to-date the client's actions are.) - latest_processed_at_timestamp: DateTime, - }, + }; + + // The object_sync_id stored in SQLite is the hashed id that's used to index into the ObjectActions + // model. + let hashed_object_id = other.hashed_object_id; + let action_type = match other.action.as_str() { + s if s == ObjectActionType::Execute.to_string() => ObjectActionType::Execute, + _ => return Err(()), + }; + + // NOTE: This is needed since we only store the sqlite hash, but we need the uid (the second part of the hash) + // to index into CloudModel and store the object actions in memory. + let uid = parse_sqlite_id_to_uid(hashed_object_id.clone())?; + + Ok(ObjectAction { + uid: uid.to_string(), + hashed_sqlite_id: hashed_object_id, + action_type, + action_subtype, + }) } /// A singleton model representing the actions that have occurred on a per-object basis. These diff --git a/app/src/cloud_object/model/json_model.rs b/app/src/cloud_object/model/json_model.rs index e1eaae964a..e9513cde87 100644 --- a/app/src/cloud_object/model/json_model.rs +++ b/app/src/cloud_object/model/json_model.rs @@ -12,6 +12,7 @@ pub trait JsonModel: StringModel + Serialize + DeserializeOwned + 'static { fn json_object_type() -> JsonObjectType; } +#[allow(dead_code)] #[derive(Clone, Debug, PartialEq, Default)] pub struct JsonSerializer; diff --git a/app/src/drive/folders/mod.rs b/app/src/drive/folders/mod.rs index 30ebbec071..9adf6e9259 100644 --- a/app/src/drive/folders/mod.rs +++ b/app/src/drive/folders/mod.rs @@ -2,17 +2,18 @@ use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; -use warp_server_client::cloud_object::CloudObjectUpsertParams; +pub use cloud_object_models::{CloudFolder, CloudFolderModel}; // Re-exported from warp_server_client. pub use warp_server_client::ids::FolderId; +// Re-exported from warp_server_client. use super::items::folder::WarpDriveFolder; use super::items::WarpDriveItem; use super::CloudObjectTypeAndId; use crate::appearance::Appearance; use crate::cloud_object::{ - CloudModelType, CloudObjectEventEntrypoint, CreateCloudObjectResult, CreateObjectRequest, - GenericCloudObject, GenericServerObject, ObjectType, Revision, Space, UpdateCloudObjectResult, + CloudModelType, CloudObjectEventEntrypoint, CloudObjectUpsertParams, CreateCloudObjectResult, + CreateObjectRequest, GenericServerObject, ObjectType, Revision, Space, UpdateCloudObjectResult, }; use crate::persistence::ModelEvent; use crate::server::cloud_objects::update_manager::InitiatedBy; @@ -20,30 +21,6 @@ use crate::server::ids::{ServerId, SyncId}; use crate::server::server_api::object::ObjectClient; use crate::server::sync_queue::{QueueItem, SerializedModel}; -/// The model for a `CloudFolder`. -#[derive(Clone, Debug, PartialEq)] -pub struct CloudFolderModel { - pub name: String, - // TODO: since this is local only state, we should consider only surfacing it as part of the - // CloudViewModel. Right now, every server folder uses CloudFolderModel, which means it - // hardcodes a value of `false` for this property since it can't know what the local state is. - pub is_open: bool, - pub is_warp_pack: bool, -} - -impl CloudFolderModel { - pub fn new(name: &str, is_warp_pack: bool) -> Self { - Self { - name: name.to_owned(), - is_open: false, - is_warp_pack, - } - } -} - -/// `CloudFolder` is a folder retrieved from the server. -pub type CloudFolder = GenericCloudObject; - #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] impl CloudModelType for CloudFolderModel { diff --git a/app/src/env_vars/mod.rs b/app/src/env_vars/mod.rs index 2f8f5b285f..5c5fbaf9ef 100644 --- a/app/src/env_vars/mod.rs +++ b/app/src/env_vars/mod.rs @@ -1,6 +1,7 @@ +pub use cloud_object_models::{ + CloudEnvVarCollection, CloudEnvVarCollectionModel, EnvVar, EnvVarCollection, EnvVarValue, +}; use itertools::Itertools; -use serde::{Deserialize, Serialize}; -use view::command_dialog::EnvVarSecretCommand; use warp_util::path::ShellFamily; pub mod active_env_var_collection_data; @@ -8,17 +9,13 @@ pub mod env_var_collection_block; pub mod manager; pub mod view; -use crate::cloud_object::model::generic_string_model::{ - GenericStringModel, GenericStringObjectId, StringModel, -}; -use crate::cloud_object::model::json_model::{JsonModel, JsonSerializer}; +use crate::cloud_object::model::generic_string_model::StringModel; +use crate::cloud_object::model::json_model::JsonModel; use crate::cloud_object::{ - GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, - Revision, + GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, Revision, }; use crate::drive::items::env_var_collection::WarpDriveEnvVarCollection; use crate::drive::items::WarpDriveItem; -use crate::external_secrets::ExternalSecret; use crate::server::ids::SyncId; use crate::server::sync_queue::QueueItem; use crate::terminal::shell::ShellType; @@ -38,48 +35,12 @@ impl EnvVarCollectionType { } } -pub type CloudEnvVarCollection = - GenericCloudObject; -pub type CloudEnvVarCollectionModel = GenericStringModel; - -/// Defines the data model for a single environment variable -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] -pub struct EnvVar { - // Variable name - pub name: String, - // Variable value - pub value: EnvVarValue, - // Description of variable - pub description: Option, -} - -/// Defines the various forms a value can take -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub enum EnvVarValue { - // Represents a string variable, i.e. PORT=4000 - Constant(String), - // Represents a computed secret, i.e. gcloud print auth token - Command(EnvVarSecretCommand), - // Represents a secret from an external secret manager - Secret(ExternalSecret), +pub trait EnvVarExt { + fn get_initialization_string(&self, shell_type: ShellType) -> String; } -impl Default for EnvVarValue { - fn default() -> Self { - EnvVarValue::Constant(String::new()) - } -} - -impl EnvVar { - pub fn new(name: String, value: String, description: Option) -> Self { - Self { - name, - value: EnvVarValue::Constant(value), - description, - } - } - - pub fn get_initialization_string(&self, shell_type: ShellType) -> String { +impl EnvVarExt for EnvVar { + fn get_initialization_string(&self, shell_type: ShellType) -> String { let shell_family = ShellFamily::from(shell_type); let name = shell_family.escape(&self.name); let value = get_init_command_for_env_var(&self.value, shell_family); @@ -111,38 +72,24 @@ fn get_init_command_for_env_var(value: &EnvVarValue, shell_family: ShellFamily) } } -/// Defines the data model for a cloud synced collection of environment variables. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] -pub struct EnvVarCollection { - // Collection title - pub title: Option, - // Description of collection - pub description: Option, - // Environment variables associated with this collection - pub vars: Vec, +pub trait EnvVarCollectionExt { + fn export_variables_for_shell(&self, shell_type: ShellType) -> String; } -impl EnvVarCollection { - #[allow(dead_code)] - pub fn new(title: Option, description: Option, vars: Vec) -> Self { - Self { - title, - description, - vars, - } +impl EnvVarCollectionExt for EnvVarCollection { + fn export_variables_for_shell(&self, shell_type: ShellType) -> String { + serialize_variables_for_shell(self.key_value_iter(), shell_type) } +} + +trait EnvVarCollectionKeyValueIter { + fn key_value_iter(&self) -> impl Iterator; +} +impl EnvVarCollectionKeyValueIter for EnvVarCollection { fn key_value_iter(&self) -> impl Iterator { self.vars.iter().map(|var| (var.name.as_str(), &var.value)) } - - pub fn export_variables(&self, delimiter: &str, shell_family: ShellFamily) -> String { - serialize_variables_internal(self.key_value_iter(), "", "=", "", delimiter, shell_family) - } - - pub fn export_variables_for_shell(&self, shell_type: ShellType) -> String { - serialize_variables_for_shell(self.key_value_iter(), shell_type) - } } impl StringModel for EnvVarCollection { diff --git a/app/src/env_vars/view/command_dialog/mod.rs b/app/src/env_vars/view/command_dialog/mod.rs index 569869466f..f13f522725 100644 --- a/app/src/env_vars/view/command_dialog/mod.rs +++ b/app/src/env_vars/view/command_dialog/mod.rs @@ -1,4 +1,4 @@ -use serde::{Deserialize, Serialize}; +pub use cloud_object_models::EnvVarSecretCommand; use warpui::ViewContext; use super::env_var_collection::{EnvVarCollectionView, VariableRowIndex}; @@ -8,12 +8,6 @@ use crate::env_vars::EnvVarValue; mod command_dialog_view; pub(super) use command_dialog_view::{EnvVarCommandDialog, EnvVarCommandDialogEvent}; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct EnvVarSecretCommand { - pub name: String, - pub command: String, -} - impl EnvVarCollectionView { pub(super) fn display_command_dialog( &mut self, diff --git a/app/src/external_secrets/mod.rs b/app/src/external_secrets/mod.rs index d6489d866e..bd2b0a9d15 100644 --- a/app/src/external_secrets/mod.rs +++ b/app/src/external_secrets/mod.rs @@ -6,11 +6,11 @@ use core::fmt; use std::path::PathBuf; use anyhow::anyhow; +pub use cloud_object_models::{ExternalSecret, LastPassSecret, OnePasswordSecret}; use itertools::Itertools; use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; use serde_json::Value; -use warp_util::path::ShellFamily; #[cfg(all(not(target_family = "wasm"), feature = "local_tty"))] use crate::terminal::local_shell::execute_command; @@ -51,40 +51,6 @@ const LASTPASS_INSTALLED_COMMAND: [&str; 2] = ["lpass", "-v"]; const ONEPASSWORD_DOCS_LINK: &str = "https://developer.1password.com/docs/cli/get-started/"; const LASTPASS_DOCS_LINK: &str = "https://github.com/lastpass/lastpass-cli"; -/// Represents a "completed" secret -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub enum ExternalSecret { - OnePassword(OnePasswordSecret), - LastPass(LastPassSecret), -} - -impl ExternalSecret { - pub fn get_secret_extraction_command(&self, shell_family: ShellFamily) -> String { - let prefix = match shell_family { - ShellFamily::Posix => "\\", - ShellFamily::PowerShell => "", - }; - match self { - ExternalSecret::OnePassword(secret) => { - format!( - "{}op item get --fields credential --reveal {}", - prefix, secret.reference - ) - } - ExternalSecret::LastPass(secret) => { - format!("{}lpass show --password {}", prefix, secret.reference) - } - } - } - - pub fn get_display_name(&self) -> String { - match self { - ExternalSecret::OnePassword(secret) => secret.name.clone(), - ExternalSecret::LastPass(secret) => secret.name.clone(), - } - } -} - /// Used to check if a secret manager is installed/fetch list of secrets #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub enum SecretManager { @@ -307,10 +273,10 @@ fn parse_onepassword_secrets(output: &str) -> anyhow::Result .and_then(|v| v.as_str()) .ok_or(anyhow!("Secret is missing id"))?; - Ok(ExternalSecret::OnePassword(OnePasswordSecret { - name: name.to_string(), - reference: reference.to_string(), - })) + Ok(ExternalSecret::OnePassword(OnePasswordSecret::new( + name.to_string(), + reference.to_string(), + ))) }) .collect::, anyhow::Error>>()?; @@ -323,10 +289,10 @@ fn parse_lastpass_secrets(output: &str) -> anyhow::Result> { .filter_map(|line| { let parts = line.split(*WARP_SECRET_DELIMITER).collect_vec(); if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() { - Some(ExternalSecret::LastPass(LastPassSecret { - name: parts[0].to_owned(), - reference: parts[1].to_owned(), - })) + Some(ExternalSecret::LastPass(LastPassSecret::new( + parts[0].to_owned(), + parts[1].to_owned(), + ))) } else { None } @@ -339,15 +305,3 @@ fn parse_lastpass_secrets(output: &str) -> anyhow::Result> { Err(anyhow!("Failed to parse any secrets")) } } - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub struct OnePasswordSecret { - name: String, - reference: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub struct LastPassSecret { - name: String, - reference: String, -} diff --git a/app/src/notebooks/mod.rs b/app/src/notebooks/mod.rs index 78b0c23a9e..f466b73233 100644 --- a/app/src/notebooks/mod.rs +++ b/app/src/notebooks/mod.rs @@ -12,16 +12,15 @@ use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; +pub use cloud_object_models::{CloudNotebook, CloudNotebookModel, NotebookId, SerializedNotebook}; use itertools::Itertools; use serde::{Deserialize, Serialize}; -use warp_server_client::cloud_object::CloudObjectUpsertParams; use warpui::AppContext; -use crate::ai::document::ai_document_model::AIDocumentId; use crate::appearance::Appearance; use crate::cloud_object::{ - CloudModelType, CloudObjectEventEntrypoint, CreateCloudObjectResult, CreateObjectRequest, - GenericCloudObject, GenericServerObject, ObjectType, Owner, Revision, UpdateCloudObjectResult, + CloudModelType, CloudObjectEventEntrypoint, CloudObjectUpsertParams, CreateCloudObjectResult, + CreateObjectRequest, GenericServerObject, ObjectType, Owner, Revision, UpdateCloudObjectResult, }; use crate::drive::items::notebook::WarpDriveNotebook; use crate::drive::items::WarpDriveItem; @@ -32,28 +31,6 @@ use crate::server::ids::{ServerId, SyncId}; use crate::server::server_api::object::ObjectClient; use crate::server::sync_queue::{QueueItem, SerializedModel}; -/// Serialized representation of a notebook for sync queue -/// The AIDocumentID and ConversationID are stored here to avoid polluting the -/// generic CreateObjectRequest type. -#[derive(Serialize, Deserialize)] -pub(crate) struct SerializedNotebook { - pub(crate) data: String, - pub(crate) ai_document_id: Option, - pub(crate) conversation_id: Option, -} - -/// `CloudNotebook` is a notebook retrieved from the server. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct CloudNotebookModel { - pub title: String, - pub data: String, - pub ai_document_id: Option, - /// This is the server-generated conversation token, not the client-side AIConversationId. - pub conversation_id: Option, -} - -pub type CloudNotebook = GenericCloudObject; - #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] impl CloudModelType for CloudNotebookModel { @@ -194,17 +171,6 @@ impl CloudModelType for CloudNotebookModel { } } -/// This is the notebook_id in the database associated with this notebook. -#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)] -pub struct NotebookId(ServerId); -crate::server_id_traits! { NotebookId, "Notebook" } - -impl From for SyncId { - fn from(id: NotebookId) -> Self { - Self::ServerId(id.into()) - } -} - /// A notebook location. Mainly, this lets us distinguish between cloud and file-based notebooks. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] pub enum NotebookLocation { diff --git a/app/src/persistence/sqlite.rs b/app/src/persistence/sqlite.rs index b8f8bc7766..c13e89e82b 100644 --- a/app/src/persistence/sqlite.rs +++ b/app/src/persistence/sqlite.rs @@ -76,7 +76,9 @@ use crate::app_state::{ use crate::auth::auth_manager::PersistedCurrentUserInformation; use crate::auth::auth_state::AuthStateProvider; use crate::auth::UserUid; -use crate::cloud_object::model::actions::{ObjectAction, ObjectActionSubtype}; +use crate::cloud_object::model::actions::{ + object_action_from_persisted, ObjectAction, ObjectActionSubtype, +}; use crate::cloud_object::model::generic_string_model::{CloudStringObject, GenericStringObjectId}; use crate::cloud_object::{ CloudObject, CloudObjectMetadata, CloudObjectPermissions, CloudObjectStatuses, @@ -109,7 +111,7 @@ use crate::themes::theme::AnsiColorIdentifier; use crate::workflows::workflow_enum::{CloudWorkflowEnum, CloudWorkflowEnumModel}; use crate::workflows::{CloudWorkflow, CloudWorkflowModel, WorkflowId}; use crate::workspaces::team::Team as TeamMetadata; -use crate::workspaces::user_profiles::UserProfileWithUID; +use crate::workspaces::user_profiles::{user_profile_from_persistence, UserProfileWithUID}; use crate::workspaces::workspace::{Workspace as WorkspaceMetadata, WorkspaceUid}; use crate::{report_error, report_if_error, safe_info, send_telemetry_from_app_ctx}; @@ -3245,13 +3247,13 @@ fn read_sqlite_data( let user_profiles = schema::user_profiles::dsl::user_profiles .load_iter::(conn)? .filter_map(|user_profile| user_profile.ok()) - .map(UserProfileWithUID::from) + .map(user_profile_from_persistence) .collect(); let object_actions: Vec = schema::object_actions::dsl::object_actions .load_iter::(conn)? .filter_map(|object_action| object_action.ok()) // parse into PersistedObjectAction - .filter_map(|action| action.try_into().ok()) + .filter_map(|action| object_action_from_persisted(action).ok()) .collect(); let server_experiments = schema::server_experiments::dsl::server_experiments @@ -3633,42 +3635,42 @@ fn load_active_mcp_servers(conn: &mut SqliteConnection) -> Result for model::NewPersistedObjectAction { - fn from(action: ObjectAction) -> Self { - match action.action_subtype { - ObjectActionSubtype::SingleAction { - timestamp, - data, - pending, - processed_at_timestamp, - } => Self { - hashed_object_id: action.hashed_sqlite_id, - timestamp: Some(timestamp.naive_utc()), - action: action.action_type.to_string(), - data, - count: None, - oldest_timestamp: None, - latest_timestamp: None, - pending: Some(pending), - processed_at_timestamp: processed_at_timestamp.map(|t| t.naive_utc()), - }, - ObjectActionSubtype::BundledActions { - count, - oldest_timestamp, - latest_timestamp, - latest_processed_at_timestamp, - } => Self { - hashed_object_id: action.hashed_sqlite_id, - timestamp: None, - action: action.action_type.to_string(), - data: None, - count: Some(count), - oldest_timestamp: Some(oldest_timestamp.naive_utc()), - latest_timestamp: Some(latest_timestamp.naive_utc()), - pending: None, - processed_at_timestamp: Some(latest_processed_at_timestamp.naive_utc()), - }, - } +fn new_persisted_object_action_from_object_action( + action: ObjectAction, +) -> model::NewPersistedObjectAction { + match action.action_subtype { + ObjectActionSubtype::SingleAction { + timestamp, + data, + pending, + processed_at_timestamp, + } => model::NewPersistedObjectAction { + hashed_object_id: action.hashed_sqlite_id, + timestamp: Some(timestamp.naive_utc()), + action: action.action_type.to_string(), + data, + count: None, + oldest_timestamp: None, + latest_timestamp: None, + pending: Some(pending), + processed_at_timestamp: processed_at_timestamp.map(|t| t.naive_utc()), + }, + ObjectActionSubtype::BundledActions { + count, + oldest_timestamp, + latest_timestamp, + latest_processed_at_timestamp, + } => model::NewPersistedObjectAction { + hashed_object_id: action.hashed_sqlite_id, + timestamp: None, + action: action.action_type.to_string(), + data: None, + count: Some(count), + oldest_timestamp: Some(oldest_timestamp.naive_utc()), + latest_timestamp: Some(latest_timestamp.naive_utc()), + pending: None, + processed_at_timestamp: Some(latest_processed_at_timestamp.naive_utc()), + }, } } @@ -3676,7 +3678,7 @@ fn insert_object_action( conn: &mut SqliteConnection, object_action: ObjectAction, ) -> Result<(), Error> { - let action: NewPersistedObjectAction = object_action.into(); + let action = new_persisted_object_action_from_object_action(object_action); conn.transaction::<(), Error, _>(|conn| { diesel::insert_into(schema::object_actions::dsl::object_actions) .values(action) @@ -3694,8 +3696,10 @@ fn sync_object_actions( let ids_to_delete: HashSet = HashSet::from_iter(actions_to_sync.iter().map(|a| a.hashed_sqlite_id.clone())); // Insert the new ones - let new_actions: Vec = - actions_to_sync.iter().map(|a| a.clone().into()).collect(); + let new_actions: Vec = actions_to_sync + .iter() + .map(|a| new_persisted_object_action_from_object_action(a.clone())) + .collect(); conn.transaction::<(), Error, _>(|conn| { // Erase all the actions that currently have this object ID for hashed_sqlite_id in ids_to_delete { diff --git a/app/src/server/cloud_objects/fake_object_client.rs b/app/src/server/cloud_objects/fake_object_client.rs index 40fcb7cc83..4e825f155d 100644 --- a/app/src/server/cloud_objects/fake_object_client.rs +++ b/app/src/server/cloud_objects/fake_object_client.rs @@ -21,6 +21,7 @@ use anyhow::{anyhow, Result}; use async_channel::Sender; use async_trait::async_trait; use chrono::{DateTime, Utc}; +use cloud_object_client::ObjectPermissionUpdateResult; use warp_graphql::object_permissions::AccessLevel; use crate::cloud_object::model::actions::{ObjectActionHistory, ObjectActionType}; @@ -29,10 +30,9 @@ use crate::cloud_object::{ 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, + 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; diff --git a/app/src/server/cloud_objects/listener.rs b/app/src/server/cloud_objects/listener.rs index 0faa4053d6..cebd582f76 100644 --- a/app/src/server/cloud_objects/listener.rs +++ b/app/src/server/cloud_objects/listener.rs @@ -2,23 +2,19 @@ use std::sync::Arc; use std::time::Duration; use async_channel::Sender; -use chrono::{DateTime, Utc}; +pub use cloud_object_client::ObjectUpdateMessage; use futures_util::stream::AbortHandle; use instant::Instant; use warpui::r#async::Timer; use warpui::{Entity, ModelContext, RequestState, SingletonEntity}; use super::update_manager::UpdateManager; -use crate::cloud_object::model::actions::ObjectActionHistory; use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent}; -use crate::cloud_object::{ServerCloudObject, ServerMetadata, ServerPermissions}; use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind}; use crate::report_error; -use crate::server::ids::ServerId; use crate::server::retry_strategies::LISTENER_RETRY_STRATEGY; use crate::server::server_api::object::ObjectClient; use crate::system::{SystemStats, SystemStatsEvent}; -use crate::workspaces::user_profiles::UserProfileWithUID; use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; lazy_static::lazy_static! { @@ -76,52 +72,6 @@ pub struct Listener { pending_refresh_abort_handle: Option, } -#[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, - }, - ObjectContentChanged { - server_object: Box, - last_editor: Option, - }, - ObjectDeleted { - object_uid: ServerId, - }, - ObjectActionOccurred { - history: ObjectActionHistory, - }, - TeamMembershipsChanged, - AmbientTaskUpdated { - task_id: String, - timestamp: DateTime, - }, -} - -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, ctx: &mut ModelContext) -> Self { let (subscription_ready_tx, subscription_ready_rx) = async_channel::unbounded(); diff --git a/app/src/server/cloud_objects/update_manager.rs b/app/src/server/cloud_objects/update_manager.rs index 5043831132..042eed5fee 100644 --- a/app/src/server/cloud_objects/update_manager.rs +++ b/app/src/server/cloud_objects/update_manager.rs @@ -1,14 +1,18 @@ +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 itertools::Itertools; use lazy_static::lazy_static; use regex::Regex; -use std::collections::{HashMap, HashSet}; -use std::future::Future; -use std::sync::mpsc::SyncSender; -use std::sync::Arc; -use std::time::Duration; use warp_core::features::FeatureFlag; use warp_core::report_error; use warp_graphql::mcp_gallery_template::MCPGalleryTemplate; @@ -51,9 +55,9 @@ use crate::cloud_object::{ 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, + ServerEnvVarCollection, ServerMCPServer, ServerMetadata, ServerPermissions, ServerPreference, + ServerScheduledAmbientAgent, ServerTemplatableMCPServer, ServerWorkflowEnum, Space, + UpdateCloudObjectResult, }; use crate::drive::folders::{CloudFolderModel, FolderId}; use crate::drive::sharing::SharingAccessLevel; @@ -161,27 +165,6 @@ pub enum InitiatedBy { User, System, } -#[derive(Default)] -pub struct InitialLoadResponse { - pub updated_notebooks: Vec, - pub deleted_notebooks: Vec, - pub updated_workflows: Vec, - pub deleted_workflows: Vec, - pub updated_folders: Vec, - pub deleted_folders: Vec, - pub updated_generic_string_objects: - HashMap>>, - pub deleted_generic_string_objects: Vec, - pub user_profiles: Vec, - pub action_histories: Vec, - pub mcp_gallery: Vec, -} - -pub struct GetCloudObjectResponse { - pub object: ServerCloudObject, - pub descendants: Vec, - pub action_histories: Vec, -} #[derive(Debug)] pub struct GenericStringObjectInput diff --git a/app/src/server/cloud_objects/update_manager_tests.rs b/app/src/server/cloud_objects/update_manager_tests.rs index 2d42930243..2118e79b46 100644 --- a/app/src/server/cloud_objects/update_manager_tests.rs +++ b/app/src/server/cloud_objects/update_manager_tests.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use std::time::Duration; use chrono::{DateTime, Utc}; +use cloud_object_models::JsonSerializer; use futures_lite::future; use settings::{RespectUserSyncSetting, SyncToCloud}; use warp_core::features::FeatureFlag; @@ -20,7 +21,6 @@ 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::json_model::JsonSerializer; use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent, UpdateSource}; use crate::cloud_object::{ BulkCreateCloudObjectResult, CloudModelType, CloudObjectEventEntrypoint, CloudObjectGuest, @@ -28,8 +28,8 @@ use crate::cloud_object::{ GenericCloudObject, GenericStringObjectFormat, JsonObjectType, ObjectDeleteResult, ObjectIdType, ObjectMetadataUpdateResult, ObjectPermissionsUpdateData, ObjectType, Owner, Revision, RevisionAndLastEditor, ServerCloudObject, ServerFolder, ServerGuestSubject, - ServerObject, ServerObjectGuest, ServerPreference, ServerWorkflow, ServerWorkflowEnum, Space, - UpdateCloudObjectResult, + ServerNotebook, ServerObject, ServerObjectGuest, ServerPreference, ServerWorkflow, + ServerWorkflowEnum, Space, UpdateCloudObjectResult, }; use crate::drive::folders::{CloudFolder, CloudFolderModel, FolderId}; use crate::drive::sharing::{SharingAccessLevel, Subject, UserKind}; @@ -42,7 +42,7 @@ use crate::server::cloud_objects::test_utils::{ }; use crate::server::cloud_objects::update_manager::{ get_duplicate_object_name, FetchSingleObjectOption, GenericStringObjectInput, InitiatedBy, - ServerMetadata, ServerNotebook, ServerPermissions, + ServerMetadata, ServerPermissions, }; use crate::server::ids::{ ClientId, HashableId, ObjectUid, ServerId, ServerIdAndType, SyncId, ToServerId, diff --git a/app/src/server/graphql/schema/mod.rs b/app/src/server/graphql/schema/mod.rs index 8cb795b146..c9042b4ad3 100644 --- a/app/src/server/graphql/schema/mod.rs +++ b/app/src/server/graphql/schema/mod.rs @@ -1,4 +1,5 @@ pub mod util; +pub use util::{action_type_to_gql_action_type, object_action_history_from_gql}; use anyhow::{bail, Result}; use warp_graphql::generic_string_object::GenericStringObjectFormat; @@ -41,7 +42,7 @@ pub fn update_generic_string_object_result_to_update_result( }) } GenericStringObjectUpdate::GenericStringObjectUpdateRejected(rejected) => { - let format = rejected.conflicting_generic_string_object.format.clone(); + let format = rejected.conflicting_generic_string_object.format; let boxed: Box = match format { GenericStringObjectFormat::JsonEnvVarCollection => { boxed_rejected_generic_string_object::( diff --git a/app/src/server/graphql/schema/util.rs b/app/src/server/graphql/schema/util.rs index 54bcf04802..e171b36d9b 100644 --- a/app/src/server/graphql/schema/util.rs +++ b/app/src/server/graphql/schema/util.rs @@ -3,12 +3,11 @@ use crate::cloud_object::model::actions::{ ObjectAction, ObjectActionHistory, ObjectActionSubtype, ObjectActionType, }; use crate::server::ids::{HashedSqliteId, ObjectUid, ServerId, SyncId}; - -impl From for warp_graphql::object_actions::ActionType { - fn from(action: ObjectActionType) -> Self { - match action { - ObjectActionType::Execute => warp_graphql::object_actions::ActionType::Executed, - } +pub fn action_type_to_gql_action_type( + action: ObjectActionType, +) -> warp_graphql::object_actions::ActionType { + match action { + ObjectActionType::Execute => warp_graphql::object_actions::ActionType::Executed, } } @@ -58,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 for warp_graphql::object_actions::ObjectActionHistory { - type Error = anyhow::Error; - fn try_into(self) -> Result { - 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: warp_graphql::object_actions::ObjectActionHistory, +) -> Result { + 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::>() - }) - .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::>() }) - } + .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, + }) } diff --git a/app/src/server/server_api/object.rs b/app/src/server/server_api/object.rs index f6a7e0c98d..75959577fa 100644 --- a/app/src/server/server_api/object.rs +++ b/app/src/server/server_api/object.rs @@ -4,9 +4,16 @@ 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}; -#[cfg(test)] -use mockall::{automock, predicate::*}; use warp_core::report_error; use warp_graphql::error::UserFacingErrorInterface; use warp_graphql::generic_string_object::GenericStringObjectInput; @@ -122,27 +129,23 @@ 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::actions::{ObjectActionHistory, ObjectActionType}; use crate::cloud_object::model::generic_string_model::{ GenericStringModel, GenericStringObjectId, Serializer, StringModel, }; -use crate::cloud_object::model::json_model::JsonSerializer; use crate::cloud_object::{ 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, TryFromGql as _, UpdateCloudObjectResult, + 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::cloud_objects::listener::ObjectUpdateMessage; -use crate::server::cloud_objects::update_manager::{GetCloudObjectResponse, InitialLoadResponse}; 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}; @@ -153,184 +156,9 @@ 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; -/// 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; - - /// 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, - ) -> Result>; - - /// 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; - - async fn create_generic_string_object( - &self, - format: GenericStringObjectFormat, - uniqueness_key: Option, - request: CreateObjectRequest, - ) -> Result; - - /// Creates a notebook on the server, returning the ID and revision of the object after - /// creation. - async fn create_notebook( - &self, - request: CreateObjectRequest, - ) -> Result; - - /// 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, - data: Option, - revision: Option, - ) -> Result>; - - async fn create_folder(&self, request: CreateObjectRequest) -> Result; - - async fn update_folder( - &self, - folder_id: FolderId, - name: SerializedModel, - ) -> Result>; - - async fn update_generic_string_object( - &self, - object_id: GenericStringObjectId, - model: SerializedModel, - revision: Option, - ) -> Result>>; - - /// Sets the current editor of the notebook to be the logged in user - async fn grab_notebook_edit_access(&self, notebook_id: NotebookId) -> Result; - /// Sets the current editor of the notebook to be null - async fn give_up_notebook_edit_access(&self, notebook_id: NotebookId) - -> Result; - - /// Gets updates for all Warp Drive actions. - async fn get_warp_drive_updates( - &self, - message_sender: Sender, - stream_ready_sender: Sender<()>, - ) -> Result<()>; - - async fn fetch_changed_objects( - &self, - objects_to_update: ObjectsToUpdate, - force_refresh: bool, - ) -> Result; - - async fn fetch_single_cloud_object(&self, id: ServerId) -> Result; - - // Transfers a notebook to the given owner - async fn transfer_notebook_owner(&self, notebook_id: NotebookId, owner: Owner) -> Result; - - async fn transfer_workflow_owner(&self, workflow_id: WorkflowId, owner: Owner) -> Result; - - async fn transfer_generic_string_object_owner( - &self, - workflow_id: GenericStringObjectId, - owner: Owner, - ) -> Result; - - async fn trash_object(&self, id: ServerId) -> Result; - - async fn untrash_object(&self, id: ServerId) -> Result; - - async fn delete_object(&self, id: ServerId) -> Result; - - async fn empty_trash(&self, owner: Owner) -> Result; - - async fn move_object( - &self, - id: ServerId, - folder_id: Option, - owner: Owner, - object_type: ObjectType, - ) -> Result; - - async fn record_object_action( - &self, - id: ServerId, - action_type: ObjectActionType, - timestamp: DateTime, - data: Option, - ) -> Result; - - async fn leave_object(&self, id: ServerId) -> Result; - - async fn set_object_link_permissions( - &self, - object_id: ServerId, - access_level: SharingAccessLevel, - ) -> Result; - - async fn remove_object_link_permissions( - &self, - object_id: ServerId, - ) -> Result; - - async fn add_object_guests( - &self, - object_id: ServerId, - guest_emails: Vec, - access_level: AccessLevel, - ) -> Result; - - async fn update_object_guests( - &self, - object_id: ServerId, - guest_emails: Vec, - access_level: AccessLevel, - ) -> Result; - - async fn remove_object_guest( - &self, - object_id: ServerId, - guest: GuestIdentifier, - ) -> Result; - - /// 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>>; -} - #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] impl ObjectClient for ServerApi { @@ -868,7 +696,7 @@ 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, @@ -935,7 +763,7 @@ impl ObjectClient for ServerApi { let mut updated_generic_string_objects = HashMap::new(); if let Some(objects) = output.generic_string_objects { for gso in objects { - match gso.format.clone() { + match gso.format { warp_graphql::generic_string_object::GenericStringObjectFormat::JsonEnvVarCollection => { parse_server_gso::( &mut updated_generic_string_objects, @@ -1058,7 +886,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(); @@ -1107,7 +935,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(); @@ -1320,7 +1148,7 @@ impl ObjectClient for ServerApi { ) -> Result { 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(), @@ -1331,7 +1159,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))) } diff --git a/app/src/settings/ai.rs b/app/src/settings/ai.rs index 62ddbe0925..237dd47e21 100644 --- a/app/src/settings/ai.rs +++ b/app/src/settings/ai.rs @@ -6,10 +6,12 @@ use std::collections::HashMap; use std::path::PathBuf; -use cfg_if::cfg_if; use chrono::{DateTime, Utc}; +pub use cloud_object_models::{ + AgentModeCommandExecutionPredicate, DEFAULT_COMMAND_EXECUTION_ALLOWLIST, + DEFAULT_COMMAND_EXECUTION_DENYLIST, +}; use indexmap::IndexMap; -use lazy_static::lazy_static; use regex::Regex; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; @@ -485,148 +487,6 @@ pub enum AgentModeCodingPermissionsType { AllowReadingSpecificFiles, } -/// Predicate types to match commands that can be executed by Agent Mode. -#[derive(Debug, Serialize, Deserialize, Clone)] -enum AgentModeCommandExecutionPredicateType { - /// A regex with start (`^`) and end (`$`) anchors. - /// - /// We want regex rules to apply to the entire cmd string so we anchor them - /// (there isn't any efficient way to apply to the entire cmd string at match-time). - #[serde(with = "serde_regex")] - AnchoredRegex(Regex), -} - -impl AgentModeCommandExecutionPredicateType { - fn new_regex(regex: &str) -> Result { - // Redundant anchors aren't a problem so we can unconditionally add them. - let anchored_regex = Regex::new(&format!("^{regex}$"))?; - Ok(Self::AnchoredRegex(anchored_regex)) - } - - fn matches(&self, cmd: &str) -> bool { - match self { - Self::AnchoredRegex(regex) => regex.is_match(cmd), - } - } -} - -impl PartialEq for AgentModeCommandExecutionPredicateType { - fn eq(&self, other: &Self) -> bool { - match (self, other) { - (Self::AnchoredRegex(a), Self::AnchoredRegex(b)) => { - // Indexing should be safe since they're guaranteed to have at least - // the anchors around them. - let a_unanchored = &a.as_str()[1..a.as_str().len() - 1]; - let b_unanchored = &b.as_str()[1..b.as_str().len() - 1]; - a_unanchored == b_unanchored - } - } - } -} - -impl std::fmt::Display for AgentModeCommandExecutionPredicateType { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::AnchoredRegex(regex) => { - write!(f, "{}", ®ex.as_str()[1..regex.as_str().len() - 1]) - } - } - } -} - -/// A wrapper around [`AgentModeCommandExecutionPredicateType`] to enforce -/// the use of the provided constructors rather than direct construction of the variants. -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(transparent)] -pub struct AgentModeCommandExecutionPredicate(AgentModeCommandExecutionPredicateType); - -impl schemars::JsonSchema for AgentModeCommandExecutionPredicate { - fn schema_name() -> std::borrow::Cow<'static, str> { - std::borrow::Cow::Borrowed("AgentModeCommandExecutionPredicate") - } - - fn json_schema(gen: &mut schemars::SchemaGenerator) -> schemars::Schema { - // In the settings file, predicates are serialized as plain regex strings. - gen.subschema_for::() - } -} - -impl AgentModeCommandExecutionPredicate { - pub fn new_regex(regex: &str) -> Result { - Ok(Self(AgentModeCommandExecutionPredicateType::new_regex( - regex, - )?)) - } - - pub fn matches(&self, cmd: &str) -> bool { - self.0.matches(cmd) - } -} - -impl std::fmt::Display for AgentModeCommandExecutionPredicate { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl settings_value::SettingsValue for AgentModeCommandExecutionPredicate { - fn to_file_value(&self) -> serde_json::Value { - serde_json::Value::String(self.to_string()) - } - - fn from_file_value(value: &serde_json::Value) -> Option { - value.as_str().and_then(|s| Self::new_regex(s).ok()) - } -} - -lazy_static! { - // Matches optional args / options for a top-level command. - static ref OPTIONAL_ARGS_REGEX: Regex = Regex::new(r"(\s.*)?").expect("Can parse optional args regex"); -} - -cfg_if! { - // Compiling the regexes for the default command execution allowlist/denylist can be slow - // in an unoptimized build, so we use empty lists in unit tests. - if #[cfg(test)] { - lazy_static! { - pub static ref DEFAULT_COMMAND_EXECUTION_ALLOWLIST: Vec = vec![]; - pub static ref DEFAULT_COMMAND_EXECUTION_DENYLIST: Vec = vec![]; - } - } else { - lazy_static! { - pub static ref DEFAULT_COMMAND_EXECUTION_ALLOWLIST: Vec = vec![ - AgentModeCommandExecutionPredicate::new_regex(&format!("cat{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default cat rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("echo{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default echo rule into regex"), - AgentModeCommandExecutionPredicate::new_regex("find .*").expect("Can parse default find rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("grep{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default grep rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("ls{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default ls rule into regex"), - AgentModeCommandExecutionPredicate::new_regex("which .*").expect("Can parse default which rule into regex"), - ]; - - pub static ref DEFAULT_COMMAND_EXECUTION_DENYLIST: Vec = vec![ - AgentModeCommandExecutionPredicate::new_regex(&format!("bash{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default bash rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("fish{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default fish rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("pwsh{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default pwsh rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("sh{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default sh rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("zsh{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default zsh rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("curl{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default curl rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("eval{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default eval rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("exec{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default exec rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("source{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default source rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("wget{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default wget rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("dig{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default dig rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("nslookup{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default nslookup rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("host{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default host rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("ssh{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default ssh rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("scp{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default scp rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("rsync{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default rsync rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("telnet{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default telnet rule into regex"), - AgentModeCommandExecutionPredicate::new_regex(&format!("rm{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default rm rule into regex"), - ]; - } - } -} - /// Maps custom toolbar command regex patterns to CLI agent names. /// Keys are regex patterns (insertion-ordered), values are serialized CLIAgent names (e.g. "Claude"). /// An empty string value means "Any CLI Agent" (CLIAgent::Unknown). diff --git a/app/src/settings/cloud_preferences.rs b/app/src/settings/cloud_preferences.rs index ed53e74ab7..a4e57b8888 100644 --- a/app/src/settings/cloud_preferences.rs +++ b/app/src/settings/cloud_preferences.rs @@ -1,16 +1,11 @@ -use anyhow::{anyhow, Result}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; +pub use cloud_object_models::{CloudPreference, CloudPreferenceModel, Platform, Preference}; use settings::macros::define_settings_group; use settings::{RespectUserSyncSetting, SupportedPlatforms, SyncToCloud}; -use crate::cloud_object::model::generic_string_model::{ - GenericStringModel, GenericStringObjectId, StringModel, -}; -use crate::cloud_object::model::json_model::{JsonModel, JsonSerializer}; +use crate::cloud_object::model::generic_string_model::StringModel; +use crate::cloud_object::model::json_model::JsonModel; use crate::cloud_object::{ - GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, - Revision, UniquePer, + GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, Revision, UniquePer, }; use crate::server::sync_queue::QueueItem; define_settings_group!(CloudPreferencesSettings, settings: [ @@ -25,109 +20,6 @@ define_settings_group!(CloudPreferencesSettings, settings: [ }, ]); -pub type CloudPreference = GenericCloudObject; -pub type CloudPreferenceModel = GenericStringModel; - -/// Defines the platform that a preference was set on. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub enum Platform { - Mac, - Linux, - Windows, - Web, - - /// This implies the preference applies on all supported platforms - Global, -} - -impl std::fmt::Display for Platform { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Mac => write!(f, "Mac"), - Self::Linux => write!(f, "Linux"), - Self::Windows => write!(f, "Windows"), - Self::Web => write!(f, "Web"), - Self::Global => write!(f, "Global"), - } - } -} - -impl Platform { - pub fn applies_to_current_platform(&self) -> bool { - *self == Platform::current_platform() || *self == Platform::Global - } -} - -impl Platform { - pub fn current_platform() -> Self { - if cfg!(all(not(target_family = "wasm"), target_os = "macos")) { - return Self::Mac; - } - - if cfg!(all( - not(target_family = "wasm"), - any(target_os = "linux", target_os = "freebsd") - )) { - return Self::Linux; - } - - if cfg!(all(not(target_family = "wasm"), target_os = "windows")) { - return Self::Windows; - } - if cfg!(target_family = "wasm") { - return Self::Web; - } - panic!("Unsupported platform"); - } -} - -/// Defines the data model for a cloud synced user preference. -/// -/// The expected usage is that each storage key is modeled as its own cloud preference object. -/// This allows users to edit individual cloud preferences with less fear of an offline -/// collision (e.g. if I change one preference on one machine and then update another while -/// offline on another machine, modeling them individually allows for both changes to be applied). -/// -/// Note that I considered adding a concept of "preference group" as a higher level namespace -/// for preferences (in case users want to create groups of them), but decided to hold off on -/// this until we actually support that feature. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub struct Preference { - /// The storage key (unique identifier for this preference). - pub storage_key: String, - - /// The value of the preference, which can be any JSON value. - pub value: Value, - - /// The platform that this preference was set on. - /// If the preference is global, this will be set to Platform::Global. - pub platform: Platform, -} - -impl Preference { - /// Creates a new preference object with the given storage key and value and the appropriate - /// platform key for the given syncing mode. - /// Used when creating a new preference the first time. For preferences synced from the - /// cloud they will desererialize directly from JSON. - pub fn new(storage_key: String, value: &str, syncing_mode: SyncToCloud) -> Result { - let platform = match syncing_mode { - SyncToCloud::PerPlatform(_) => Platform::current_platform(), - SyncToCloud::Globally(_) => Platform::Global, - SyncToCloud::Never => Err(anyhow!( - "Cannot create a preference with SyncToCloud::Never" - ))?, - }; - match serde_json::from_str(value) { - Ok(value) => Ok(Self { - storage_key, - value, - platform, - }), - Err(err) => Err(anyhow!("Failed to parse preference value {}", err)), - } - } -} - /// Defines a based model for syncing cloud preferences. impl StringModel for Preference { type CloudObjectType = CloudPreference; diff --git a/app/src/settings/cloud_preferences_syncer.rs b/app/src/settings/cloud_preferences_syncer.rs index 65ae586291..80db2e8420 100644 --- a/app/src/settings/cloud_preferences_syncer.rs +++ b/app/src/settings/cloud_preferences_syncer.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use cloud_object_models::JsonSerializer; use lazy_static::lazy_static; use settings::{Setting as _, SyncToCloud}; use warp_core::execution_mode::AppExecutionMode; @@ -17,7 +18,6 @@ use super::manager::SettingsEvent; use super::PrivacySettings; use crate::auth::auth_state::AuthState; use crate::cloud_object::model::generic_string_model::GenericStringObjectId; -use crate::cloud_object::model::json_model::JsonSerializer; use crate::cloud_object::model::persistence::CloudModel; use crate::cloud_object::{CloudObjectEventEntrypoint, GenericStringObjectFormat, JsonObjectType}; use crate::debounce::debounce; diff --git a/app/src/settings_view/ai_page.rs b/app/src/settings_view/ai_page.rs index 007beb3e18..0147e85871 100644 --- a/app/src/settings_view/ai_page.rs +++ b/app/src/settings_view/ai_page.rs @@ -60,7 +60,9 @@ use crate::ai::execution_profiles::model_menu_items::available_model_menu_items; use crate::ai::execution_profiles::profiles::{ AIExecutionProfilesModel, AIExecutionProfilesModelEvent, ClientProfileId, }; -use crate::ai::execution_profiles::{AIExecutionProfile, ActionPermission, WriteToPtyPermission}; +use crate::ai::execution_profiles::{ + AIExecutionProfile, AIExecutionProfileAppExt, ActionPermission, WriteToPtyPermission, +}; use crate::ai::llms::{LLMContextWindow, LLMId, LLMPreferences, LLMPreferencesEvent}; use crate::ai::mcp::TemplatableMCPServerManager; use crate::ai::paths::host_native_absolute_path; @@ -6655,7 +6657,9 @@ impl SettingsWidget for CloudAgentComputerUseWidget { appearance: &Appearance, app: &AppContext, ) -> Box { - use crate::ai::execution_profiles::{CloudAgentComputerUseState, ComputerUsePermission}; + use crate::ai::execution_profiles::{ + resolve_cloud_agent_computer_use_state, CloudAgentComputerUseState, + }; let is_any_ai_enabled = AISettings::as_ref(app).is_any_ai_enabled(app); @@ -6663,7 +6667,7 @@ impl SettingsWidget for CloudAgentComputerUseWidget { let CloudAgentComputerUseState { enabled: is_checked, is_forced_by_org, - } = ComputerUsePermission::resolve_cloud_agent_state(app); + } = resolve_cloud_agent_computer_use_state(app); // Toggle is disabled if forced by org settings OR if AI is globally disabled let is_disabled = is_forced_by_org || !is_any_ai_enabled; diff --git a/app/src/terminal/bootstrap.rs b/app/src/terminal/bootstrap.rs index 5b699771b4..382ed97000 100644 --- a/app/src/terminal/bootstrap.rs +++ b/app/src/terminal/bootstrap.rs @@ -10,7 +10,7 @@ use super::{ model::session::{BootstrapSessionType, SessionInfo}, warpify::settings::{PIPENV_SUBSHELL_COMMAND_REGEX, POETRY_SUBSHELL_COMMAND_REGEX}, }; -use crate::env_vars::EnvVar; +use crate::env_vars::{EnvVar, EnvVarExt}; use crate::terminal::session_settings::SessionSettings; use crate::terminal::shell::ShellType; diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 1493ce6345..d86e5d7d80 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -202,6 +202,7 @@ use crate::cloud_object::model::view::CloudViewModel; use crate::cloud_object::{CloudObject, CloudObjectLookup as _, Space}; #[cfg(feature = "local_fs")] use crate::code::editor_management::CodeSource; +#[cfg(feature = "local_fs")] use crate::code_review::diff_state::DiffMode; use crate::completer::SessionContext; use crate::context_chips::display::{PromptDisplay, PromptDisplayEvent}; @@ -219,6 +220,7 @@ use crate::editor::{ PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, ReplicaId, TextColors, TextRun, MAX_IMAGES_PER_CONVERSATION, }; +use crate::env_vars::EnvVarCollectionExt; use crate::features::FeatureFlag; use crate::input_suggestions::{ Event as InputSuggestionsEvent, HistoryInputSuggestion, InputSuggestions, @@ -229,6 +231,7 @@ use crate::pane_group::focus_state::PaneFocusHandle; use crate::pane_group::PaneGroupAction; #[cfg(feature = "local_fs")] use crate::persistence::{database_file_path_for_scope, establish_ro_connection, PersistenceScope}; +#[cfg(feature = "local_fs")] use crate::prefix::longest_common_prefix; use crate::prompt::editor_modal::OpenSource as PromptEditorOpenSource; use crate::resource_center::{ @@ -244,6 +247,7 @@ use crate::server::ids::SyncId; use crate::server::server_api::ai::AttachmentFileInfo; #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] use crate::server::server_api::ai::AttachmentInput; +#[cfg(all(feature = "local_fs", not(target_family = "wasm")))] use crate::server::server_api::ServerApi; use crate::server::telemetry::{ AICommandSearchEntrypoint, AgentModeAutoDetectionFalsePositivePayload, @@ -310,6 +314,7 @@ use crate::user_config::WarpConfig; use crate::util::bindings::{self, keybinding_name_to_normalized_string, CustomAction}; #[cfg(feature = "local_fs")] use crate::util::file::external_editor; +#[cfg(feature = "local_fs")] use crate::util::image::MAX_IMAGE_COUNT_FOR_QUERY; use crate::util::truncation::truncate_from_end; use crate::view_components::{DismissibleToast, ToastFlavor}; @@ -336,6 +341,7 @@ use crate::workspace::{ use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; #[allow(unused_imports)] use crate::ASSETS; +#[allow(unused_imports)] use crate::{ cmd_or_ctrl_shift, report_if_error, send_telemetry_from_ctx, AgentModeEntrypoint, ServerApiProvider, diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 8f75a0bd32..6e6d795bbd 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -22,7 +22,6 @@ use onboarding::callout::{FinalState, OnboardingCalloutViewEvent, OnboardingQuer use onboarding::{OnboardingCalloutView, OnboardingKeybindings}; use crate::ai::block_context::BlockContext; -#[cfg(feature = "local_fs")] use crate::ai::skills::SkillOpenOrigin; use crate::global_resource_handles::GlobalResourceHandlesProvider; pub(crate) mod docker_sandbox; @@ -105,6 +104,7 @@ use pathfinder_color::ColorU; use regex::Regex; #[cfg(not(target_family = "wasm"))] use repo_metadata::repositories::DetectedRepositories; +#[cfg(not(target_family = "wasm"))] use repo_metadata::repositories::RepoDetectionSource; use serde::Serialize; use serde_json::json; @@ -222,6 +222,7 @@ use crate::ai::agent::{ }; #[cfg(feature = "local_fs")] use crate::ai::agent::{CurrentHead, DiffBase}; +#[cfg(feature = "local_fs")] use crate::ai::agent_conversations_model::{AgentConversationsModel, AgentConversationsModelEvent}; use crate::ai::ambient_agents::{ conversation_output_status_from_conversation, AmbientAgentTaskId, AmbientConversationStatus, @@ -276,6 +277,7 @@ use crate::ai::llms::{LLMId, LLMModelHost, LLMPreferences}; use crate::ai::loading::shimmering_warp_loading_text; #[cfg(feature = "local_fs")] use crate::ai::persisted_workspace::PersistedWorkspace; +#[cfg(feature = "local_fs")] use crate::ai::predict::prompt_suggestions::{ has_pending_code_or_unit_test_prompt_suggestion, is_accept_prompt_suggestion_bound_to_cmd_enter, @@ -313,6 +315,7 @@ use crate::code_review::diff_state::{DiffMode, GitDeltaPreference}; use crate::code_review::git_status_update::{ GitRepoStatusModel, GitStatusMetadata, GitStatusUpdateModel, }; +#[cfg(feature = "local_fs")] use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; #[cfg(feature = "local_fs")] use crate::code_review::DiffSetScope; @@ -327,7 +330,7 @@ use crate::editor::{AutosuggestionType, CrdtOperation, EditorAction}; use crate::env_vars::env_var_collection_block::{ EnvVarCollectionBlock, EnvVarCollectionBlockEvent, }; -use crate::env_vars::{CloudEnvVarCollection, EnvVar}; +use crate::env_vars::{CloudEnvVarCollection, EnvVar, EnvVarExt}; use crate::features::FeatureFlag; use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}; use crate::pane_group::focus_state::PaneFocusHandle; @@ -359,6 +362,7 @@ use crate::session_management::{CommandContext, SessionNavigationPromptElements} use crate::settings::ai::FocusedTerminalInfo; #[cfg(feature = "local_fs")] use crate::settings::import::model::ImportedConfigModel; +#[cfg(feature = "local_fs")] use crate::settings::import::view::{SettingsImportEvent, SettingsImportView}; use crate::settings::{ AISettings, AISettingsChangedEvent, AliasExpansionSettings, AppEditorSettings, @@ -397,6 +401,7 @@ use crate::terminal::cli_agent_sessions::listener::{ }; #[cfg(not(target_family = "wasm"))] use crate::terminal::cli_agent_sessions::plugin_manager::{plugin_manager_for, PluginModalKind}; +#[cfg(not(target_family = "wasm"))] use crate::terminal::cli_agent_sessions::{ CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, @@ -424,6 +429,7 @@ use crate::terminal::links::should_directly_open_link; use crate::terminal::local_tty::get_shell_starter; #[cfg(feature = "local_tty")] use crate::terminal::local_tty::shell::ShellStarter; +#[cfg(feature = "local_tty")] #[cfg(all(windows, feature = "local_tty"))] use crate::terminal::local_tty::windows::get_user_and_system_env_variable; use crate::terminal::model::ansi::{ClearMode, Handler}; @@ -521,6 +527,7 @@ use crate::util::color::darken; use crate::util::file::external_editor::{settings::EditorLayout, EditorSettings}; #[cfg(feature = "local_fs")] use crate::util::openable_file_type::{is_markdown_file, resolve_file_target, FileTarget}; +#[cfg(feature = "local_fs")] use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; use crate::util::truncation::truncate_from_end; use crate::view_components::action_button::{ActionButton, ButtonSize, KeystrokeSource}; diff --git a/app/src/terminal/view/ambient_agent/model.rs b/app/src/terminal/view/ambient_agent/model.rs index ad5f756e52..3abc0f2cc0 100644 --- a/app/src/terminal/view/ambient_agent/model.rs +++ b/app/src/terminal/view/ambient_agent/model.rs @@ -27,7 +27,9 @@ use crate::ai::blocklist::handoff::touched_repos::TouchedWorkspace; use crate::ai::blocklist::handoff::PendingCloudLaunch; use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::ai::cloud_environments::CloudAmbientAgentEnvironment; -use crate::ai::execution_profiles::{CloudAgentComputerUseState, ComputerUsePermission}; +use crate::ai::execution_profiles::{ + resolve_cloud_agent_computer_use_state, CloudAgentComputerUseState, +}; use crate::ai::harness_availability::HarnessAvailabilityModel; use crate::ai::llms::{LLMId, LLMPreferences}; use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent}; @@ -1063,7 +1065,7 @@ impl AmbientAgentViewModel { let computer_use_enabled = if selected_harness == Harness::Oz { // If the harness is Oz, determine computer use based on workspace AI autonomy settings. let CloudAgentComputerUseState { enabled, .. } = - ComputerUsePermission::resolve_cloud_agent_state(ctx); + resolve_cloud_agent_computer_use_state(ctx); Some(enabled) } else { None diff --git a/app/src/terminal/view/use_agent_footer/mod.rs b/app/src/terminal/view/use_agent_footer/mod.rs index 3c75d53c00..d302c851a0 100644 --- a/app/src/terminal/view/use_agent_footer/mod.rs +++ b/app/src/terminal/view/use_agent_footer/mod.rs @@ -4,6 +4,9 @@ //! offering users the option to bring in the agent. For CLI agent commands (e.g., Claude Code, //! Gemini CLI, Codex), it displays a specialized footer with additional functionality. +use base64::Engine; +use warpui::clipboard::{ClipboardContent, ImageData}; + use crate::ai::agent::ImageContext; use crate::ai::blocklist::agent_view::agent_input_footer::{ AgentInputFooter, AgentInputFooterEvent, @@ -14,70 +17,56 @@ use crate::terminal::shared_session::{ SharedSessionActionSource, SharedSessionScrollbackType, SharedSessionSource, }; use crate::util::image::{infer_mime_type, MAX_IMAGE_SIZE_BYTES_FOR_CLI_AGENT, MIME_SNIFF_BYTES}; -use base64::Engine; -use warpui::clipboard::{ClipboardContent, ImageData}; mod warpify_footer; -pub use crate::terminal::CLIAgent; -use warpify_footer::{WarpifyFooterView, WarpifyFooterViewEvent}; - use std::path::Path; use std::sync::{Arc, LazyLock}; use std::time::Duration; -use warpui::r#async::Timer; - -use crate::code_review::diff_state::GitDeltaPreference; -use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; use anyhow::anyhow; use parking_lot::FairMutex; use pathfinder_color::ColorU; -use warp_core::{ - features::FeatureFlag, - report_error, send_telemetry_from_ctx, - settings::Setting, - ui::{ - appearance::Appearance, - color::contrast::{ - high_enough_contrast, pick_best_foreground_color, MinimumAllowedContrast, - }, - theme::{color::internal_colors, Fill as ThemeFill}, - }, +use warp_core::features::FeatureFlag; +use warp_core::settings::Setting; +use warp_core::ui::appearance::Appearance; +use warp_core::ui::color::contrast::{ + high_enough_contrast, pick_best_foreground_color, MinimumAllowedContrast, }; - +use warp_core::ui::theme::color::internal_colors; +use warp_core::ui::theme::Fill as ThemeFill; +use warp_core::{report_error, send_telemetry_from_ctx}; +use warp_terminal::model::escape_sequences::{BRACKETED_PASTE_END, BRACKETED_PASTE_START}; +use warpify_footer::{WarpifyFooterView, WarpifyFooterViewEvent}; +use warpui::elements::{ + ChildView, Container, CrossAxisAlignment, Empty, Expanded, Flex, MainAxisSize, ParentElement, +}; +use warpui::keymap::Keystroke; +use warpui::r#async::Timer; use warpui::{ - elements::{ - ChildView, Container, CrossAxisAlignment, Empty, Expanded, Flex, MainAxisSize, - ParentElement, - }, - keymap::Keystroke, AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; -use crate::{ - ai::blocklist::{agent_view::agent_view_bg_fill, block::cli_controller::CLISubagentEvent}, - cmd_or_ctrl_shift, - server::telemetry::{CLIAgentType, CLISubagentControlState, TelemetryEvent}, - settings::{ - AISettings, AISettingsChangedEvent, CompiledCommandsForCodingAgentToolbar, - InputModeSettings, - }, - terminal::cli_agent_sessions::CLIAgentRichInputCloseReason, - terminal::{ - model_events::{ModelEvent, ModelEventDispatcher}, - TerminalModel, - }, - ui_components::{blended_colors, icons::Icon}, - view_components::action_button::{ - ActionButton, ActionButtonTheme, ButtonSize, KeystrokeSource, TooltipAlignment, - }, -}; - -use warp_terminal::model::escape_sequences::{BRACKETED_PASTE_END, BRACKETED_PASTE_START}; - use super::{RichContentInsertionPosition, TerminalAction, TerminalView}; +use crate::ai::blocklist::agent_view::agent_view_bg_fill; +use crate::ai::blocklist::block::cli_controller::CLISubagentEvent; +use crate::cmd_or_ctrl_shift; +use crate::code_review::diff_state::GitDeltaPreference; +use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; +use crate::server::telemetry::{CLIAgentType, CLISubagentControlState, TelemetryEvent}; +use crate::settings::{ + AISettings, AISettingsChangedEvent, CompiledCommandsForCodingAgentToolbar, InputModeSettings, +}; +use crate::terminal::cli_agent_sessions::CLIAgentRichInputCloseReason; +use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher}; use crate::terminal::view::block_banner::WarpificationMode; +pub use crate::terminal::CLIAgent; +use crate::terminal::TerminalModel; +use crate::ui_components::blended_colors; +use crate::ui_components::icons::Icon; +use crate::view_components::action_button::{ + ActionButton, ActionButtonTheme, ButtonSize, KeystrokeSource, TooltipAlignment, +}; /// Small delay inserted between separate PTY writes to CLI agents. /// (Used both for the mode-switch prefix split and for the `DelayedEnter` diff --git a/app/src/terminal/view/use_agent_footer/mod_tests.rs b/app/src/terminal/view/use_agent_footer/mod_tests.rs index f8c0d63ad3..14b4f32169 100644 --- a/app/src/terminal/view/use_agent_footer/mod_tests.rs +++ b/app/src/terminal/view/use_agent_footer/mod_tests.rs @@ -3,34 +3,29 @@ use std::rc::Rc; use warp_core::settings::Setting as _; use warpui::{App, AppContext, SingletonEntity, ViewContext}; -use crate::{ - ai::{ - agent::{ - conversation::AIConversationId, task::TaskId, AIAgentInput, ServerOutputId, - UserQueryMode, - }, - blocklist::{ - agent_view::AgentViewEntryOrigin, - block::cli_controller::UserTakeOverReason, - model::{AIBlockModel, AIBlockOutputStatus, AIRequestType, OutputStatusUpdateCallback}, - AIBlock, ClientIdentifiers, - }, - llms::LLMId, - }, - features::FeatureFlag, - settings::AISettings, - terminal::cli_agent_sessions::{ - CLIAgentInputState, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, - CLIAgentSessionsModel, - }, - terminal::model::ansi::{BootstrappedValue, Handler as _, InitShellValue}, - terminal::shared_session::SharedSessionSource, - terminal::CLIAgent, - test_util::{add_window_with_terminal, terminal::initialize_app_for_terminal_view}, -}; - use super::super::{AIBlockMetadata, RichContentMetadata, RichContentType}; use super::*; +use crate::ai::agent::conversation::AIConversationId; +use crate::ai::agent::task::TaskId; +use crate::ai::agent::{AIAgentInput, ServerOutputId, UserQueryMode}; +use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; +use crate::ai::blocklist::block::cli_controller::UserTakeOverReason; +use crate::ai::blocklist::model::{ + AIBlockModel, AIBlockOutputStatus, AIRequestType, OutputStatusUpdateCallback, +}; +use crate::ai::blocklist::{AIBlock, ClientIdentifiers}; +use crate::ai::llms::LLMId; +use crate::features::FeatureFlag; +use crate::settings::AISettings; +use crate::terminal::cli_agent_sessions::{ + CLIAgentInputState, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, + CLIAgentSessionsModel, +}; +use crate::terminal::model::ansi::{BootstrappedValue, Handler as _, InitShellValue}; +use crate::terminal::shared_session::SharedSessionSource; +use crate::terminal::CLIAgent; +use crate::test_util::add_window_with_terminal; +use crate::test_util::terminal::initialize_app_for_terminal_view; struct PendingAIBlockModel { conversation_id: AIConversationId, diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index 6c4699a000..9671f4109c 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -1,18 +1,3 @@ -use crate::ai::agent::conversation::ConversationStatus; -use crate::ai::agent::task::TaskId; -use crate::ai::agent::{ - AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, UserQueryMode, -}; -use crate::ai::ambient_agents::AmbientAgentTaskId; -use crate::ai::cloud_environments::{ - AmbientAgentEnvironment, CloudAmbientAgentEnvironment, CloudAmbientAgentEnvironmentModel, -}; -use crate::cloud_object::model::persistence::CloudModel; -use crate::cloud_object::{CloudObjectMetadata, CloudObjectPermissions}; -use crate::server::ids::{ClientId, SyncId}; -use chrono::Local; -use parking_lot::FairMutex; -use session_sharing_protocol::common::CLIAgentSessionState; use std::any::Any; use std::cell::RefCell; use std::collections::HashSet; @@ -20,26 +5,42 @@ use std::pin::pin; use std::rc::Rc; use std::str::FromStr; use std::sync::Arc; + +use chrono::Local; +use parking_lot::FairMutex; +use session_sharing_protocol::common::CLIAgentSessionState; use warp_cli::agent::Harness; use warp_terminal::model::escape_sequences::{BRACKETED_PASTE_END, BRACKETED_PASTE_START}; -use warpui::{ - notification::UserNotification, platform::WindowStyle, Presenter, WindowInvalidation, -}; -use warpui::{App, ReadModel}; +use warpui::notification::UserNotification; +use warpui::platform::WindowStyle; +use warpui::{App, Presenter, ReadModel, WindowInvalidation}; +use super::*; +use crate::ai::agent::conversation::ConversationStatus; +use crate::ai::agent::task::TaskId; +use crate::ai::agent::{ + AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, UserQueryMode, +}; +use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::blocklist::agent_view::toolbar_item::AgentToolbarItemKind; -use crate::ai::blocklist::agent_view::ExitAgentViewError; +use crate::ai::blocklist::agent_view::{AgentViewEntryOrigin, AgentViewState, ExitAgentViewError}; use crate::ai::blocklist::block::cli_controller::UserTakeOverReason; use crate::ai::blocklist::{ - agent_view::{AgentViewEntryOrigin, AgentViewState}, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, InputConfig, InputType, ResponseStreamId, }; +use crate::ai::cloud_environments::{ + AmbientAgentEnvironment, CloudAmbientAgentEnvironment, CloudAmbientAgentEnvironmentModel, +}; use crate::ai::llms::LLMId; +use crate::cloud_object::model::persistence::CloudModel; +use crate::cloud_object::{CloudObjectMetadata, CloudObjectPermissions}; use crate::context_chips::prompt::Prompt; use crate::editor::{AutosuggestionLocation, AutosuggestionType, CrdtOperation}; use crate::features::FeatureFlag; use crate::pane_group::focus_state::PaneGroupFocusState; -use crate::pane_group::{pane::PaneStack, BackingView, TerminalPaneId}; +use crate::pane_group::pane::PaneStack; +use crate::pane_group::{BackingView, TerminalPaneId}; +use crate::server::ids::{ClientId, SyncId}; use crate::server::server_api::ai::SpawnAgentRequest; use crate::settings::import::model::ImportedConfigModel; use crate::settings::{AISettings, AppEditorSettings, WarpPromptSeparator}; @@ -54,9 +55,7 @@ use crate::terminal::cli_agent_sessions::{ CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, }; - -use crate::terminal::model::ansi::{self, InitShellValue}; -use crate::terminal::model::ansi::{BootstrappedValue, PreexecValue}; +use crate::terminal::model::ansi::{self, BootstrappedValue, InitShellValue, PreexecValue}; use crate::terminal::model::block::AgentViewVisibility; use crate::terminal::model::blocks::{insert_block, TotalIndex}; use crate::terminal::model::grid::Dimensions as _; @@ -69,17 +68,14 @@ use crate::terminal::shared_session::{SharedSessionSource, SharedSessionStatus}; use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent; use crate::terminal::view::load_ai_conversation::RestoredAIConversation; use crate::terminal::view::shared_session::ConversationEndedTombstoneView; -use crate::terminal::CLIAgent; - -use crate::terminal::{MockTerminalManager, TerminalManager, TerminalModel}; -use crate::test_util::terminal::add_window_with_id_and_terminal; -use crate::test_util::terminal::initialize_app_for_terminal_view; +use crate::terminal::{CLIAgent, MockTerminalManager, TerminalManager, TerminalModel}; +use crate::test_util::terminal::{ + add_window_with_id_and_terminal, initialize_app_for_terminal_view, +}; use crate::test_util::{add_window_with_terminal, assert_eventually}; use crate::view_components::find::FindWithinBlockState; use crate::workspace::ToastStack; -use super::*; - fn add_window_with_cloud_mode_terminal(app: &mut App) -> ViewHandle { let tips_model = app.add_model(|_| Default::default()); let (_, terminal) = app.add_window(WindowStyle::NotStealFocus, |ctx| { diff --git a/app/src/workflows/mod.rs b/app/src/workflows/mod.rs index b3c1bf2419..558f4b231d 100644 --- a/app/src/workflows/mod.rs +++ b/app/src/workflows/mod.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +pub use cloud_object_models::{CloudWorkflow, CloudWorkflowModel, WorkflowId}; use serde::{Deserialize, Serialize}; use warp_core::context_flag::ContextFlag; use warp_core::features::FeatureFlag; @@ -26,8 +27,7 @@ use crate::appearance::Appearance; use crate::cloud_object::model::view::CloudViewModel; use crate::cloud_object::{ CloudModelType, CloudObjectEventEntrypoint, CloudObjectUpsertParams, CreateCloudObjectResult, - CreateObjectRequest, GenericCloudObject, GenericServerObject, ObjectType, Revision, - UpdateCloudObjectResult, + CreateObjectRequest, GenericServerObject, ObjectType, Revision, UpdateCloudObjectResult, }; use crate::drive::items::workflow::WarpDriveWorkflow; use crate::drive::items::WarpDriveItem; @@ -138,10 +138,6 @@ impl WorkflowViewMode { } } -#[derive(Clone, Debug, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] -pub struct WorkflowId(ServerId); -crate::server_id_traits! { WorkflowId, "Workflow" } - #[derive(Clone, Copy, Debug, PartialEq)] pub enum AIWorkflowOrigin { CommandSearch, @@ -214,21 +210,6 @@ impl WorkflowType { } } -/// The model for a `CloudWorkflow`. -#[derive(Clone, Debug, PartialEq)] -pub struct CloudWorkflowModel { - pub data: Workflow, -} - -impl CloudWorkflowModel { - pub fn new(workflow: Workflow) -> Self { - Self { data: workflow } - } -} - -/// `CloudWorkflow` is a workflow retrieved from the server. -pub type CloudWorkflow = GenericCloudObject; - #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] impl CloudModelType for CloudWorkflowModel { @@ -355,19 +336,3 @@ impl CloudModelType for CloudWorkflowModel { true } } - -impl From for Workflow { - fn from(cloud_workflow: CloudWorkflow) -> Self { - cloud_workflow.model().data.clone() - } -} - -impl From<&CloudWorkflow> for Workflow { - fn from(cloud_workflow: &CloudWorkflow) -> Self { - cloud_workflow.model().data.to_owned() - } -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod tests; diff --git a/app/src/workflows/mod_tests.rs b/app/src/workflows/mod_tests.rs deleted file mode 100644 index 1ec66004c2..0000000000 --- a/app/src/workflows/mod_tests.rs +++ /dev/null @@ -1,70 +0,0 @@ -use warpui::App; - -use super::workflow::{Argument, Workflow}; -use crate::server::ids::SyncId; - -#[test] -fn test_serialize_cloud_workflow() { - App::test((), |_app| async move { - let sample_workflow = Workflow::new("Test name", "Command name"); - assert_eq!( - serde_json::from_str::( - serde_json::to_string(&sample_workflow) - .expect("Serialized workflow.") - .as_str() - ) - .expect("Deserialized workflow."), - sample_workflow - ); - - let arguments = vec![Argument { - name: "Argument".to_string(), - description: Some("no".to_string()), - default_value: None, - arg_type: Default::default(), - }]; - let arguments_workflow = sample_workflow.clone().with_arguments(arguments); - assert_eq!( - serde_json::from_str::( - serde_json::to_string(&arguments_workflow) - .expect("Serialized workflow.") - .as_str() - ) - .expect("Deserialized workflow."), - arguments_workflow - ); - - let description_workflow = sample_workflow.with_description("cool description".to_string()); - assert_eq!( - serde_json::from_str::( - serde_json::to_string(&description_workflow) - .expect("Serialized workflow.") - .as_str() - ) - .expect("Deserialized workflow."), - description_workflow - ); - - let workflow_with_additional_fields = Workflow::Command { - name: "Test".to_string(), - command: "Command".to_string(), - tags: vec![], - description: None, - arguments: vec![], - source_url: Some("url".to_string()), - author: Some("author_name".to_string()), - author_url: None, - shells: vec![], - environment_variables: Some(SyncId::ServerId(123.into())), - }; - assert_eq!( - serde_json::from_str::( - serde_json::to_string(&workflow_with_additional_fields) - .expect("Serialized workflow.") - .as_str() - ) - .expect("Deserialized workflow."), - workflow_with_additional_fields - ); - }); -} diff --git a/app/src/workflows/workflow.rs b/app/src/workflows/workflow.rs index fd80458b08..c7e2fcf3e7 100644 --- a/app/src/workflows/workflow.rs +++ b/app/src/workflows/workflow.rs @@ -1,387 +1 @@ -use serde::{Deserialize, Deserializer, Serialize}; -use serde_json::Value; -use warp_workflows; - -use crate::cloud_object::model::generic_string_model::GenericStringObjectId; -use crate::server::ids::SyncId; - -/// Workflow model to be used inside of `warp-internal` -#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)] -#[serde(tag = "type")] -#[serde(rename_all = "snake_case")] -#[allow(clippy::large_enum_variant)] -pub enum Workflow { - AgentMode { - name: String, - - /// The query to be inserted in the terminal input. - query: String, - - #[serde(skip_serializing_if = "Option::is_none")] - description: Option, - - #[serde(default)] - arguments: Vec, - }, - #[serde(untagged)] - Command { - name: String, - command: String, - #[serde(default)] - tags: Vec, - description: Option, - #[serde(default)] - arguments: Vec, - source_url: Option, - author: Option, - author_url: Option, - #[serde(default)] - shells: Vec, - #[serde(default)] - environment_variables: Option, - }, -} - -impl Workflow { - pub fn name(&self) -> &str { - match self { - Self::AgentMode { name, .. } => name.as_str(), - Self::Command { name, .. } => name.as_str(), - } - } - - /// The core "content" of the workflow. - /// - /// For Command workflows, this is the shell command. For Agent Mode workflows, this is the - /// query. - pub fn content(&self) -> &str { - match self { - Self::AgentMode { query, .. } => query, - Self::Command { command, .. } => command, - } - } - - pub fn prompt(&self) -> Option<&str> { - if let Self::AgentMode { query, .. } = self { - Some(query.as_str()) - } else { - None - } - } - - pub fn command(&self) -> Option<&str> { - if let Self::Command { command, .. } = self { - Some(command.as_str()) - } else { - None - } - } - - pub fn description(&self) -> Option<&String> { - match self { - Self::AgentMode { description, .. } => description.as_ref(), - Self::Command { description, .. } => description.as_ref(), - } - } - - pub fn arguments(&self) -> &Vec { - match self { - Self::AgentMode { arguments, .. } => arguments, - Self::Command { arguments, .. } => arguments, - } - } - - pub fn tags(&self) -> Option<&Vec> { - match self { - Self::Command { tags, .. } => Some(tags), - _ => None, - } - } - - pub fn source_url(&self) -> Option<&String> { - match self { - Self::Command { source_url, .. } => source_url.as_ref(), - _ => None, - } - } - - pub fn author_name(&self) -> Option<&String> { - match self { - Self::Command { author, .. } => author.as_ref(), - _ => None, - } - } - - pub fn shells(&self) -> Option<&Vec> { - match self { - Self::Command { shells, .. } => Some(shells), - _ => None, - } - } - - pub fn is_command_workflow(&self) -> bool { - matches!(self, Self::Command { .. }) - } - - pub fn is_agent_mode_workflow(&self) -> bool { - matches!(self, Self::AgentMode { .. }) - } - - /// Returns `true` if the workflow name starts with the given character (case-insensitive). - /// - /// Used by prompt search datasources to prefix-match on single-character queries, where - /// fuzzy matching would be unreliable. - pub fn name_starts_with_char_ignore_case(&self, c: char) -> bool { - self.name() - .chars() - .next() - .is_some_and(|first| first.eq_ignore_ascii_case(&c)) - } - - /// Return a list of every enum ID referenced by this workflow. - pub fn get_enum_ids(&self) -> Vec { - self.arguments() - .iter() - .filter_map(|arg| match arg.arg_type { - ArgumentType::Enum { enum_id } => Some(enum_id), - _ => None, - }) - .collect() - } - - /// Return a list of every enum ID that has been synced to the server, used for telemetry. - pub fn get_server_enum_ids(&self) -> Vec { - self.arguments() - .iter() - .filter_map(|arg| match arg.arg_type { - ArgumentType::Enum { enum_id } => enum_id.into_server(), - _ => None, - }) - .map(Into::into) - .collect() - } - - pub fn default_env_vars(&self) -> Option { - match self { - Workflow::Command { - environment_variables, - .. - } => *environment_variables, - _ => None, - } - } - - /// Given two IDs, replace any instance of the old ID referenced by this workflow with the new ID. - /// Returns `true` if any instances of the old_id were present. - pub fn replace_object_id(&mut self, old_id: SyncId, new_id: SyncId) -> bool { - let mut changed = false; - let arguments = match self { - Self::Command { - ref mut arguments, .. - } => arguments, - Self::AgentMode { - ref mut arguments, .. - } => arguments, - }; - for arg in arguments.iter_mut() { - match &mut arg.arg_type { - ArgumentType::Enum { enum_id } if *enum_id == old_id => { - *enum_id = new_id; - changed = true; - } - _ => {} - } - } - if let Self::Command { - ref mut environment_variables, - .. - } = self - { - if *environment_variables == Some(old_id) { - *environment_variables = Some(new_id); - changed = true; - } - } - changed - } - - pub fn new(name: impl Into, command: impl Into) -> Self { - Workflow::Command { - name: name.into(), - command: command.into(), - tags: Vec::new(), - arguments: Vec::new(), - description: None, - source_url: None, - author: None, - author_url: None, - shells: Vec::new(), - environment_variables: None, - } - } - - pub fn with_arguments(mut self, new_arguments: Vec) -> Self { - match self { - Workflow::AgentMode { - ref mut arguments, .. - } => { - *arguments = new_arguments; - } - Workflow::Command { - ref mut arguments, .. - } => { - *arguments = new_arguments; - } - } - self - } - - pub fn with_description(mut self, new_description: String) -> Self { - match self { - Workflow::AgentMode { - ref mut description, - .. - } => { - *description = Some(new_description); - } - Workflow::Command { - ref mut description, - .. - } => { - *description = Some(new_description); - } - } - self - } - - pub fn set_name(&mut self, new_name: &str) { - match self { - Workflow::AgentMode { ref mut name, .. } => new_name.clone_into(name), - Workflow::Command { ref mut name, .. } => new_name.clone_into(name), - } - } -} - -/// Create a warp-internal Workflow model from a public-facing workflow -/// https://github.com/warpdotdev/workflows/blob/main/workflow-types/src/lib.rs -impl From for Workflow { - fn from(workflow: warp_workflows::Workflow) -> Self { - Workflow::Command { - name: workflow.name, - command: workflow.command, - description: workflow.description, - arguments: workflow.arguments.into_iter().map(Argument::from).collect(), - tags: workflow.tags, - source_url: workflow.source_url, - author: workflow.author, - author_url: workflow.author_url, - shells: workflow.shells, - environment_variables: None, - } - } -} - -/// Argument model to be used in `warp-internal` -#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash, Default)] -pub struct Argument { - pub name: String, - /// The type of the argument to the workflow - #[serde(flatten, deserialize_with = "deserialize_arg_type")] - pub arg_type: ArgumentType, - pub description: Option, - pub default_value: Option, -} - -impl From for Argument { - fn from(arg: warp_workflows::Argument) -> Self { - Argument { - name: arg.name, - arg_type: ArgumentType::Text, // public workflows only have text arguments - description: arg.description, - default_value: arg.default_value, - } - } -} - -impl Argument { - pub fn new(name: impl Into, arg_type: ArgumentType) -> Self { - Argument { - arg_type, - name: name.into(), - description: None, - default_value: None, - } - } - - pub fn with_description(mut self, description: impl Into) -> Self { - self.description = Some(description.into()); - self - } - - pub fn with_default(mut self, default: impl Into) -> Self { - self.default_value = Some(default.into()); - self - } - - pub fn name(&self) -> &str { - &self.name - } - - pub fn description(&self) -> &Option { - &self.description - } - - pub fn arg_type(&self) -> &ArgumentType { - &self.arg_type - } - - pub fn default_value(&self) -> &Option { - &self.default_value - } -} - -/// The type of the workflow argument -#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash)] -#[serde(tag = "arg_type")] -#[derive(Default)] -pub enum ArgumentType { - #[default] - Text, - Enum { - /// The ID of the associated WorkflowEnum Generic String Object - enum_id: SyncId, - }, -} - -/// Custom deserialization for argument types, used to both `flatten` the argument type -/// and allow for the specification of `default` behavior. -/// -/// Necessary because serde currently does not support the use of `flatten` with a `default`, -/// related GitHub issue here: https://github.com/serde-rs/serde/issues/1626 -fn deserialize_arg_type<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - let value: Value = Deserialize::deserialize(deserializer)?; - - let arg_type = match value.get("arg_type").and_then(|value| value.as_str()) { - Some("Text") => ArgumentType::Text, - Some("Enum") => { - let enum_id = value - .get("enum_id") - .ok_or(serde::de::Error::missing_field("enum_id"))?; - let deserialized_id = SyncId::deserialize(enum_id) - .map_err(|_| serde::de::Error::custom("Unable to parse enum_id"))?; - ArgumentType::Enum { - enum_id: deserialized_id, - } - } - _ => ArgumentType::default(), - }; - - Ok(arg_type) -} - -#[cfg(test)] -#[path = "workflow_tests.rs"] -mod tests; +pub use cloud_object_models::{Argument, ArgumentType, Workflow}; diff --git a/app/src/workflows/workflow_enum.rs b/app/src/workflows/workflow_enum.rs index 668d2651e5..a4c6d2d6fb 100644 --- a/app/src/workflows/workflow_enum.rs +++ b/app/src/workflows/workflow_enum.rs @@ -1,18 +1,14 @@ -pub use warp_server_client::cloud_object::models::{EnumVariants, WorkflowEnum}; - -use crate::cloud_object::model::generic_string_model::{ - GenericStringModel, GenericStringObjectId, StringModel, +pub use cloud_object_models::{ + CloudWorkflowEnum, CloudWorkflowEnumModel, EnumVariants, WorkflowEnum, }; -use crate::cloud_object::model::json_model::{JsonModel, JsonSerializer}; + +use crate::cloud_object::model::generic_string_model::StringModel; +use crate::cloud_object::model::json_model::JsonModel; use crate::cloud_object::{ - GenericCloudObject, GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, - Revision, + GenericStringObjectFormat, GenericStringObjectUniqueKey, JsonObjectType, Revision, }; use crate::server::sync_queue::QueueItem; -pub type CloudWorkflowEnum = GenericCloudObject; -pub type CloudWorkflowEnumModel = GenericStringModel; - impl StringModel for WorkflowEnum { type CloudObjectType = CloudWorkflowEnum; diff --git a/app/src/workspaces/gql_convert.rs b/app/src/workspaces/gql_convert.rs index 441871c6fb..bb2542b3a0 100644 --- a/app/src/workspaces/gql_convert.rs +++ b/app/src/workspaces/gql_convert.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -use anyhow::{anyhow, bail}; +use anyhow::{anyhow, bail, Result}; use regex::Regex; use warp_graphql::billing::{ AiAutonomyPolicy as GqlAiAutonomyPolicy, AmbientAgentsPolicy as GqlAmbientAgentsPolicy, @@ -22,11 +22,10 @@ use warp_graphql::billing::{ UsageVisibilityGranularity as GqlUsageVisibilityGranularity, UsageVisibilityPolicy as GqlUsageVisibilityPolicy, WarpAiPolicy as GqlWarpAiPolicy, }; -use warp_graphql::object::CloudObjectWithDescendants; use warp_graphql::queries::get_conversation_usage as gql_usage; use warp_graphql::queries::get_workspaces_metadata_for_user::User as GqlUser; use warp_graphql::subscriptions::get_warp_drive_updates::WarpDriveUpdate; -use warp_graphql::user::{DiscoverableTeamData as GqlDiscoverableTeamData, PublicUserProfile}; +use warp_graphql::user::DiscoverableTeamData as GqlDiscoverableTeamData; use warp_graphql::workspace::{ AddonCreditsSettings as GqlAddonCreditsSettings, AdminEnablementSetting as GqlAdminEnablementSetting, AiAutonomyValue as GqlAiAutonomyValue, @@ -42,7 +41,6 @@ use warp_graphql::workspace::{ }; use super::team::{DiscoverableTeam, MembershipRole, Team, TeamMember}; -use super::user_profiles::UserProfileWithUID; use super::user_workspaces::WorkspacesMetadataResponse; use super::workspace::{ AIAutonomyPolicy, AddonCreditsSettings, AdminEnablementSetting, AiAutonomySettings, @@ -64,14 +62,9 @@ use crate::ai::execution_profiles::{ }; use crate::ai::{BonusGrant, BonusGrantScope}; use crate::auth::UserUid; -use crate::cloud_object::{ - ServerAIExecutionProfile, ServerAIFact, ServerAmbientAgentEnvironment, ServerCloudObject, - ServerEnvVarCollection, ServerFolder, ServerMCPServer, ServerNotebook, ServerPreference, - ServerScheduledAmbientAgent, ServerTemplatableMCPServer, ServerWorkflow, ServerWorkflowEnum, - TryFromGql as _, -}; use crate::server::cloud_objects::listener::ObjectUpdateMessage; use crate::server::experiments::ServerExperiment; +use crate::server::graphql::schema::object_action_history_from_gql; use crate::server::ids::ServerId; use crate::settings::AgentModeCommandExecutionPredicate; use crate::workspaces::workspace::{ @@ -1061,168 +1054,51 @@ impl From for WorkspacesMetadataResponse { } } -impl From for UserProfileWithUID { - fn from(value: PublicUserProfile) -> Self { - UserProfileWithUID { - firebase_uid: UserUid::new(&value.uid), - display_name: value.display_name, - email: value.email.unwrap_or_default(), - photo_url: value.photo_url.unwrap_or_default(), +pub fn object_update_message_from_gql(value: WarpDriveUpdate) -> Result { + match value { + WarpDriveUpdate::ObjectActionOccurred(message) => { + Ok(ObjectUpdateMessage::ObjectActionOccurred { + history: object_action_history_from_gql(message.history)?, + }) } - } -} - -impl TryFrom for ObjectUpdateMessage { - type Error = anyhow::Error; - - fn try_from(value: WarpDriveUpdate) -> Result { - match value { - WarpDriveUpdate::ObjectActionOccurred(message) => { - Ok(ObjectUpdateMessage::ObjectActionOccurred { - history: message.history.try_into()?, - }) - } - WarpDriveUpdate::ObjectContentUpdated(message) => { - let server_object = message.object.try_into()?; - let last_editor = message.last_editor.map(|e| e.into()); - Ok(ObjectUpdateMessage::ObjectContentChanged { - server_object: Box::new(server_object), - last_editor, - }) - } - WarpDriveUpdate::ObjectDeleted(message) => Ok(ObjectUpdateMessage::ObjectDeleted { + WarpDriveUpdate::ObjectContentUpdated(message) => { + let server_object = message.object.try_into()?; + let last_editor = message.last_editor.map(|e| e.into()); + Ok(ObjectUpdateMessage::ObjectContentChanged { + server_object: Box::new(server_object), + last_editor, + }) + } + WarpDriveUpdate::ObjectDeleted(message) => Ok(ObjectUpdateMessage::ObjectDeleted { + object_uid: ServerId::from_string_lossy(message.object_uid.inner()), + }), + WarpDriveUpdate::ObjectMetadataUpdated(message) => { + Ok(ObjectUpdateMessage::ObjectMetadataChanged { + metadata: message.metadata.try_into()?, + }) + } + WarpDriveUpdate::ObjectPermissionsUpdated(message) => { + Ok(ObjectUpdateMessage::ObjectPermissionsChangedV2 { object_uid: ServerId::from_string_lossy(message.object_uid.inner()), - }), - WarpDriveUpdate::ObjectMetadataUpdated(message) => { - Ok(ObjectUpdateMessage::ObjectMetadataChanged { - metadata: message.metadata.try_into()?, - }) - } - WarpDriveUpdate::ObjectPermissionsUpdated(message) => { - Ok(ObjectUpdateMessage::ObjectPermissionsChangedV2 { - object_uid: ServerId::from_string_lossy(message.object_uid.inner()), - user_profiles: message - .user_profiles - .into_iter() - .flatten() - .map(Into::into) - .collect(), - permissions: message.permissions.try_into()?, - }) - } - WarpDriveUpdate::TeamMembershipsChanged(_) => { - Ok(ObjectUpdateMessage::TeamMembershipsChanged) - } - WarpDriveUpdate::AmbientTaskUpdated(message) => { - Ok(ObjectUpdateMessage::AmbientTaskUpdated { - task_id: message.task_id.inner().to_string(), - timestamp: message.task_updated_ts.utc(), - }) - } - WarpDriveUpdate::Unknown => bail!("Unexpected WarpDriveUpdate variant"), + user_profiles: message + .user_profiles + .into_iter() + .flatten() + .map(Into::into) + .collect(), + permissions: message.permissions.try_into()?, + }) } - } -} - -impl TryFrom for ServerCloudObject { - type Error = anyhow::Error; - - fn try_from(value: warp_graphql::object::CloudObject) -> Result { - match value { - warp_graphql::object::CloudObject::AIConversation(_) => Err(anyhow::anyhow!( - "AIConversation is not a supported object type for this operation" - )), - warp_graphql::object::CloudObject::Folder(folder) => Ok(ServerCloudObject::Folder( - ServerFolder::try_from_gql(folder)?, - )), - warp_graphql::object::CloudObject::GenericStringObject(gso) => { - match gso.format.clone() { - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonEnvVarCollection => { - Ok(ServerCloudObject::EnvVarCollection(ServerEnvVarCollection::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonPreference => { - Ok(ServerCloudObject::Preference(ServerPreference::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonWorkflowEnum => { - Ok(ServerCloudObject::WorkflowEnum(ServerWorkflowEnum::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonAIFact => { - Ok(ServerCloudObject::AIFact(ServerAIFact::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonMCPServer => { - Ok(ServerCloudObject::MCPServer(ServerMCPServer::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonAIExecutionProfile => { - Ok(ServerCloudObject::AIExecutionProfile(ServerAIExecutionProfile::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonTemplatableMCPServer => { - Ok(ServerCloudObject::TemplatableMCPServer(ServerTemplatableMCPServer::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonCloudEnvironment => { - Ok(ServerCloudObject::AmbientAgentEnvironment(ServerAmbientAgentEnvironment::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonScheduledAmbientAgent => { - Ok(ServerCloudObject::ScheduledAmbientAgent(ServerScheduledAmbientAgent::try_from_gql(gso)?)) - } - } - } - warp_graphql::object::CloudObject::Notebook(notebook) => Ok( - ServerCloudObject::Notebook(ServerNotebook::try_from_gql(notebook)?), - ), - warp_graphql::object::CloudObject::Workflow(workflow) => Ok( - ServerCloudObject::Workflow(Box::new(ServerWorkflow::try_from_gql(workflow)?)), - ), - warp_graphql::object::CloudObject::Unknown => { - Err(anyhow::anyhow!("Unable to convert cloud object type")) - } + WarpDriveUpdate::TeamMembershipsChanged(_) => { + Ok(ObjectUpdateMessage::TeamMembershipsChanged) } - } -} - -impl TryFrom for ServerCloudObject { - type Error = anyhow::Error; - - fn try_from(value: CloudObjectWithDescendants) -> Result { - match value { - CloudObjectWithDescendants::AIConversation(_) => { - Err(anyhow::anyhow!("AIConversation is not a supported object type for this operation")) - } - CloudObjectWithDescendants::FolderWithDescendants(fwd) => { - Ok(ServerCloudObject::Folder(ServerFolder::try_from_gql(fwd.folder)?)) - } - CloudObjectWithDescendants::GenericStringObject(gso) => match gso.format.clone() { - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonEnvVarCollection => { - Ok(ServerCloudObject::EnvVarCollection(ServerEnvVarCollection::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonPreference => { - Ok(ServerCloudObject::Preference(ServerPreference::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonWorkflowEnum => { - Ok(ServerCloudObject::WorkflowEnum(ServerWorkflowEnum::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonAIFact => { - Ok(ServerCloudObject::AIFact(ServerAIFact::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonMCPServer => { - Ok(ServerCloudObject::MCPServer(ServerMCPServer::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonAIExecutionProfile => { - Ok(ServerCloudObject::AIExecutionProfile(ServerAIExecutionProfile::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonTemplatableMCPServer => { - Ok(ServerCloudObject::TemplatableMCPServer(ServerTemplatableMCPServer::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonCloudEnvironment => { - Ok(ServerCloudObject::AmbientAgentEnvironment(ServerAmbientAgentEnvironment::try_from_gql(gso)?)) - } - warp_graphql::generic_string_object::GenericStringObjectFormat::JsonScheduledAmbientAgent => { - Ok(ServerCloudObject::ScheduledAmbientAgent(ServerScheduledAmbientAgent::try_from_gql(gso)?)) - } - } - CloudObjectWithDescendants::Notebook(notebook) => Ok(ServerCloudObject::Notebook(ServerNotebook::try_from_gql(notebook)?)), - CloudObjectWithDescendants::Workflow(workflow) => Ok(ServerCloudObject::Workflow(Box::new(ServerWorkflow::try_from_gql(workflow)?))), - CloudObjectWithDescendants::Unknown => Err(anyhow::anyhow!("Unable to convert cloud object with descendants type")), + WarpDriveUpdate::AmbientTaskUpdated(message) => { + Ok(ObjectUpdateMessage::AmbientTaskUpdated { + task_id: message.task_id.inner().to_string(), + timestamp: message.task_updated_ts.utc(), + }) } + WarpDriveUpdate::Unknown => bail!("Unexpected WarpDriveUpdate variant"), } } diff --git a/app/src/workspaces/user_profiles.rs b/app/src/workspaces/user_profiles.rs index 93857fc87d..bfaf9197d0 100644 --- a/app/src/workspaces/user_profiles.rs +++ b/app/src/workspaces/user_profiles.rs @@ -1,40 +1,20 @@ use std::collections::HashMap; -use session_sharing_protocol::common::ProfileData; use warpui::{Entity, SingletonEntity}; use crate::auth::UserUid; +pub use cloud_object_models::UserProfileWithUID; pub enum UserProfilesEvent {} -/// Public struct for storing all the UserProfile data that's fed in from either sqlite or the server. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct UserProfileWithUID { - pub firebase_uid: UserUid, - pub display_name: Option, - pub email: String, - pub photo_url: String, -} - -impl From for UserProfileWithUID { - fn from(data: ProfileData) -> Self { - Self { - firebase_uid: UserUid::new(&data.firebase_uid), - display_name: Some(data.display_name), - email: data.email.unwrap_or_default(), - photo_url: data.photo_url.unwrap_or_default(), - } - } -} - -impl From for UserProfileWithUID { - fn from(user_profile: crate::persistence::model::UserProfile) -> Self { - UserProfileWithUID { - firebase_uid: UserUid::new(&user_profile.firebase_uid), - display_name: user_profile.display_name, - email: user_profile.email, - photo_url: user_profile.photo_url, - } +pub fn user_profile_from_persistence( + user_profile: crate::persistence::model::UserProfile, +) -> UserProfileWithUID { + UserProfileWithUID { + firebase_uid: UserUid::new(&user_profile.firebase_uid), + display_name: user_profile.display_name, + email: user_profile.email, + photo_url: user_profile.photo_url, } } diff --git a/crates/cloud_object_client/Cargo.toml b/crates/cloud_object_client/Cargo.toml new file mode 100644 index 0000000000..b6e6e25d16 --- /dev/null +++ b/crates/cloud_object_client/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "cloud_object_client" +version = "0.1.0" +edition = "2024" +authors.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +async-channel.workspace = true +async-trait.workspace = true +chrono.workspace = true +cloud_object_models.workspace = true +cloud_objects.workspace = true +mockall = { version = "0.13.1", optional = true } +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +warp_core.workspace = true +warp_graphql.workspace = true + +[features] +test-util = ["dep:mockall", "cloud_object_models/test-util", "cloud_objects/test-util"] diff --git a/crates/cloud_object_client/src/lib.rs b/crates/cloud_object_client/src/lib.rs new file mode 100644 index 0000000000..0015028e68 --- /dev/null +++ b/crates/cloud_object_client/src/lib.rs @@ -0,0 +1,372 @@ +use std::collections::HashMap; + +use anyhow::Result; +use async_channel::Sender; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use cloud_objects::{ + drive::sharing::SharingAccessLevel, + ids::{FolderId, GenericStringObjectId, HashedSqliteId, ObjectUid, ServerId, SyncId}, +}; +#[cfg(any(test, feature = "test-util"))] +use mockall::automock; +use warp_graphql::{mcp_gallery_template::MCPGalleryTemplate, object_permissions::AccessLevel}; + +pub use cloud_object_models::*; +pub use cloud_objects::cloud_object::*; + +/// 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), +} + +/// The type of action that occurred on an object, such as an execution, selection, so on +/// and so forth. +#[derive(Clone, Debug, PartialEq)] +pub enum ObjectActionType { + Execute, +} + +// In order to convert from a graphql type and from a SQLite read, the action type +// implements to_string(). +// +// Temporarily suppress clippy warnings about the `ToString` impl until we +// move `ObjectType` away from using `std::fmt::Display` for serialization. +#[allow(clippy::to_string_trait_impl)] +impl ToString for ObjectActionType { + fn to_string(&self) -> String { + match self { + ObjectActionType::Execute => String::from("EXECUTE"), + } + } +} + +impl ObjectActionType { + pub fn singular(&self) -> String { + match self { + ObjectActionType::Execute => "run".to_string(), + } + } + + pub fn plural(&self) -> String { + match self { + ObjectActionType::Execute => "runs".to_string(), + } + } +} + +/// We track object actions, both those that have been sent to the server and not, through this +/// type. A single ObjectAction represents an object_id, action pair and a subtype that contains data +/// about the action(s). Each ObjectAction either represents one action or a summary of identical actions +/// that occurred at different times. We summarize old actions in order to save memory footprint on the client. +#[derive(Clone, Debug, PartialEq)] +pub struct ObjectAction { + pub action_type: ObjectActionType, + pub uid: ObjectUid, + pub hashed_sqlite_id: HashedSqliteId, + // This action either represents one action or a consolidation of multiple actions. + pub action_subtype: ObjectActionSubtype, +} + +impl ObjectAction { + pub fn is_pending(&self) -> bool { + match self.action_subtype { + ObjectActionSubtype::SingleAction { pending, .. } => pending, + ObjectActionSubtype::BundledActions { .. } => false, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ObjectActionHistory { + pub uid: ObjectUid, + pub hashed_sqlite_id: HashedSqliteId, + pub latest_processed_at_timestamp: DateTime, + pub actions: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ObjectActionSubtype { + SingleAction { + timestamp: DateTime, + processed_at_timestamp: Option>, + data: Option, + pending: bool, + }, + BundledActions { + count: i32, + oldest_timestamp: DateTime, + latest_timestamp: DateTime, + latest_processed_at_timestamp: DateTime, + }, +} + +#[derive(Default)] +pub struct InitialLoadResponse { + pub updated_notebooks: Vec, + pub deleted_notebooks: Vec, + pub updated_workflows: Vec, + pub deleted_workflows: Vec, + pub updated_folders: Vec, + pub deleted_folders: Vec, + pub updated_generic_string_objects: + HashMap>>, + pub deleted_generic_string_objects: Vec, + pub user_profiles: Vec, + pub action_histories: Vec, + pub mcp_gallery: Vec, +} + +pub struct GetCloudObjectResponse { + pub object: ServerCloudObject, + pub descendants: Vec, + pub action_histories: Vec, +} + +#[derive(Debug, Clone)] +#[allow(clippy::enum_variant_names)] +pub enum ObjectUpdateMessage { + ObjectMetadataChanged { + metadata: ServerMetadata, + }, + ObjectPermissionsChanged, + ObjectPermissionsChangedV2 { + object_uid: ServerId, + permissions: ServerPermissions, + user_profiles: Vec, + }, + ObjectContentChanged { + server_object: Box, + last_editor: Option, + }, + ObjectDeleted { + object_uid: ServerId, + }, + ObjectActionOccurred { + history: ObjectActionHistory, + }, + TeamMembershipsChanged, + AmbientTaskUpdated { + task_id: String, + timestamp: DateTime, + }, +} + +impl ObjectUpdateMessage { + pub 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", + } + } +} + +#[derive(Clone, Debug)] +pub enum ObjectPermissionUpdateResult { + Success, + Failure, +} + +#[derive(Clone, Debug)] +pub struct ObjectPermissionsUpdateData { + pub permissions: ServerPermissions, + pub profiles: Vec, +} + +#[derive(Clone, Debug)] +pub enum ObjectMetadataUpdateResult { + Success { metadata: Box }, + Failure, +} + +pub enum ObjectDeleteResult { + Success { deleted_ids: Vec }, + Failure, +} + +#[cfg_attr(any(test, feature = "test-util"), 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; + + /// 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, + ) -> Result>; + + /// 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; + + async fn create_generic_string_object( + &self, + format: GenericStringObjectFormat, + uniqueness_key: Option, + request: CreateObjectRequest, + ) -> Result; + + /// Creates a notebook on the server, returning the ID and revision of the object after + /// creation. + async fn create_notebook( + &self, + request: CreateObjectRequest, + ) -> Result; + + /// 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: cloud_object_models::NotebookId, + title: Option, + data: Option, + revision: Option, + ) -> Result>; + + async fn create_folder(&self, request: CreateObjectRequest) -> Result; + + async fn update_folder( + &self, + folder_id: FolderId, + name: SerializedModel, + ) -> Result>; + + async fn update_generic_string_object( + &self, + object_id: GenericStringObjectId, + model: SerializedModel, + revision: Option, + ) -> Result>>; + + /// Sets the current editor of the notebook to be the logged in user + async fn grab_notebook_edit_access( + &self, + notebook_id: cloud_object_models::NotebookId, + ) -> Result; + + /// Sets the current editor of the notebook to be null + async fn give_up_notebook_edit_access( + &self, + notebook_id: cloud_object_models::NotebookId, + ) -> Result; + + /// Gets updates for all Warp Drive actions. + async fn get_warp_drive_updates( + &self, + message_sender: Sender, + stream_ready_sender: Sender<()>, + ) -> Result<()>; + + async fn fetch_changed_objects( + &self, + objects_to_update: ObjectsToUpdate, + force_refresh: bool, + ) -> Result; + + async fn fetch_single_cloud_object(&self, id: ServerId) -> Result; + + // Transfers a notebook to the given owner + async fn transfer_notebook_owner( + &self, + notebook_id: cloud_object_models::NotebookId, + owner: Owner, + ) -> Result; + + async fn transfer_workflow_owner(&self, workflow_id: WorkflowId, owner: Owner) -> Result; + + async fn transfer_generic_string_object_owner( + &self, + workflow_id: GenericStringObjectId, + owner: Owner, + ) -> Result; + + async fn trash_object(&self, id: ServerId) -> Result; + + async fn untrash_object(&self, id: ServerId) -> Result; + + async fn delete_object(&self, id: ServerId) -> Result; + + async fn empty_trash(&self, owner: Owner) -> Result; + + async fn move_object( + &self, + id: ServerId, + folder_id: Option, + owner: Owner, + object_type: ObjectType, + ) -> Result; + + async fn record_object_action( + &self, + id: ServerId, + action_type: ObjectActionType, + timestamp: DateTime, + data: Option, + ) -> Result; + + async fn leave_object(&self, id: ServerId) -> Result; + + async fn set_object_link_permissions( + &self, + object_id: ServerId, + access_level: SharingAccessLevel, + ) -> Result; + + async fn remove_object_link_permissions( + &self, + object_id: ServerId, + ) -> Result; + + async fn add_object_guests( + &self, + object_id: ServerId, + guest_emails: Vec, + access_level: AccessLevel, + ) -> Result; + + async fn update_object_guests( + &self, + object_id: ServerId, + guest_emails: Vec, + access_level: AccessLevel, + ) -> Result; + + async fn remove_object_guest( + &self, + object_id: ServerId, + guest: GuestIdentifier, + ) -> Result; + + /// 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>>; +} diff --git a/crates/cloud_object_models/Cargo.toml b/crates/cloud_object_models/Cargo.toml new file mode 100644 index 0000000000..5c334a8db1 --- /dev/null +++ b/crates/cloud_object_models/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "cloud_object_models" +version = "0.1.0" +edition = "2024" +authors.workspace = true +publish.workspace = true +license.workspace = true + +[features] +agent_mode_evals = [] +test-util = ["cloud_objects/test-util"] + +[dependencies] +ai.workspace = true +anyhow.workspace = true +chrono.workspace = true +cfg-if.workspace = true +cloud_objects.workspace = true +handlebars.workspace = true +lazy_static.workspace = true +log.workspace = true +regex.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_regex = "1.1.0" +session-sharing-protocol.workspace = true +settings.workspace = true +settings_value = { workspace = true, features = ["derive"] } +uuid.workspace = true +warp-workflows.workspace = true +warp_cli.workspace = true +warp_core.workspace = true +warp_graphql.workspace = true +warp_util.workspace = true + +[dev-dependencies] +cloud_objects = { workspace = true, features = ["test-util"] } diff --git a/crates/cloud_object_models/src/ai_execution_profile.rs b/crates/cloud_object_models/src/ai_execution_profile.rs new file mode 100644 index 0000000000..0da946641d --- /dev/null +++ b/crates/cloud_object_models/src/ai_execution_profile.rs @@ -0,0 +1,518 @@ +use std::path::PathBuf; + +use ai::LLMId; +use cloud_objects::{ + cloud_object::{GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType}, + ids::GenericStringObjectId, +}; +use lazy_static::lazy_static; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use warp_core::{channel::ChannelState, features::FeatureFlag}; + +use crate::{JsonModel, JsonSerializer}; + +pub const PROFILE_NAME_MAX_LENGTH: usize = 50; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ActionPermission { + AgentDecides, + AlwaysAllow, + AlwaysAsk, + // This is intended to catch deserialization errors whenever we add new variants to this enum. Say we + // want to add a "Never" variant. Without this catch-all, old clients wouldn't be able to deserialize + // a "Never" into one of the existing options. + #[serde(other)] + Unknown, +} + +impl ActionPermission { + pub fn description(&self) -> &'static str { + match self { + ActionPermission::AgentDecides | ActionPermission::Unknown => { + "The Agent chooses the safest path: acting on its own when confident, and asking for approval when uncertain." + } + ActionPermission::AlwaysAllow => { + "Give the Agent full autonomy — no manual approval ever required." + } + ActionPermission::AlwaysAsk => { + "Require explicit approval before the Agent takes any action." + } + } + } + + pub fn is_always_ask(&self) -> bool { + matches!(self, Self::AlwaysAsk) + } + + pub fn is_always_allow(&self) -> bool { + matches!(self, Self::AlwaysAllow) + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum WriteToPtyPermission { + // This is for backwards compatibility with the old "Never" value. + #[serde(alias = "Never")] + AlwaysAllow, + #[default] + AlwaysAsk, + AskOnFirstWrite, + // This is intended to catch deserialization errors whenever we add new variants to this enum. + #[serde(other)] + Unknown, +} + +impl WriteToPtyPermission { + pub fn description(&self) -> &'static str { + match self { + WriteToPtyPermission::AlwaysAllow => ActionPermission::AlwaysAllow.description(), + WriteToPtyPermission::AskOnFirstWrite => { + "The agent will ask for permission the first time it needs to interact with a running command. After that, it will continue automatically for the rest of that command." + } + WriteToPtyPermission::AlwaysAsk => { + "The agent will always ask for permission to interact with a running command." + } + WriteToPtyPermission::Unknown => ActionPermission::Unknown.description(), + } + } + + pub fn is_always_allow(&self) -> bool { + matches!(self, Self::AlwaysAllow) + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ComputerUsePermission { + #[default] + Never, + AlwaysAsk, + AlwaysAllow, + // This is intended to catch deserialization errors whenever we add new variants to this enum. + #[serde(other)] + Unknown, +} + +impl ComputerUsePermission { + pub fn description(&self) -> &'static str { + match self { + ComputerUsePermission::Never => { + "Computer use tools are disabled and will not be available to the Agent." + } + ComputerUsePermission::AlwaysAsk => { + "Require explicit approval before the Agent uses computer use tools." + } + ComputerUsePermission::AlwaysAllow => { + "Give the Agent full autonomy to use computer use tools without approval." + } + ComputerUsePermission::Unknown => "Unknown setting.", + } + } + + pub fn is_enabled(&self) -> bool { + !matches!(self, Self::Never | Self::Unknown) + } + + pub fn is_always_allow(&self) -> bool { + matches!(self, Self::AlwaysAllow) + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum RunAgentsPermission { + NeverAllow, + AlwaysAllow, + #[default] + AlwaysAsk, + + // This is intended to catch deserialization errors whenever we add new variants to this enum. + #[serde(other)] + Unknown, +} + +impl RunAgentsPermission { + pub fn description(&self) -> &'static str { + match self { + RunAgentsPermission::NeverAllow => { + "The Agent cannot run child agents and the run_agents tool will not be available." + } + RunAgentsPermission::AlwaysAllow => { + "Give the Agent full autonomy to run child agents without approval." + } + RunAgentsPermission::AlwaysAsk => { + "Require explicit approval before the Agent runs child agents." + } + RunAgentsPermission::Unknown => "Unknown setting.", + } + } + + pub fn is_enabled(&self) -> bool { + matches!(self, Self::AlwaysAllow | Self::AlwaysAsk) + } + + pub fn is_always_allow(&self) -> bool { + matches!(self, Self::AlwaysAllow) + } + + pub fn is_never_allow(&self) -> bool { + matches!(self, Self::NeverAllow | Self::Unknown) + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AskUserQuestionPermission { + /// Never pause; skip questions and continue with best judgment. + Never, + /// Pause and wait for the user, unless auto-approve mode is enabled. + AskExceptInAutoApprove, + /// Always pause and wait for the user to answer before continuing, even in auto-approve mode. + #[default] + AlwaysAsk, + + // This is intended to catch deserialization errors whenever we add new variants to this enum. + #[serde(other)] + Unknown, +} + +impl AskUserQuestionPermission { + pub fn label(&self) -> &'static str { + match self { + AskUserQuestionPermission::Never => "Never ask", + AskUserQuestionPermission::AskExceptInAutoApprove => "Ask unless auto-approve", + AskUserQuestionPermission::AlwaysAsk | AskUserQuestionPermission::Unknown => { + "Always ask" + } + } + } + + pub fn description(&self) -> &'static str { + match self { + AskUserQuestionPermission::AskExceptInAutoApprove + | AskUserQuestionPermission::Unknown => { + "The Agent may ask a question and pause for your response, but will continue automatically when auto-approve is on." + } + AskUserQuestionPermission::Never => { + "The Agent will not ask questions and will continue with its best judgment." + } + AskUserQuestionPermission::AlwaysAsk => { + "The Agent may ask a question and will pause for your response even when auto-approve is on." + } + } + } +} + +/// Predicate types to match commands that can be executed by Agent Mode. +#[derive(Debug, Serialize, Deserialize, Clone)] +enum AgentModeCommandExecutionPredicateType { + /// A regex with start (`^`) and end (`$`) anchors. + /// + /// We want regex rules to apply to the entire cmd string so we anchor them + /// (there isn't any efficient way to apply to the entire cmd string at match-time). + #[serde(with = "serde_regex")] + AnchoredRegex(Regex), +} + +impl AgentModeCommandExecutionPredicateType { + fn new_regex(regex: &str) -> Result { + // Redundant anchors aren't a problem so we can unconditionally add them. + let anchored_regex = Regex::new(&format!("^{regex}$"))?; + Ok(Self::AnchoredRegex(anchored_regex)) + } + + fn matches(&self, cmd: &str) -> bool { + match self { + Self::AnchoredRegex(regex) => regex.is_match(cmd), + } + } +} + +impl PartialEq for AgentModeCommandExecutionPredicateType { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::AnchoredRegex(a), Self::AnchoredRegex(b)) => { + // Indexing should be safe since they're guaranteed to have at least + // the anchors around them. + let a_unanchored = &a.as_str()[1..a.as_str().len() - 1]; + let b_unanchored = &b.as_str()[1..b.as_str().len() - 1]; + a_unanchored == b_unanchored + } + } + } +} + +impl std::fmt::Display for AgentModeCommandExecutionPredicateType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AnchoredRegex(regex) => { + write!(f, "{}", ®ex.as_str()[1..regex.as_str().len() - 1]) + } + } + } +} + +/// A wrapper around [`AgentModeCommandExecutionPredicateType`] to enforce +/// the use of the provided constructors rather than direct construction of the variants. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(transparent)] +pub struct AgentModeCommandExecutionPredicate(AgentModeCommandExecutionPredicateType); + +impl schemars::JsonSchema for AgentModeCommandExecutionPredicate { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("AgentModeCommandExecutionPredicate") + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + generator.subschema_for::() + } +} + +impl AgentModeCommandExecutionPredicate { + pub fn new_regex(regex: &str) -> Result { + Ok(Self(AgentModeCommandExecutionPredicateType::new_regex( + regex, + )?)) + } + + pub fn matches(&self, cmd: &str) -> bool { + self.0.matches(cmd) + } +} + +impl std::fmt::Display for AgentModeCommandExecutionPredicate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl settings_value::SettingsValue for AgentModeCommandExecutionPredicate { + fn to_file_value(&self) -> serde_json::Value { + serde_json::Value::String(self.to_string()) + } + + fn from_file_value(value: &serde_json::Value) -> Option { + value.as_str().and_then(|s| Self::new_regex(s).ok()) + } +} + +lazy_static! { + static ref OPTIONAL_ARGS_REGEX: Regex = + Regex::new(r"(\s.*)?").expect("Can parse optional args regex"); +} + +cfg_if::cfg_if! { + if #[cfg(test)] { + lazy_static! { + // Compiling the regexes for the default command execution allowlist/denylist can be slow + // in an unoptimized build, so we use empty lists in unit tests. + pub static ref DEFAULT_COMMAND_EXECUTION_ALLOWLIST: Vec = vec![]; + pub static ref DEFAULT_COMMAND_EXECUTION_DENYLIST: Vec = vec![]; + } + } else { + lazy_static! { + pub static ref DEFAULT_COMMAND_EXECUTION_ALLOWLIST: Vec = vec![ + AgentModeCommandExecutionPredicate::new_regex(&format!("cat{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default cat rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("echo{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default echo rule into regex"), + AgentModeCommandExecutionPredicate::new_regex("find .*").expect("Can parse default find rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("grep{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default grep rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("ls{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default ls rule into regex"), + AgentModeCommandExecutionPredicate::new_regex("which .*").expect("Can parse default which rule into regex"), + ]; + + pub static ref DEFAULT_COMMAND_EXECUTION_DENYLIST: Vec = vec![ + AgentModeCommandExecutionPredicate::new_regex(&format!("bash{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default bash rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("fish{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default fish rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("pwsh{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default pwsh rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("sh{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default sh rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("zsh{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default zsh rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("curl{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default curl rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("eval{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default eval rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("exec{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default exec rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("source{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default source rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("wget{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default wget rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("dig{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default dig rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("nslookup{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default nslookup rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("host{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default host rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("ssh{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default ssh rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("scp{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default scp rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("rsync{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default rsync rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("telnet{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default telnet rule into regex"), + AgentModeCommandExecutionPredicate::new_regex(&format!("rm{}", OPTIONAL_ARGS_REGEX.as_str())).expect("Can parse default rm rule into regex"), + ]; + } + } +} + +/// Core data structure representing an AI execution profile, which includes model configuration, +/// behavior settings, and permissions. +/// +/// NOTE: `planning_model` was removed after planning via subagent was deprecated; serialized legacy +/// profiles may include a `planning_model` field and this field name should remain reserved +/// indefinitely. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AIExecutionProfile { + pub name: String, + pub is_default_profile: bool, + pub apply_code_diffs: ActionPermission, + pub read_files: ActionPermission, + + pub execute_commands: ActionPermission, + pub write_to_pty: WriteToPtyPermission, + pub mcp_permissions: ActionPermission, + pub ask_user_question: AskUserQuestionPermission, + pub run_agents: RunAgentsPermission, + + /// Always ask for permission for these commands + pub command_denylist: Vec, + + /// When the execute_commands is set to AlwaysAsk, autoexecute these commands + pub command_allowlist: Vec, + + /// When the read_files is set to AlwaysAsk, autoread from these directories + pub directory_allowlist: Vec, + + pub mcp_allowlist: Vec, + pub mcp_denylist: Vec, + + pub computer_use: ComputerUsePermission, + + pub base_model: Option, + pub coding_model: Option, + pub cli_agent_model: Option, + pub computer_use_model: Option, + + pub context_window_limit: Option, + + /// Whether plans created by the agent should be automatically synced to Warp Drive + pub autosync_plans_to_warp_drive: bool, + + /// Whether the agent may use web search when helpful for completing tasks + pub web_search_enabled: bool, +} + +impl Default for AIExecutionProfile { + fn default() -> Self { + Self { + name: Default::default(), + is_default_profile: false, + apply_code_diffs: ActionPermission::AgentDecides, + read_files: ActionPermission::AgentDecides, + execute_commands: ActionPermission::AlwaysAsk, + write_to_pty: WriteToPtyPermission::AlwaysAsk, + mcp_permissions: ActionPermission::AgentDecides, + ask_user_question: AskUserQuestionPermission::AlwaysAsk, + run_agents: RunAgentsPermission::AlwaysAsk, + command_denylist: DEFAULT_COMMAND_EXECUTION_DENYLIST.clone(), + command_allowlist: Vec::new(), + directory_allowlist: Vec::new(), + mcp_allowlist: Vec::new(), + mcp_denylist: Vec::new(), + computer_use: ComputerUsePermission::Never, + base_model: None, + coding_model: None, + cli_agent_model: None, + computer_use_model: None, + context_window_limit: None, + autosync_plans_to_warp_drive: true, + web_search_enabled: true, + } + } +} + +impl AIExecutionProfile { + #[cfg(feature = "agent_mode_evals")] + pub fn create_agent_mode_eval_profile() -> Self { + Self { + name: "Agent Mode Eval".to_string(), + is_default_profile: false, + apply_code_diffs: ActionPermission::AlwaysAllow, + read_files: ActionPermission::AlwaysAllow, + execute_commands: ActionPermission::AlwaysAllow, + write_to_pty: WriteToPtyPermission::AlwaysAllow, + mcp_permissions: ActionPermission::AlwaysAllow, + ask_user_question: AskUserQuestionPermission::Never, + run_agents: RunAgentsPermission::AlwaysAllow, + command_denylist: Vec::new(), + command_allowlist: Vec::new(), + directory_allowlist: Vec::new(), + mcp_allowlist: Vec::new(), + mcp_denylist: Vec::new(), + computer_use: ComputerUsePermission::Never, + base_model: None, + coding_model: None, + cli_agent_model: None, + computer_use_model: None, + context_window_limit: None, + autosync_plans_to_warp_drive: false, + web_search_enabled: true, + } + } + + /// This creates a CLI-specific profile that will never ask the user for permission, + /// since we cannot do so in a non-interactive setting. + pub fn create_default_cli_profile( + is_sandboxed: bool, + computer_use_override: Option, + ) -> Self { + let command_denylist = if is_sandboxed { + Vec::new() + } else { + DEFAULT_COMMAND_EXECUTION_DENYLIST.to_vec() + }; + + let computer_use_permission = match computer_use_override { + Some(true) => { + if is_sandboxed || FeatureFlag::LocalComputerUse.is_enabled() { + ComputerUsePermission::AlwaysAllow + } else { + ComputerUsePermission::Never + } + } + Some(false) => ComputerUsePermission::Never, + None => { + if is_sandboxed && ChannelState::channel().is_dogfood() { + ComputerUsePermission::AlwaysAllow + } else { + ComputerUsePermission::Never + } + } + }; + + Self { + name: "Default (CLI)".to_owned(), + is_default_profile: true, + apply_code_diffs: ActionPermission::AlwaysAllow, + read_files: ActionPermission::AlwaysAllow, + execute_commands: ActionPermission::AlwaysAllow, + mcp_permissions: ActionPermission::AlwaysAllow, + write_to_pty: WriteToPtyPermission::AlwaysAllow, + ask_user_question: AskUserQuestionPermission::Never, + run_agents: RunAgentsPermission::AlwaysAllow, + command_denylist, + command_allowlist: DEFAULT_COMMAND_EXECUTION_ALLOWLIST.to_vec(), + directory_allowlist: Vec::new(), + mcp_allowlist: Vec::new(), + mcp_denylist: Vec::new(), + computer_use: computer_use_permission, + base_model: None, + coding_model: None, + cli_agent_model: None, + computer_use_model: None, + context_window_limit: None, + autosync_plans_to_warp_drive: FeatureFlag::SyncAmbientPlans.is_enabled(), + web_search_enabled: true, + } + } +} + +impl JsonModel for AIExecutionProfile { + fn json_object_type() -> JsonObjectType { + JsonObjectType::AIExecutionProfile + } +} + +pub type CloudAIExecutionProfile = + GenericCloudObject; +pub type CloudAIExecutionProfileModel = GenericStringModel; +pub type ServerAIExecutionProfile = + GenericServerObject; diff --git a/crates/cloud_object_models/src/ai_fact.rs b/crates/cloud_object_models/src/ai_fact.rs new file mode 100644 index 0000000000..41388d469b --- /dev/null +++ b/crates/cloud_object_models/src/ai_fact.rs @@ -0,0 +1,64 @@ +use cloud_objects::{ + cloud_object::{GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType}, + ids::GenericStringObjectId, +}; +use serde::{Deserialize, Serialize}; +use std::fmt::Display; + +use crate::{JsonModel, JsonSerializer}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AIFact { + #[serde(rename = "memory")] + Memory(AIMemory), +} + +/// A globally unique ID for suggested objects. +/// +/// This is used for telemetry purposes to track and connect both: +/// - Suggested objects generated by the AI agent. +/// - The corresponding objects stored in the cloud, if the suggestion was accepted. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +pub struct SuggestedLoggingId(String); + +impl Display for SuggestedLoggingId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for SuggestedLoggingId { + fn from(value: String) -> Self { + Self(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AIMemory { + #[serde(default)] + pub name: Option, + pub content: String, + // Deprecated: This field is no longer used and will be removed in the future. + #[serde(default)] + pub is_autogenerated: bool, + /// If this rule was created from a suggested rule, record the suggestion's logging_id + /// so we can suppress re-surfacing the same suggestion in future responses. + #[serde(default)] + pub suggested_logging_id: Option, +} + +impl AIFact { + pub fn is_memory(&self) -> bool { + matches!(self, AIFact::Memory { .. }) + } +} + +impl JsonModel for AIFact { + fn json_object_type() -> JsonObjectType { + JsonObjectType::AIFact + } +} + +pub type CloudAIFact = GenericCloudObject; +pub type CloudAIFactModel = GenericStringModel; +pub type ServerAIFact = GenericServerObject; diff --git a/crates/cloud_object_models/src/cloud_agent_config.rs b/crates/cloud_object_models/src/cloud_agent_config.rs new file mode 100644 index 0000000000..aa91b9a75b --- /dev/null +++ b/crates/cloud_object_models/src/cloud_agent_config.rs @@ -0,0 +1,58 @@ +use std::collections::HashMap; + +use cloud_objects::{ + cloud_object::{GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType}, + ids::GenericStringObjectId, +}; +use serde::{Deserialize, Serialize}; + +use crate::{AgentConfigSnapshot, JsonModel, JsonSerializer}; + +/// A CloudAgentConfig represents a saved agent configuration that can be referenced +/// when running agents via `--agent-id`. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] +pub struct AgentConfig { + /// Configuration name + pub name: String, + /// Base model ID to use for the agent + #[serde(skip_serializing_if = "Option::is_none")] + pub base_model_id: Option, + /// Base prompt to prepend to user prompts + #[serde(skip_serializing_if = "Option::is_none")] + pub base_prompt: Option, + /// MCP servers configuration + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_servers: Option>, +} + +impl AgentConfig { + /// Convert to AgentConfigSnapshot for use in agent execution. + /// + /// Note: `AgentConfig` matches the server's JSON format (e.g. `base_model_id`), + /// while `AgentConfigSnapshot` is the runtime config format (e.g. `model_id`). + pub fn to_ambient_config(&self) -> AgentConfigSnapshot { + AgentConfigSnapshot { + name: Some(self.name.clone()), + environment_id: None, + model_id: self.base_model_id.clone(), + base_prompt: self.base_prompt.clone(), + mcp_servers: self.mcp_servers.clone().map(|m| m.into_iter().collect()), + profile_id: None, + worker_host: None, + skill_spec: None, + computer_use_enabled: None, + harness: None, + harness_auth_secrets: None, + } + } +} + +impl JsonModel for AgentConfig { + fn json_object_type() -> JsonObjectType { + JsonObjectType::CloudAgentConfig + } +} + +pub type CloudAgentConfig = GenericCloudObject; +pub type CloudAgentConfigModel = GenericStringModel; +pub type ServerCloudAgentConfig = GenericServerObject; diff --git a/crates/cloud_objects/src/cloud_object/models/cloud_environment.rs b/crates/cloud_object_models/src/cloud_environment.rs similarity index 81% rename from crates/cloud_objects/src/cloud_object/models/cloud_environment.rs rename to crates/cloud_object_models/src/cloud_environment.rs index 689e9773d6..fe82385db1 100644 --- a/crates/cloud_objects/src/cloud_object/models/cloud_environment.rs +++ b/crates/cloud_object_models/src/cloud_environment.rs @@ -1,7 +1,13 @@ use std::fmt; +use cloud_objects::{ + cloud_object::{GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType}, + ids::GenericStringObjectId, +}; use serde::{Deserialize, Serialize}; +use crate::{JsonModel, JsonSerializer}; + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct GithubRepo { /// Repository owner (e.g. "warpdotdev") @@ -107,3 +113,20 @@ impl AmbientAgentEnvironment { } } } + +impl JsonModel for AmbientAgentEnvironment { + fn json_object_type() -> JsonObjectType { + JsonObjectType::CloudEnvironment + } +} + +pub type CloudAmbientAgentEnvironment = + GenericCloudObject; +pub type CloudAmbientAgentEnvironmentModel = + GenericStringModel; +pub type ServerAmbientAgentEnvironment = + GenericServerObject; + +#[cfg(test)] +#[path = "cloud_environment_tests.rs"] +mod tests; diff --git a/app/src/ai/cloud_environments/mod_tests.rs b/crates/cloud_object_models/src/cloud_environment_tests.rs similarity index 97% rename from app/src/ai/cloud_environments/mod_tests.rs rename to crates/cloud_object_models/src/cloud_environment_tests.rs index 261eeacba5..d936ddde12 100644 --- a/app/src/ai/cloud_environments/mod_tests.rs +++ b/crates/cloud_object_models/src/cloud_environment_tests.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + AmbientAgentEnvironment, AwsProviderConfig, BaseImage, GcpProviderConfig, GithubRepo, + ProvidersConfig, +}; #[test] fn deserialize_legacy_environment_without_providers() { diff --git a/crates/cloud_object_models/src/env_vars.rs b/crates/cloud_object_models/src/env_vars.rs new file mode 100644 index 0000000000..b794112938 --- /dev/null +++ b/crates/cloud_object_models/src/env_vars.rs @@ -0,0 +1,199 @@ +use cloud_objects::{ + cloud_object::{GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType}, + ids::GenericStringObjectId, +}; +use serde::{Deserialize, Serialize}; +use warp_util::path::ShellFamily; + +use crate::{JsonModel, JsonSerializer}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct EnvVarSecretCommand { + pub name: String, + pub command: String, +} + +/// Represents a completed external secret reference. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum ExternalSecret { + OnePassword(OnePasswordSecret), + LastPass(LastPassSecret), +} + +impl ExternalSecret { + pub fn get_secret_extraction_command(&self, shell_family: ShellFamily) -> String { + let prefix = match shell_family { + ShellFamily::Posix => "\\", + ShellFamily::PowerShell => "", + }; + match self { + ExternalSecret::OnePassword(secret) => { + format!( + "{}op item get --fields credential --reveal {}", + prefix, secret.reference + ) + } + ExternalSecret::LastPass(secret) => { + format!("{}lpass show --password {}", prefix, secret.reference) + } + } + } + + pub fn get_display_name(&self) -> String { + match self { + ExternalSecret::OnePassword(secret) => secret.name.clone(), + ExternalSecret::LastPass(secret) => secret.name.clone(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct OnePasswordSecret { + name: String, + reference: String, +} + +impl OnePasswordSecret { + pub fn new(name: String, reference: String) -> Self { + Self { name, reference } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct LastPassSecret { + name: String, + reference: String, +} + +impl LastPassSecret { + pub fn new(name: String, reference: String) -> Self { + Self { name, reference } + } +} + +/// Defines the data model for a single environment variable +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] +pub struct EnvVar { + // Variable name + pub name: String, + // Variable value + pub value: EnvVarValue, + // Description of variable + pub description: Option, +} + +/// Defines the various forms a value can take +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum EnvVarValue { + // Represents a string variable, i.e. PORT=4000 + Constant(String), + // Represents a computed secret, i.e. gcloud print auth token + Command(EnvVarSecretCommand), + // Represents a secret from an external secret manager + Secret(ExternalSecret), +} + +impl Default for EnvVarValue { + fn default() -> Self { + EnvVarValue::Constant(String::new()) + } +} + +impl EnvVar { + pub fn new(name: String, value: String, description: Option) -> Self { + Self { + name, + value: EnvVarValue::Constant(value), + description, + } + } +} + +/// Defines the data model for a cloud synced collection of environment variables. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] +pub struct EnvVarCollection { + // Collection title + pub title: Option, + // Description of collection + pub description: Option, + // Environment variables associated with this collection + pub vars: Vec, +} + +impl EnvVarCollection { + pub fn new(title: Option, description: Option, vars: Vec) -> Self { + Self { + title, + description, + vars, + } + } + + fn key_value_iter(&self) -> impl Iterator { + self.vars.iter().map(|var| (var.name.as_str(), &var.value)) + } + + pub fn export_variables(&self, delimiter: &str, shell_family: ShellFamily) -> String { + serialize_variables_internal(self.key_value_iter(), "", "=", "", delimiter, shell_family) + } +} + +pub fn serialize_variables_internal<'s, I: IntoIterator>( + pairs: I, + prefix: &str, + separator: &str, + postfix: &str, + delimiter: &str, + shell_family: ShellFamily, +) -> String { + // Prefix — what's prepended to each variable + // Separator — what separates the variable name from the value + // Postfix — what's appended to the end of each variable + // Delimiter — what separates one variable from the next one + // set -x var_name var_value; set -x name2 value2; + // ------ - - - + // ^ ^ ^ ^ + // prefix separator postfix delimiter (in this case 4 spaces, usually one space or newline) + pairs + .into_iter() + .map(|(name, value)| { + format!( + "{}{}{}{}{}", + prefix, + shell_family.escape(name), + separator, + get_init_command_for_env_var_value(value, shell_family), + postfix + ) + }) + .collect::>() + .join(delimiter) +} + +pub fn get_init_command_for_env_var_value( + value: &EnvVarValue, + shell_family: ShellFamily, +) -> String { + match value { + EnvVarValue::Constant(val) => match shell_family { + ShellFamily::Posix => shell_family.escape(val).into_owned(), + ShellFamily::PowerShell => format!("'{}'", val.replace("'", "''")), + }, + EnvVarValue::Command(cmd) => format!("$({})", cmd.command), + EnvVarValue::Secret(secret) => { + format!("$({})", secret.get_secret_extraction_command(shell_family)) + } + } +} + +impl JsonModel for EnvVarCollection { + fn json_object_type() -> JsonObjectType { + JsonObjectType::EnvVarCollection + } +} + +pub type CloudEnvVarCollection = + GenericCloudObject; +pub type CloudEnvVarCollectionModel = GenericStringModel; +pub type ServerEnvVarCollection = + GenericServerObject; diff --git a/crates/cloud_object_models/src/folder.rs b/crates/cloud_object_models/src/folder.rs new file mode 100644 index 0000000000..6550ba5a4f --- /dev/null +++ b/crates/cloud_object_models/src/folder.rs @@ -0,0 +1,35 @@ +use cloud_objects::{ + cloud_object::{GenericCloudObject, GenericServerObject, ObjectType, ServerObjectModel}, + ids::FolderId, +}; + +/// The model for a `CloudFolder`. +#[derive(Clone, Debug, PartialEq)] +pub struct CloudFolderModel { + pub name: String, + // TODO: since this is local only state, we should consider only surfacing it as part of the + // CloudViewModel. Right now, every server folder uses CloudFolderModel, which means it + // hardcodes a value of `false` for this property since it can't know what the local state is. + pub is_open: bool, + pub is_warp_pack: bool, +} + +impl CloudFolderModel { + pub fn new(name: &str, is_warp_pack: bool) -> Self { + Self { + name: name.to_owned(), + is_open: false, + is_warp_pack, + } + } +} + +impl ServerObjectModel for CloudFolderModel { + fn object_type(&self) -> ObjectType { + ObjectType::Folder + } +} + +/// `CloudFolder` is a folder retrieved from the server. +pub type CloudFolder = GenericCloudObject; +pub type ServerFolder = GenericServerObject; diff --git a/crates/cloud_object_models/src/json_model.rs b/crates/cloud_object_models/src/json_model.rs new file mode 100644 index 0000000000..2c7ce54214 --- /dev/null +++ b/crates/cloud_object_models/src/json_model.rs @@ -0,0 +1,38 @@ +use std::fmt::Debug; + +use anyhow::Result; +use cloud_objects::cloud_object::{ + GenericStringObjectFormat, JsonObjectType, SerializedModel, Serializer, +}; +use serde::{Serialize, de::DeserializeOwned}; + +/// A JSON-backed cloud object payload. +pub trait JsonModel: Clone + Debug + Send + Sync + Serialize + DeserializeOwned + 'static { + /// Returns the JSON object type used by the generic string object API. + fn json_object_type() -> JsonObjectType; + + /// Returns the generic string format for this JSON model. + fn model_format() -> GenericStringObjectFormat { + GenericStringObjectFormat::Json(Self::json_object_type()) + } +} + +#[derive(Clone, Debug, PartialEq, Default)] +pub struct JsonSerializer; + +impl Serializer for JsonSerializer { + fn model_format() -> GenericStringObjectFormat { + M::model_format() + } + + fn serialize(model: &M) -> SerializedModel { + SerializedModel::new(serde_json::to_string(model).expect("model should serialize")) + } + + fn deserialize_owned(serialized: &str) -> Result + where + Self: Sized, + { + Ok(serde_json::from_str(serialized)?) + } +} diff --git a/crates/cloud_object_models/src/lib.rs b/crates/cloud_object_models/src/lib.rs new file mode 100644 index 0000000000..106b48c3fb --- /dev/null +++ b/crates/cloud_object_models/src/lib.rs @@ -0,0 +1,31 @@ +pub mod ai_execution_profile; +pub mod ai_fact; +pub mod cloud_agent_config; +pub mod cloud_environment; +pub mod env_vars; +pub mod folder; +pub mod json_model; +pub mod mcp; +pub mod notebook; +pub mod preference; +pub mod scheduled_ambient_agent; +pub mod server_cloud_object; +pub mod user_profile; +pub mod workflow; +pub mod workflow_enum; + +pub use ai_execution_profile::*; +pub use ai_fact::*; +pub use cloud_agent_config::*; +pub use cloud_environment::*; +pub use env_vars::*; +pub use folder::*; +pub use json_model::*; +pub use mcp::*; +pub use notebook::*; +pub use preference::*; +pub use scheduled_ambient_agent::*; +pub use server_cloud_object::*; +pub use user_profile::*; +pub use workflow::*; +pub use workflow_enum::*; diff --git a/crates/cloud_object_models/src/mcp.rs b/crates/cloud_object_models/src/mcp.rs new file mode 100644 index 0000000000..e90dd90db6 --- /dev/null +++ b/crates/cloud_object_models/src/mcp.rs @@ -0,0 +1,298 @@ +use std::collections::HashMap; + +use chrono::Utc; +use cloud_objects::{ + cloud_object::{GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType}, + ids::GenericStringObjectId, +}; +use handlebars::get_arguments; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{JsonModel, JsonSerializer}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JSONMCPServer { + #[serde(flatten)] + pub transport_type: JSONTransportType, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum JSONTransportType { + CLIServer { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, + #[serde(default)] + working_directory: Option, + }, + SSEServer { + #[serde(alias = "serverUrl")] + url: String, + #[serde(default)] + headers: HashMap, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MCPServer { + pub transport_type: TransportType, + pub name: String, + #[serde(default)] + pub uuid: uuid::Uuid, +} + +#[derive(Debug, Clone, Copy)] +pub enum MCPServerState { + NotRunning, + Starting, + Authenticating, + Running, + ShuttingDown, + FailedToStart, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransportType { + CLIServer(CLIServer), + ServerSentEvents(ServerSentEvents), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CLIServer { + pub command: String, + #[serde(default)] + pub args: Vec, + pub cwd_parameter: Option, + /// Static env vars added via editor inputs. + pub static_env_vars: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StaticEnvVar { + pub name: String, + /// To avoid leaking environment variables, we ensure that values are not + /// serialized before being sent to our servers + #[serde(skip_serializing, default)] + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StaticHeader { + pub name: String, + /// To avoid leaking header values (which may contain secrets), we ensure that values are not + /// serialized before being sent to our servers + #[serde(skip_serializing, default)] + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServerSentEvents { + pub url: String, + /// Static headers added via editor inputs. + #[serde(default)] + pub headers: Vec, +} + +impl JsonModel for MCPServer { + fn json_object_type() -> JsonObjectType { + JsonObjectType::MCPServer + } +} + +pub type CloudMCPServer = GenericCloudObject; +pub type CloudMCPServerModel = GenericStringModel; +pub type ServerMCPServer = GenericServerObject; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, Hash)] +pub struct JsonTemplate { + pub json: String, + pub variables: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] +pub struct TemplateVariable { + pub key: String, + /// When present, the variable should be filled via a dropdown of these values + /// instead of a freetext input. + #[serde(default)] + pub allowed_values: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct GalleryData { + pub gallery_item_id: Uuid, + pub version: i32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct TemplatableMCPServer { + pub uuid: uuid::Uuid, + pub name: String, + pub description: Option, + pub template: JsonTemplate, + #[serde(default)] + pub version: i64, // This will default to 0 if stored objects have no version + pub gallery_data: Option, +} + +#[derive(Debug)] +pub enum FromStoredJsonError { + NoServersFound, + TooManyServersFound, + ParseError(serde_json::Error), +} + +impl TemplatableMCPServer { + /// Looks for MCP servers under known wrapper keys (`mcpServers`, `servers`, + /// `mcp.servers`, `mcp_servers`). Returns `None` if no known key is found. + fn find_servers_under_known_keys( + config: &serde_json::Value, + ) -> Option> { + const POINTERS: [&str; 4] = ["/mcp/servers", "/servers", "/mcpServers", "/mcp_servers"]; + for pointer in POINTERS { + if let Some(value) = config.pointer(pointer) + && let Ok(servers) = + serde_json::from_value::>(value.clone()) + { + return Some(servers); + } + } + None + } + + /// Permissively parses MCP servers from JSON. + /// + /// Accepts servers under known wrapper keys (VSCode, Claude Desktop, etc.) + /// and also falls back to treating the entire object as a bare server map. + /// This is appropriate for user-pasted input. + pub fn find_template_map( + config: serde_json::Value, + ) -> serde_json::Result> { + if let Some(servers) = Self::find_servers_under_known_keys(&config) { + return Ok(servers); + } + // Fallback: treat the entire object as a bare map of servers. + serde_json::from_value::>(config) + } + /// Like [`find_template_map`], but without the bare-object fallback. + /// + /// Returns servers only when found under a known wrapper key. This prevents + /// misinterpreting unrelated JSON files (e.g. Claude Code's `~/.claude.json` + /// settings) as MCP config. + pub fn find_template_map_strict( + config: &serde_json::Value, + ) -> HashMap { + Self::find_servers_under_known_keys(config).unwrap_or_default() + } + + pub fn to_user_json(&self) -> String { + let value: serde_json::Value = serde_json::from_str(&self.template.json) + // All templates should be valid JSON - this should never fail + // Ones that are not should not have been saved in the first place + .unwrap_or_else(|err| { + log::error!("Could not parse MCP server template to json: {err:?}"); + Default::default() + }); + serde_json::to_string_pretty(&value) + // serde_json::to_string_pretty should never fail on this value since we just parsed it as valid json + .unwrap_or_else(|err| { + log::error!("Could not serialize MCP server to user json: {err:?}"); + Default::default() + }) + } + + // Uses from_user_json to parse the json and then returns the first TemplatableMCPServer + // This is meant to be used for stored json from the database, which should only contain + // a single server and already checked for json validity + pub fn from_stored_json( + json: &str, + uuid: uuid::Uuid, + ) -> Result { + let templates = Self::from_user_json(json); + match templates { + Ok(templates) => { + if templates.is_empty() { + // This should never happen for stored json from the database + log::error!("No templatable MCP servers found in stored json: {uuid}"); + Err(FromStoredJsonError::NoServersFound) + } else if templates.len() > 1 { + Err(FromStoredJsonError::TooManyServersFound) + } else { + // templates should always contain exactly one server for stored json from the database + let mut templatable_mcp_server = templates[0].clone(); + templatable_mcp_server.uuid = uuid; + Ok(templatable_mcp_server) + } + } + Err(err) => Err(FromStoredJsonError::ParseError(err)), + } + } + + pub fn from_user_json(json: &str) -> serde_json::Result> { + // Some docs don't show curly braces around the json object, so add them if necessary. + let json = json.trim(); + let json = if json.starts_with("{") { + json.to_owned() + } else { + format!("{{{json}}}") + }; + + let config: serde_json::Value = serde_json::from_str(&json)?; + let template_jsons = Self::find_template_map(config)?; + Ok(template_jsons + .iter() + .map(|(name, json)| { + // Each template_json is the nested config for a single MCP server + // We need to re-wrap it in a top level object so that we can + // reuse from_user_json to read it later + let normalized_map = + serde_json::Map::from_iter(vec![(name.to_owned(), json.clone())]); + let normalized_json = serde_json::Value::Object(normalized_map).to_string(); + + let description: Option = json + .get("description") + .and_then(|value| value.as_str().map(|s| s.to_owned())); + let arguments = get_arguments(&normalized_json); + let variables = arguments + .iter() + .map(|argument| TemplateVariable { + key: argument.to_owned(), + allowed_values: None, + }) + .collect::>(); + + TemplatableMCPServer { + uuid: uuid::Uuid::new_v4(), + name: name.to_owned(), + description, + template: JsonTemplate { + json: normalized_json, + variables, + }, + version: Utc::now().timestamp(), + gallery_data: None, + } + }) + .collect()) + } +} + +impl JsonModel for TemplatableMCPServer { + fn json_object_type() -> JsonObjectType { + JsonObjectType::TemplatableMCPServer + } +} + +pub type CloudTemplatableMCPServer = + GenericCloudObject; +pub type CloudTemplatableMCPServerModel = GenericStringModel; +pub type ServerTemplatableMCPServer = + GenericServerObject; + +#[cfg(test)] +#[path = "mcp_tests.rs"] +mod tests; diff --git a/crates/cloud_object_models/src/mcp_tests.rs b/crates/cloud_object_models/src/mcp_tests.rs new file mode 100644 index 0000000000..adc5d18640 --- /dev/null +++ b/crates/cloud_object_models/src/mcp_tests.rs @@ -0,0 +1,118 @@ +use super::{CLIServer, MCPServer, ServerSentEvents, StaticEnvVar, TransportType}; + +#[test] +fn test_mcp_server_config_serialization_excludes_secret_env_values() { + // Create a CLI server with environment variables containing secrets + let cli_server = CLIServer { + command: "npx".to_string(), + args: vec!["@modelcontextprotocol/server-postgres".to_string()], + cwd_parameter: Some("/tmp".to_string()), + static_env_vars: vec![ + StaticEnvVar { + name: "API_KEY".to_string(), + value: "SOME_LEAKED_SECRET".to_string(), + }, + StaticEnvVar { + name: "DATABASE_URL".to_string(), + value: "postgresql://user:password@localhost/db".to_string(), + }, + StaticEnvVar { + name: "PUBLIC_CONFIG".to_string(), + value: "not-secret-value".to_string(), + }, + ], + }; + + let mcp_server = MCPServer { + transport_type: TransportType::CLIServer(cli_server), + name: "test-server".to_string(), + uuid: uuid::Uuid::new_v4(), + }; + // Test direct serde serialization + let serialized = serde_json::to_string(&mcp_server).expect("Failed to serialize MCP server"); + // The serialized config should NOT contain the secret values + assert!( + !serialized.contains("SOME_LEAKED_SECRET"), + "Serialized config contains leaked secret value: {serialized}", + ); + assert!( + !serialized.contains("password"), + "Serialized config contains password: {serialized}", + ); + assert!( + !serialized.contains("not-secret-value"), + "Serialized config contains env var value: {serialized}", + ); + // But should contain the environment variable names/keys + assert!( + serialized.contains("API_KEY"), + "Serialized config should contain env var key 'API_KEY': {serialized}", + ); + assert!( + serialized.contains("DATABASE_URL"), + "Serialized config should contain env var key 'DATABASE_URL': {serialized}", + ); + assert!( + serialized.contains("PUBLIC_CONFIG"), + "Serialized config should contain env var key 'PUBLIC_CONFIG': {serialized}", + ); +} + +#[test] +fn test_static_env_var_direct_serialization() { + // Test direct serialization of StaticEnvVar to ensure skip_serializing works + let env_var = StaticEnvVar { + name: "TEST_SECRET".to_string(), + value: "SOME_LEAKED_SECRET".to_string(), + }; + + let serialized = serde_json::to_string(&env_var).expect("Failed to serialize env var"); + + // Should contain the name but not the value due to skip_serializing + assert!( + serialized.contains("TEST_SECRET"), + "Serialized env var should contain name: {serialized}", + ); + assert!( + !serialized.contains("SOME_LEAKED_SECRET"), + "Serialized env var should not contain value due to skip_serializing: {serialized}", + ); +} + +#[test] +fn test_static_env_var_deserialization_with_default() { + // Test that StaticEnvVar can be deserialized properly with default value + let json = r#"{"name": "API_KEY"}"#; + + let env_var: StaticEnvVar = serde_json::from_str(json).expect("Failed to deserialize env var"); + + assert_eq!(env_var.name, "API_KEY"); + assert_eq!(env_var.value, ""); // Should default to empty string +} + +#[test] +fn test_sse_server_serialization() { + // Test that ServerSentEvents transport type serializes correctly + let sse_server = ServerSentEvents { + url: "https://example.com/sse".to_string(), + headers: Default::default(), + }; + + let mcp_server = MCPServer { + transport_type: TransportType::ServerSentEvents(sse_server), + name: "sse-server".to_string(), + uuid: uuid::Uuid::new_v4(), + }; + + let serialized = serde_json::to_string(&mcp_server).expect("Failed to serialize MCP server"); + + // Should contain the URL since it's not a secret field + assert!( + serialized.contains("https://example.com/sse"), + "Serialized SSE server should contain URL: {serialized}", + ); + assert!( + serialized.contains("sse-server"), + "Serialized SSE server should contain name: {serialized}", + ); +} diff --git a/crates/cloud_object_models/src/notebook.rs b/crates/cloud_object_models/src/notebook.rs new file mode 100644 index 0000000000..1ed3c59d7f --- /dev/null +++ b/crates/cloud_object_models/src/notebook.rs @@ -0,0 +1,46 @@ +use ai::document::AIDocumentId; +use cloud_objects::{ + cloud_object::{GenericCloudObject, GenericServerObject, ObjectType, ServerObjectModel}, + ids::{ServerId, SyncId}, +}; +use serde::{Deserialize, Serialize}; + +/// Serialized representation of a notebook for sync queue +/// The AIDocumentID and ConversationID are stored here to avoid polluting the +/// generic CreateObjectRequest type. +#[derive(Serialize, Deserialize)] +pub struct SerializedNotebook { + pub data: String, + pub ai_document_id: Option, + pub conversation_id: Option, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CloudNotebookModel { + pub title: String, + pub data: String, + pub ai_document_id: Option, + /// This is the server-generated conversation token, not the client-side AIConversationId. + pub conversation_id: Option, +} + +impl ServerObjectModel for CloudNotebookModel { + fn object_type(&self) -> ObjectType { + ObjectType::Notebook + } +} + +/// This is the notebook_id in the database associated with this notebook. +#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)] +pub struct NotebookId(ServerId); +cloud_objects::server_id_traits! { NotebookId, "Notebook" } + +impl From for SyncId { + fn from(id: NotebookId) -> Self { + Self::ServerId(id.into()) + } +} + +/// `CloudNotebook` is a notebook retrieved from the server. +pub type CloudNotebook = GenericCloudObject; +pub type ServerNotebook = GenericServerObject; diff --git a/crates/cloud_object_models/src/preference.rs b/crates/cloud_object_models/src/preference.rs new file mode 100644 index 0000000000..60a75ee512 --- /dev/null +++ b/crates/cloud_object_models/src/preference.rs @@ -0,0 +1,113 @@ +use anyhow::{Result, anyhow}; +use cloud_objects::{ + cloud_object::{GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType}, + ids::GenericStringObjectId, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use settings::SyncToCloud; + +use crate::{JsonModel, JsonSerializer}; + +/// Defines the platform that a preference was set on. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum Platform { + Mac, + Linux, + Windows, + Web, + /// This implies the preference applies on all supported platforms + Global, +} + +impl std::fmt::Display for Platform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Mac => write!(f, "Mac"), + Self::Linux => write!(f, "Linux"), + Self::Windows => write!(f, "Windows"), + Self::Web => write!(f, "Web"), + Self::Global => write!(f, "Global"), + } + } +} + +impl Platform { + pub fn applies_to_current_platform(&self) -> bool { + *self == Platform::current_platform() || *self == Platform::Global + } + + pub fn current_platform() -> Self { + if cfg!(all(not(target_family = "wasm"), target_os = "macos")) { + return Self::Mac; + } + if cfg!(all( + not(target_family = "wasm"), + any(target_os = "linux", target_os = "freebsd") + )) { + return Self::Linux; + } + if cfg!(all(not(target_family = "wasm"), target_os = "windows")) { + return Self::Windows; + } + if cfg!(target_family = "wasm") { + return Self::Web; + } + panic!("Unsupported platform"); + } +} + +/// Defines the data model for a cloud synced user preference. +/// +/// The expected usage is that each storage key is modeled as its own cloud preference object. +/// This allows users to edit individual cloud preferences with less fear of an offline +/// collision (e.g. if I change one preference on one machine and then update another while +/// offline on another machine, modeling them individually allows for both changes to be applied). +/// +/// Note that I considered adding a concept of "preference group" as a higher level namespace +/// for preferences (in case users want to create groups of them), but decided to hold off on +/// this until we actually support that feature. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Preference { + /// The storage key (unique identifier for this preference). + pub storage_key: String, + /// The value of the preference, which can be any JSON value. + pub value: Value, + /// The platform that this preference was set on. + /// If the preference is global, this will be set to Platform::Global. + pub platform: Platform, +} + +impl Preference { + /// Creates a new preference object with the given storage key and value and the appropriate + /// platform key for the given syncing mode. + /// Used when creating a new preference the first time. For preferences synced from the + /// cloud they will desererialize directly from JSON. + pub fn new(storage_key: String, value: &str, syncing_mode: SyncToCloud) -> Result { + let platform = match syncing_mode { + SyncToCloud::PerPlatform(_) => Platform::current_platform(), + SyncToCloud::Globally(_) => Platform::Global, + SyncToCloud::Never => Err(anyhow!( + "Cannot create a preference with SyncToCloud::Never" + ))?, + }; + match serde_json::from_str(value) { + Ok(value) => Ok(Self { + storage_key, + value, + platform, + }), + Err(err) => Err(anyhow!("Failed to parse preference value {err}")), + } + } +} + +impl JsonModel for Preference { + fn json_object_type() -> JsonObjectType { + JsonObjectType::Preference + } +} + +pub type CloudPreference = GenericCloudObject; +pub type CloudPreferenceModel = GenericStringModel; +pub type ServerPreference = GenericServerObject; diff --git a/crates/cloud_object_models/src/scheduled_ambient_agent.rs b/crates/cloud_object_models/src/scheduled_ambient_agent.rs new file mode 100644 index 0000000000..8f8be9ce16 --- /dev/null +++ b/crates/cloud_object_models/src/scheduled_ambient_agent.rs @@ -0,0 +1,207 @@ +use std::collections::HashMap; + +use cloud_objects::{ + cloud_object::{GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType}, + ids::GenericStringObjectId, +}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use warp_cli::agent::Harness; + +use crate::{JsonModel, JsonSerializer}; + +/// Runtime configuration snapshot for agent execution. +/// +/// This is the merged/resolved config used when spawning or running an agent. +/// It combines settings from config files and CLI args. +/// Unlike `AgentConfig` (the cloud model), field names here use the runtime format +/// (e.g. `model_id` instead of `base_model_id`). +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct AgentConfigSnapshot { + /// Config name for searchability/traceability. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub environment_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_prompt: Option, + /// MCP server configuration map (unwrapped; no `mcpServers` wrapper). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mcp_servers: Option>, + /// Profile ID for local agent runs. This configures the terminal session + /// with the specified execution profile. Only used for local runs, not cloud runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_id: Option, + /// Self-hosted worker ID that should execute this task. + /// If None or Some("warp"), the task will be dispatched to Warp-hosted (Namespace) workers. + /// Otherwise, the task will only be assigned to a connected self-hosted worker with matching ID. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_host: Option, + /// Skill spec to use as the base prompt for the agent. + /// Format: "skill_name", "repo:skill_name", or "org/repo:skill_name". + /// The skill is resolved at runtime in the agent environment. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skill_spec: Option, + /// Whether computer use is enabled for this agent run. + /// If None, the default behavior is used. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub computer_use_enabled: Option, + /// Execution harness for the agent run. + /// If None, we use Warp's default ("oz"). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness: Option, + /// Authentication secrets for third-party harnesses. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness_auth_secrets: Option, +} + +/// Configuration for a third-party execution harness. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct HarnessConfig { + /// The harness type, e.g. [`Harness::Claude`]. + #[serde( + rename = "type", + serialize_with = "serialize_harness", + deserialize_with = "deserialize_harness" + )] + pub harness_type: Harness, + /// The model to use with this harness. None means use the harness default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, + /// Optional reasoning level for harnesses that support it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_level: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct HarnessModelConfig { + pub model_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_level: Option, +} + +impl HarnessConfig { + /// Builds a harness config from just the harness type. + pub fn from_harness_type(harness_type: Harness) -> Self { + Self { + harness_type, + model_id: None, + reasoning_level: None, + } + } + + pub fn model_config(&self) -> Option { + self.model_id + .as_ref() + .filter(|id| !id.is_empty()) + .map(|model_id| HarnessModelConfig { + model_id: model_id.clone(), + reasoning_level: self.reasoning_level.clone(), + }) + } +} + +fn serialize_harness(harness: &Harness, serializer: S) -> Result { + serializer.serialize_str(harness.config_name()) +} + +fn deserialize_harness<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let name = String::deserialize(deserializer)?; + Ok(Harness::from_config_name(&name).unwrap_or_else(|| { + log::warn!("Unknown harness config name: {name:?}; treating as Unknown"); + Harness::Unknown + })) +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct HarnessAuthSecretsConfig { + /// Name of a managed secret for Claude Code harness authentication. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub claude_auth_secret_name: Option, + /// Name of a managed secret for Codex harness authentication. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub codex_auth_secret_name: Option, +} + +impl AgentConfigSnapshot { + /// Returns true if this config is empty (no options are set). + pub fn is_empty(&self) -> bool { + let Self { + name, + environment_id, + model_id, + base_prompt, + mcp_servers, + profile_id, + worker_host, + skill_spec, + computer_use_enabled, + harness, + harness_auth_secrets, + } = self; + + name.is_none() + && environment_id.is_none() + && model_id.is_none() + && base_prompt.is_none() + && mcp_servers.is_none() + && profile_id.is_none() + && worker_host.is_none() + && skill_spec.is_none() + && computer_use_enabled.is_none() + && harness.is_none() + && harness_auth_secrets.is_none() + } +} + +/// A ScheduledAmbientAgent represents configuration for ambient agents that run on a cron schedule. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ScheduledAmbientAgent { + /// Agent name + #[serde(default)] + pub name: String, + /// Cron schedule expression + #[serde(default)] + pub cron_schedule: String, + /// Whether the scheduled agent is enabled + #[serde(default)] + pub enabled: bool, + /// The prompt to use for the scheduled agent + #[serde(default)] + pub prompt: String, + /// The latest failure to execute this scheduled agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_spawn_error: Option, + /// Configuration for how the ambient agent should run. + #[serde(default, skip_serializing_if = "AgentConfigSnapshot::is_empty")] + pub agent_config: AgentConfigSnapshot, +} + +impl ScheduledAmbientAgent { + pub fn new(name: String, cron_schedule: String, enabled: bool, prompt: String) -> Self { + Self { + name, + cron_schedule, + enabled, + prompt, + last_spawn_error: None, + agent_config: Default::default(), + } + } +} + +impl JsonModel for ScheduledAmbientAgent { + fn json_object_type() -> JsonObjectType { + JsonObjectType::ScheduledAmbientAgent + } +} + +pub type CloudScheduledAmbientAgent = + GenericCloudObject; +pub type CloudScheduledAmbientAgentModel = + GenericStringModel; +pub type ServerScheduledAmbientAgent = + GenericServerObject; + +pub type AgentConfigMap = HashMap; diff --git a/crates/cloud_object_models/src/server_cloud_object.rs b/crates/cloud_object_models/src/server_cloud_object.rs new file mode 100644 index 0000000000..71612f38b2 --- /dev/null +++ b/crates/cloud_object_models/src/server_cloud_object.rs @@ -0,0 +1,322 @@ +use std::any::Any; + +use anyhow::Result; +use cloud_objects::{ + cloud_object::{GenericServerObject, GenericStringModel, Serializer, ServerMetadata}, + ids::{GenericStringObjectId, ObjectUid, ServerId, SyncId}, +}; +use warp_graphql::object::CloudObjectWithDescendants; + +use crate::{ + AIExecutionProfile, AIFact, AmbientAgentEnvironment, CloudFolderModel, CloudNotebookModel, + CloudWorkflowModel, EnvVarCollection, JsonSerializer, MCPServer, Preference, + ScheduledAmbientAgent, ServerAIExecutionProfile, ServerAIFact, ServerAmbientAgentEnvironment, + ServerCloudAgentConfig, ServerEnvVarCollection, ServerFolder, ServerMCPServer, ServerNotebook, + ServerPreference, ServerScheduledAmbientAgent, ServerTemplatableMCPServer, ServerWorkflow, + ServerWorkflowEnum, TemplatableMCPServer, WorkflowEnum, +}; + +/// A cloud object from the server. +#[derive(Clone, Debug)] +pub enum ServerCloudObject { + Notebook(ServerNotebook), + Workflow(Box), + Folder(ServerFolder), + Preference(ServerPreference), + EnvVarCollection(ServerEnvVarCollection), + WorkflowEnum(ServerWorkflowEnum), + AIFact(ServerAIFact), + MCPServer(ServerMCPServer), + AIExecutionProfile(ServerAIExecutionProfile), + TemplatableMCPServer(ServerTemplatableMCPServer), + AmbientAgentEnvironment(ServerAmbientAgentEnvironment), + ScheduledAmbientAgent(ServerScheduledAmbientAgent), + CloudAgentConfig(ServerCloudAgentConfig), +} + +impl ServerCloudObject { + pub fn metadata(&self) -> &ServerMetadata { + match self { + ServerCloudObject::Notebook(notebook) => ¬ebook.metadata, + ServerCloudObject::Workflow(workflow) => &workflow.metadata, + ServerCloudObject::Folder(folder) => &folder.metadata, + ServerCloudObject::Preference(preferences) => &preferences.metadata, + ServerCloudObject::EnvVarCollection(env_var_collection) => &env_var_collection.metadata, + ServerCloudObject::WorkflowEnum(workflow_enum) => &workflow_enum.metadata, + ServerCloudObject::AIFact(aifact) => &aifact.metadata, + ServerCloudObject::MCPServer(mcp_server) => &mcp_server.metadata, + ServerCloudObject::TemplatableMCPServer(templatable_mcp_server) => { + &templatable_mcp_server.metadata + } + ServerCloudObject::AIExecutionProfile(ai_execution_profile) => { + &ai_execution_profile.metadata + } + ServerCloudObject::AmbientAgentEnvironment(ambient_agent_environment) => { + &ambient_agent_environment.metadata + } + ServerCloudObject::ScheduledAmbientAgent(scheduled_ambient_agent) => { + &scheduled_ambient_agent.metadata + } + ServerCloudObject::CloudAgentConfig(cloud_agent_config) => &cloud_agent_config.metadata, + } + } + + pub fn uid(&self) -> ObjectUid { + match self { + ServerCloudObject::Notebook(notebook) => notebook.id.uid(), + ServerCloudObject::Workflow(workflow) => workflow.id.uid(), + ServerCloudObject::Folder(folder) => folder.id.uid(), + ServerCloudObject::Preference(preferences) => preferences.id.uid(), + ServerCloudObject::EnvVarCollection(env_var_collection) => env_var_collection.id.uid(), + ServerCloudObject::WorkflowEnum(workflow_enum) => workflow_enum.id.uid(), + ServerCloudObject::AIFact(aifact) => aifact.id.uid(), + ServerCloudObject::MCPServer(mcp_server) => mcp_server.id.uid(), + ServerCloudObject::AIExecutionProfile(ai_execution_profile) => { + ai_execution_profile.id.uid() + } + ServerCloudObject::TemplatableMCPServer(templatable_mcp_server) => { + templatable_mcp_server.id.uid() + } + ServerCloudObject::AmbientAgentEnvironment(ambient_agent_environment) => { + ambient_agent_environment.id.uid() + } + ServerCloudObject::ScheduledAmbientAgent(scheduled_ambient_agent) => { + scheduled_ambient_agent.id.uid() + } + ServerCloudObject::CloudAgentConfig(cloud_agent_config) => cloud_agent_config.id.uid(), + } + } +} + +impl From<&GenericServerObject> for ServerCloudObject +where + K: 'static, + M: 'static, +{ + fn from(value: &GenericServerObject) -> Self { + let value = value as &dyn Any; + if let Some(server_notebook) = value.downcast_ref::() { + ServerCloudObject::Notebook(server_notebook.clone()) + } else if let Some(server_workflow) = value.downcast_ref::() { + ServerCloudObject::Workflow(Box::new(server_workflow.clone())) + } else if let Some(server_folder) = value.downcast_ref::() { + ServerCloudObject::Folder(server_folder.clone()) + } else if let Some(server_preferences) = value.downcast_ref::() { + ServerCloudObject::Preference(server_preferences.clone()) + } else if let Some(server_env_var_collection) = + value.downcast_ref::() + { + ServerCloudObject::EnvVarCollection(server_env_var_collection.clone()) + } else if let Some(server_workflow_enum) = value.downcast_ref::() { + ServerCloudObject::WorkflowEnum(server_workflow_enum.clone()) + } else if let Some(server_aifact) = value.downcast_ref::() { + ServerCloudObject::AIFact(server_aifact.clone()) + } else if let Some(server_mcp_server) = value.downcast_ref::() { + ServerCloudObject::MCPServer(server_mcp_server.clone()) + } else if let Some(server_ai_execution_profile) = + value.downcast_ref::() + { + ServerCloudObject::AIExecutionProfile(server_ai_execution_profile.clone()) + } else if let Some(server_templatable_mcp_server) = + value.downcast_ref::() + { + ServerCloudObject::TemplatableMCPServer(server_templatable_mcp_server.clone()) + } else if let Some(server_ambient_agent_environment) = + value.downcast_ref::() + { + ServerCloudObject::AmbientAgentEnvironment(server_ambient_agent_environment.clone()) + } else if let Some(server_scheduled_ambient_agent) = + value.downcast_ref::() + { + ServerCloudObject::ScheduledAmbientAgent(server_scheduled_ambient_agent.clone()) + } else if let Some(server_cloud_agent_config) = + value.downcast_ref::() + { + ServerCloudObject::CloudAgentConfig(server_cloud_agent_config.clone()) + } else { + panic!("Unknown server object type"); + } + } +} + +/// Tries to convert a GraphQL object payload into a local server object. +pub trait TryFromGql: Sized { + type GqlType; + + fn try_from_gql(value: Self::GqlType) -> Result; +} + +impl TryFromGql for GenericServerObject> +where + T: std::fmt::Debug + Clone + Send + Sync + 'static, + S: Serializer, +{ + type GqlType = warp_graphql::generic_string_object::GenericStringObject; + + fn try_from_gql(value: Self::GqlType) -> Result { + let uid = ServerId::from_string_lossy(value.metadata.uid.inner()); + let model = GenericStringModel::::deserialize_owned(&value.serialized_model)?; + Ok(Self::new( + SyncId::ServerId(uid), + model, + value.metadata.try_into()?, + value.permissions.try_into()?, + )) + } +} + +impl TryFromGql for ServerFolder { + type GqlType = warp_graphql::folder::Folder; + + fn try_from_gql(value: Self::GqlType) -> Result { + let uid = ServerId::from_string_lossy(value.metadata.uid.inner()); + Ok(Self::new( + SyncId::ServerId(uid), + CloudFolderModel::new(&value.name, value.is_warp_pack), + value.metadata.try_into()?, + value.permissions.try_into()?, + )) + } +} + +impl TryFromGql for ServerNotebook { + type GqlType = warp_graphql::notebook::Notebook; + + fn try_from_gql(value: Self::GqlType) -> Result { + let uid = ServerId::from_string_lossy(value.metadata.uid.inner()); + let ai_document_id = value + .ai_document_id + .map(|id| ai::document::AIDocumentId::try_from(&id[..])) + .transpose()?; + Ok(Self::new( + SyncId::ServerId(uid), + CloudNotebookModel { + title: value.title, + data: value.data, + ai_document_id, + conversation_id: None, + }, + value.metadata.try_into()?, + value.permissions.try_into()?, + )) + } +} + +impl TryFromGql for ServerWorkflow { + type GqlType = warp_graphql::workflow::Workflow; + + fn try_from_gql(value: Self::GqlType) -> Result { + let uid = ServerId::from_string_lossy(value.metadata.uid.inner()); + let workflow = serde_json::from_str(value.data.as_str())?; + Ok(Self::new( + SyncId::ServerId(uid), + CloudWorkflowModel { data: workflow }, + value.metadata.try_into()?, + value.permissions.try_into()?, + )) + } +} + +impl TryFrom for ServerCloudObject { + type Error = anyhow::Error; + + fn try_from(value: warp_graphql::object::CloudObject) -> Result { + match value { + warp_graphql::object::CloudObject::AIConversation(_) => Err(anyhow::anyhow!( + "AIConversation is not a supported object type for this operation" + )), + warp_graphql::object::CloudObject::Folder(folder) => Ok(ServerCloudObject::Folder( + ServerFolder::try_from_gql(folder)?, + )), + warp_graphql::object::CloudObject::GenericStringObject(gso) => { + server_gso_to_cloud_object(gso) + } + warp_graphql::object::CloudObject::Notebook(notebook) => Ok( + ServerCloudObject::Notebook(ServerNotebook::try_from_gql(notebook)?), + ), + warp_graphql::object::CloudObject::Workflow(workflow) => Ok( + ServerCloudObject::Workflow(Box::new(ServerWorkflow::try_from_gql(workflow)?)), + ), + warp_graphql::object::CloudObject::Unknown => { + Err(anyhow::anyhow!("Unable to convert cloud object type")) + } + } + } +} + +impl TryFrom for ServerCloudObject { + type Error = anyhow::Error; + + fn try_from(value: CloudObjectWithDescendants) -> Result { + match value { + CloudObjectWithDescendants::AIConversation(_) => Err(anyhow::anyhow!( + "AIConversation is not a supported object type for this operation" + )), + CloudObjectWithDescendants::FolderWithDescendants(fwd) => Ok( + ServerCloudObject::Folder(ServerFolder::try_from_gql(fwd.folder)?), + ), + CloudObjectWithDescendants::GenericStringObject(gso) => server_gso_to_cloud_object(gso), + CloudObjectWithDescendants::Notebook(notebook) => Ok(ServerCloudObject::Notebook( + ServerNotebook::try_from_gql(notebook)?, + )), + CloudObjectWithDescendants::Workflow(workflow) => Ok(ServerCloudObject::Workflow( + Box::new(ServerWorkflow::try_from_gql(workflow)?), + )), + CloudObjectWithDescendants::Unknown => Err(anyhow::anyhow!( + "Unable to convert cloud object with descendants type" + )), + } + } +} + +fn server_gso_to_cloud_object( + gso: warp_graphql::generic_string_object::GenericStringObject, +) -> Result { + match gso.format { + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonEnvVarCollection => { + Ok(ServerCloudObject::EnvVarCollection( + GenericServerObject::>::try_from_gql(gso)?, + )) + } + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonPreference => Ok( + ServerCloudObject::Preference( + GenericServerObject::>::try_from_gql(gso)?, + ), + ), + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonWorkflowEnum => Ok( + ServerCloudObject::WorkflowEnum( + GenericServerObject::>::try_from_gql(gso)?, + ), + ), + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonAIFact => Ok( + ServerCloudObject::AIFact( + GenericServerObject::>::try_from_gql(gso)?, + ), + ), + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonMCPServer => Ok( + ServerCloudObject::MCPServer( + GenericServerObject::>::try_from_gql(gso)?, + ), + ), + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonAIExecutionProfile => { + Ok(ServerCloudObject::AIExecutionProfile( + GenericServerObject::>::try_from_gql(gso)?, + )) + } + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonTemplatableMCPServer => { + Ok(ServerCloudObject::TemplatableMCPServer( + GenericServerObject::>::try_from_gql(gso)?, + )) + } + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonCloudEnvironment => { + Ok(ServerCloudObject::AmbientAgentEnvironment( + GenericServerObject::>::try_from_gql(gso)?, + )) + } + warp_graphql::generic_string_object::GenericStringObjectFormat::JsonScheduledAmbientAgent => { + Ok(ServerCloudObject::ScheduledAmbientAgent( + GenericServerObject::>::try_from_gql(gso)?, + )) + } + } +} diff --git a/crates/cloud_object_models/src/user_profile.rs b/crates/cloud_object_models/src/user_profile.rs new file mode 100644 index 0000000000..6a5e48213a --- /dev/null +++ b/crates/cloud_object_models/src/user_profile.rs @@ -0,0 +1,45 @@ +use cloud_objects::{UserUid, ids::ServerId}; +use session_sharing_protocol::common::ProfileData; + +/// Public struct for storing all the UserProfile data that's fed in from either sqlite or the server. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UserProfileWithUID { + pub firebase_uid: UserUid, + pub display_name: Option, + pub email: String, + pub photo_url: String, +} + +impl From for UserProfileWithUID { + fn from(data: ProfileData) -> Self { + Self { + firebase_uid: UserUid::new(&data.firebase_uid), + display_name: Some(data.display_name), + email: data.email.unwrap_or_default(), + photo_url: data.photo_url.unwrap_or_default(), + } + } +} + +impl From for UserProfileWithUID { + fn from(value: warp_graphql::user::PublicUserProfile) -> Self { + UserProfileWithUID { + firebase_uid: UserUid::new(&value.uid), + display_name: value.display_name, + email: value.email.unwrap_or_default(), + photo_url: value.photo_url.unwrap_or_default(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UserProfileIdAndName { + pub user_uid: UserUid, + pub display_name: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TeamProfileIdAndName { + pub team_uid: ServerId, + pub display_name: String, +} diff --git a/crates/cloud_object_models/src/workflow.rs b/crates/cloud_object_models/src/workflow.rs new file mode 100644 index 0000000000..fd5f72cf0b --- /dev/null +++ b/crates/cloud_object_models/src/workflow.rs @@ -0,0 +1,414 @@ +use cloud_objects::{ + cloud_object::{GenericCloudObject, GenericServerObject, ObjectType, ServerObjectModel}, + ids::{GenericStringObjectId, ServerId, SyncId}, +}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; + +/// Workflow model used by Warp and warp-internal. +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq, Hash)] +#[serde(tag = "type")] +#[serde(rename_all = "snake_case")] +#[allow(clippy::large_enum_variant)] +pub enum Workflow { + AgentMode { + name: String, + /// The query to be inserted in the terminal input. + query: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(default)] + arguments: Vec, + }, + #[serde(untagged)] + Command { + name: String, + command: String, + #[serde(default)] + tags: Vec, + description: Option, + #[serde(default)] + arguments: Vec, + source_url: Option, + author: Option, + author_url: Option, + #[serde(default)] + shells: Vec, + #[serde(default)] + environment_variables: Option, + }, +} + +impl Workflow { + pub fn name(&self) -> &str { + match self { + Self::AgentMode { name, .. } => name.as_str(), + Self::Command { name, .. } => name.as_str(), + } + } + + /// The core "content" of the workflow. + /// + /// For Command workflows, this is the shell command. For Agent Mode workflows, this is the + /// query. + pub fn content(&self) -> &str { + match self { + Self::AgentMode { query, .. } => query, + Self::Command { command, .. } => command, + } + } + + pub fn prompt(&self) -> Option<&str> { + if let Self::AgentMode { query, .. } = self { + Some(query.as_str()) + } else { + None + } + } + + pub fn command(&self) -> Option<&str> { + if let Self::Command { command, .. } = self { + Some(command.as_str()) + } else { + None + } + } + + pub fn description(&self) -> Option<&String> { + match self { + Self::AgentMode { description, .. } => description.as_ref(), + Self::Command { description, .. } => description.as_ref(), + } + } + + pub fn arguments(&self) -> &Vec { + match self { + Self::AgentMode { arguments, .. } => arguments, + Self::Command { arguments, .. } => arguments, + } + } + + pub fn tags(&self) -> Option<&Vec> { + match self { + Self::Command { tags, .. } => Some(tags), + Self::AgentMode { .. } => None, + } + } + + pub fn source_url(&self) -> Option<&String> { + match self { + Self::Command { source_url, .. } => source_url.as_ref(), + Self::AgentMode { .. } => None, + } + } + + pub fn author_name(&self) -> Option<&String> { + match self { + Self::Command { author, .. } => author.as_ref(), + Self::AgentMode { .. } => None, + } + } + + pub fn shells(&self) -> Option<&Vec> { + match self { + Self::Command { shells, .. } => Some(shells), + Self::AgentMode { .. } => None, + } + } + + pub fn is_command_workflow(&self) -> bool { + matches!(self, Self::Command { .. }) + } + + pub fn is_agent_mode_workflow(&self) -> bool { + matches!(self, Self::AgentMode { .. }) + } + + /// Returns `true` if the workflow name starts with the given character (case-insensitive). + /// + /// Used by prompt search datasources to prefix-match on single-character queries, where + /// fuzzy matching would be unreliable. + pub fn name_starts_with_char_ignore_case(&self, c: char) -> bool { + self.name() + .chars() + .next() + .is_some_and(|first| first.eq_ignore_ascii_case(&c)) + } + + /// Return a list of every enum ID referenced by this workflow. + pub fn get_enum_ids(&self) -> Vec { + self.arguments() + .iter() + .filter_map(|arg| match arg.arg_type { + ArgumentType::Enum { enum_id } => Some(enum_id), + ArgumentType::Text => None, + }) + .collect() + } + + /// Return a list of every enum ID that has been synced to the server, used for telemetry. + pub fn get_server_enum_ids(&self) -> Vec { + self.arguments() + .iter() + .filter_map(|arg| match arg.arg_type { + ArgumentType::Enum { enum_id } => enum_id.into_server(), + ArgumentType::Text => None, + }) + .map(Into::into) + .collect() + } + + pub fn default_env_vars(&self) -> Option { + match self { + Workflow::Command { + environment_variables, + .. + } => *environment_variables, + Workflow::AgentMode { .. } => None, + } + } + + /// Given two IDs, replace any instance of the old ID referenced by this workflow with the new ID. + /// Returns `true` if any instances of the old_id were present. + pub fn replace_object_id(&mut self, old_id: SyncId, new_id: SyncId) -> bool { + let mut changed = false; + let arguments = match self { + Self::Command { arguments, .. } => arguments, + Self::AgentMode { arguments, .. } => arguments, + }; + for arg in arguments.iter_mut() { + match &mut arg.arg_type { + ArgumentType::Enum { enum_id } if *enum_id == old_id => { + *enum_id = new_id; + changed = true; + } + ArgumentType::Enum { .. } | ArgumentType::Text => {} + } + } + if let Self::Command { + environment_variables, + .. + } = self + && *environment_variables == Some(old_id) + { + *environment_variables = Some(new_id); + changed = true; + } + changed + } + + pub fn new(name: impl Into, command: impl Into) -> Self { + Workflow::Command { + name: name.into(), + command: command.into(), + tags: Vec::new(), + arguments: Vec::new(), + description: None, + source_url: None, + author: None, + author_url: None, + shells: Vec::new(), + environment_variables: None, + } + } + + pub fn with_arguments(mut self, new_arguments: Vec) -> Self { + match self { + Workflow::AgentMode { + ref mut arguments, .. + } + | Workflow::Command { + ref mut arguments, .. + } => { + *arguments = new_arguments; + } + } + self + } + + pub fn with_description(mut self, new_description: String) -> Self { + match self { + Workflow::AgentMode { + ref mut description, + .. + } + | Workflow::Command { + ref mut description, + .. + } => { + *description = Some(new_description); + } + } + self + } + + pub fn set_name(&mut self, new_name: &str) { + match self { + Workflow::AgentMode { name, .. } | Workflow::Command { name, .. } => { + new_name.clone_into(name) + } + } + } +} + +/// Create a warp-internal Workflow model from a public-facing workflow +/// https://github.com/warpdotdev/workflows/blob/main/workflow-types/src/lib.rs +impl From for Workflow { + fn from(workflow: warp_workflows::Workflow) -> Self { + Workflow::Command { + name: workflow.name, + command: workflow.command, + description: workflow.description, + arguments: workflow.arguments.into_iter().map(Argument::from).collect(), + tags: workflow.tags, + source_url: workflow.source_url, + author: workflow.author, + author_url: workflow.author_url, + shells: workflow.shells, + environment_variables: None, + } + } +} + +/// Argument model to be used in `warp-internal` +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash, Default)] +pub struct Argument { + pub name: String, + /// The type of the argument to the workflow + #[serde(flatten, deserialize_with = "deserialize_arg_type")] + pub arg_type: ArgumentType, + pub description: Option, + pub default_value: Option, +} + +impl From for Argument { + fn from(arg: warp_workflows::Argument) -> Self { + Argument { + name: arg.name, + arg_type: ArgumentType::Text, + description: arg.description, + default_value: arg.default_value, + } + } +} + +impl Argument { + pub fn new(name: impl Into, arg_type: ArgumentType) -> Self { + Argument { + arg_type, + name: name.into(), + description: None, + default_value: None, + } + } + + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn with_default(mut self, default: impl Into) -> Self { + self.default_value = Some(default.into()); + self + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn description(&self) -> &Option { + &self.description + } + + pub fn arg_type(&self) -> &ArgumentType { + &self.arg_type + } + + pub fn default_value(&self) -> &Option { + &self.default_value + } +} + +/// The type of the workflow argument +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash)] +#[serde(tag = "arg_type")] +#[derive(Default)] +pub enum ArgumentType { + #[default] + Text, + Enum { + /// The ID of the associated WorkflowEnum Generic String Object + enum_id: SyncId, + }, +} + +/// Custom deserialization for argument types, used to both `flatten` the argument type +/// and allow for the specification of `default` behavior. +/// +/// Necessary because serde currently does not support the use of `flatten` with a `default`, +/// related GitHub issue here: https://github.com/serde-rs/serde/issues/1626 +fn deserialize_arg_type<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let value: Value = Deserialize::deserialize(deserializer)?; + + let arg_type = match value.get("arg_type").and_then(|value| value.as_str()) { + Some("Text") => ArgumentType::Text, + Some("Enum") => { + let enum_id = value + .get("enum_id") + .ok_or(serde::de::Error::missing_field("enum_id"))?; + let deserialized_id = SyncId::deserialize(enum_id) + .map_err(|_| serde::de::Error::custom("Unable to parse enum_id"))?; + ArgumentType::Enum { + enum_id: deserialized_id, + } + } + _ => ArgumentType::default(), + }; + + Ok(arg_type) +} + +/// The model for a `CloudWorkflow`. +#[derive(Clone, Debug, PartialEq)] +pub struct CloudWorkflowModel { + pub data: Workflow, +} + +impl CloudWorkflowModel { + pub fn new(workflow: Workflow) -> Self { + Self { data: workflow } + } +} + +impl ServerObjectModel for CloudWorkflowModel { + fn object_type(&self) -> ObjectType { + ObjectType::Workflow + } +} + +#[derive(Clone, Debug, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +pub struct WorkflowId(ServerId); +cloud_objects::server_id_traits! { WorkflowId, "Workflow" } + +/// `CloudWorkflow` is a workflow retrieved from the server. +pub type CloudWorkflow = GenericCloudObject; +pub type ServerWorkflow = GenericServerObject; + +impl From for Workflow { + fn from(cloud_workflow: CloudWorkflow) -> Self { + cloud_workflow.model().data.clone() + } +} + +impl From<&CloudWorkflow> for Workflow { + fn from(cloud_workflow: &CloudWorkflow) -> Self { + cloud_workflow.model().data.to_owned() + } +} + +#[cfg(test)] +#[path = "workflow_tests.rs"] +mod tests; diff --git a/crates/cloud_objects/src/cloud_object/models/workflow_enum.rs b/crates/cloud_object_models/src/workflow_enum.rs similarity index 63% rename from crates/cloud_objects/src/cloud_object/models/workflow_enum.rs rename to crates/cloud_object_models/src/workflow_enum.rs index fd3743689b..b93f2b0a08 100644 --- a/crates/cloud_objects/src/cloud_object/models/workflow_enum.rs +++ b/crates/cloud_object_models/src/workflow_enum.rs @@ -1,5 +1,11 @@ +use cloud_objects::{ + cloud_object::{GenericCloudObject, GenericServerObject, GenericStringModel, JsonObjectType}, + ids::GenericStringObjectId, +}; use serde::{Deserialize, Serialize}; +use crate::{JsonModel, JsonSerializer}; + /// Data model for a workflow enum, one type of argument that can be inserted into a workflow /// A workflow enum can either be static or dynamic, as determined by the type of `EnumVariants` it uses /// @@ -20,3 +26,13 @@ pub enum EnumVariants { Static(Vec), // contains the explicit variants for a static enum Dynamic(String), // contains the value of the shell command associated with the dynamic enum } + +impl JsonModel for WorkflowEnum { + fn json_object_type() -> JsonObjectType { + JsonObjectType::WorkflowEnum + } +} + +pub type CloudWorkflowEnum = GenericCloudObject; +pub type CloudWorkflowEnumModel = GenericStringModel; +pub type ServerWorkflowEnum = GenericServerObject; diff --git a/app/src/workflows/workflow_tests.rs b/crates/cloud_object_models/src/workflow_tests.rs similarity index 64% rename from app/src/workflows/workflow_tests.rs rename to crates/cloud_object_models/src/workflow_tests.rs index e703c36a5f..0559cb0fa2 100644 --- a/app/src/workflows/workflow_tests.rs +++ b/crates/cloud_object_models/src/workflow_tests.rs @@ -1,6 +1,17 @@ -use crate::cloud_object::model::generic_string_model::GenericStringObjectId; -use crate::server::ids::{ClientId, HashableId, ServerId, SyncId}; -use crate::workflows::workflow::{Argument, ArgumentType, Workflow}; +use cloud_objects::ids::{ClientId, GenericStringObjectId, HashableId, ServerId, SyncId}; + +use super::{Argument, ArgumentType, Workflow}; + +fn server_id(id: &str) -> ServerId { + ServerId::try_from(id).expect("test server ID should be valid") +} + +fn assert_workflow_roundtrips(workflow: &Workflow) { + let serialized = serde_json::to_string(workflow).expect("Serialized workflow."); + let deserialized = + serde_json::from_str::(&serialized).expect("Deserialized workflow."); + assert_eq!(&deserialized, workflow); +} #[test] fn test_workflow_serialization_with_enum_params() { @@ -17,7 +28,9 @@ fn test_workflow_serialization_with_enum_params() { Argument { name: "server id enum".to_string(), arg_type: ArgumentType::Enum { - enum_id: SyncId::from(GenericStringObjectId::from(ServerId::from(123))), + enum_id: SyncId::from(GenericStringObjectId::from(server_id( + "test_uid00000000000123", + ))), }, description: Some("description".to_string()), default_value: None, @@ -84,3 +97,35 @@ fn test_agent_mode_workflow_serialization() { assert_eq!(deserialized, workflow); } + +#[test] +fn test_serialize_cloud_workflow() { + let sample_workflow = Workflow::new("Test name", "Command name"); + assert_workflow_roundtrips(&sample_workflow); + + let arguments = vec![Argument { + name: "Argument".to_string(), + description: Some("no".to_string()), + default_value: None, + arg_type: Default::default(), + }]; + let arguments_workflow = sample_workflow.clone().with_arguments(arguments); + assert_workflow_roundtrips(&arguments_workflow); + + let description_workflow = sample_workflow.with_description("cool description".to_string()); + assert_workflow_roundtrips(&description_workflow); + + let workflow_with_additional_fields = Workflow::Command { + name: "Test".to_string(), + command: "Command".to_string(), + tags: vec![], + description: None, + arguments: vec![], + source_url: Some("url".to_string()), + author: Some("author_name".to_string()), + author_url: None, + shells: vec![], + environment_variables: Some(SyncId::ServerId(server_id("test_uid00000000000123"))), + }; + assert_workflow_roundtrips(&workflow_with_additional_fields); +} diff --git a/crates/cloud_objects/src/cloud_object/models/mod.rs b/crates/cloud_objects/src/cloud_object/models/mod.rs index dad9cfde05..8b13789179 100644 --- a/crates/cloud_objects/src/cloud_object/models/mod.rs +++ b/crates/cloud_objects/src/cloud_object/models/mod.rs @@ -1,5 +1 @@ -mod cloud_environment; -mod workflow_enum; -pub use cloud_environment::*; -pub use workflow_enum::*; diff --git a/crates/warp_server_client/Cargo.toml b/crates/warp_server_client/Cargo.toml index 7b68d8eaf4..5dc2932525 100644 --- a/crates/warp_server_client/Cargo.toml +++ b/crates/warp_server_client/Cargo.toml @@ -7,12 +7,14 @@ publish.workspace = true license.workspace = true [features] -test-util = ["cloud_objects/test-util"] +test-util = ["cloud_object_client/test-util", "cloud_objects/test-util"] [dependencies] anyhow.workspace = true bincode.workspace = true chrono.workspace = true +cloud_object_client.workspace = true +cloud_object_models.workspace = true cloud_objects.workspace = true cynic.workspace = true derivative.workspace = true diff --git a/crates/warp_server_client/src/cloud_object.rs b/crates/warp_server_client/src/cloud_object.rs index 03e517e98b..e325b66dc0 100644 --- a/crates/warp_server_client/src/cloud_object.rs +++ b/crates/warp_server_client/src/cloud_object.rs @@ -1 +1,5 @@ -pub use cloud_objects::cloud_object::*; +pub use cloud_object_client::*; + +pub mod models { + pub use cloud_object_models::*; +}