1478 lines
50 KiB
Rust
1478 lines
50 KiB
Rust
use core::fmt;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
use galaxy_core::user_preferences::GetUserPreferences;
|
|
use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity};
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use super::{AIExecutionProfile, ActionPermission, WriteToPtyPermission};
|
|
use crate::ai::llms::{LLMId, LLMPreferences};
|
|
use crate::ai::mcp::templatable_manager::TemplatableMCPServerManagerEvent;
|
|
use crate::ai::mcp::TemplatableMCPServerManager;
|
|
use crate::cloud_object::CloudObject as _;
|
|
use crate::local_object_repository::{LocalObjectRepository, LocalObjectRepositoryEvent};
|
|
use crate::server::ids::{ClientId, SyncId};
|
|
use crate::settings::AgentModeCommandExecutionPredicate;
|
|
use crate::{send_telemetry_from_ctx, LaunchMode, TelemetryEvent};
|
|
|
|
/// ExecutionProfileId is the identifier that users of the AIExecutionProfilesModel use
|
|
/// to refer back to a specific profile. These are unique across the lifespan of the app.
|
|
#[derive(Copy, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
pub struct ClientProfileId(usize);
|
|
|
|
impl ClientProfileId {
|
|
#[allow(clippy::new_without_default)]
|
|
pub fn new() -> ClientProfileId {
|
|
static NEXT_PROFILE_ID: AtomicUsize = AtomicUsize::new(0);
|
|
let raw = NEXT_PROFILE_ID.fetch_add(1, Ordering::Relaxed);
|
|
ClientProfileId(raw)
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for ClientProfileId {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
std::fmt::Display::fmt(&self.0, f)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct AIExecutionProfileInfo {
|
|
id: ClientProfileId,
|
|
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
|
sync_id: Option<SyncId>,
|
|
data: AIExecutionProfile,
|
|
}
|
|
|
|
impl AIExecutionProfileInfo {
|
|
pub fn id(&self) -> &ClientProfileId {
|
|
&self.id
|
|
}
|
|
|
|
/// The persisted object ID of this profile, if it has been saved.
|
|
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
|
pub fn sync_id(&self) -> Option<SyncId> {
|
|
self.sync_id
|
|
}
|
|
|
|
pub fn data(&self) -> &AIExecutionProfile {
|
|
&self.data
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
#[allow(clippy::large_enum_variant)]
|
|
pub enum DefaultProfileState {
|
|
Unsynced {
|
|
id: ClientProfileId,
|
|
profile: AIExecutionProfile,
|
|
},
|
|
Synced {
|
|
id: ClientProfileId,
|
|
},
|
|
/// Currently, the behavior of the CLI default is that it
|
|
/// cannot be updated and will never be synced.
|
|
#[allow(dead_code)]
|
|
Cli {
|
|
id: ClientProfileId,
|
|
profile: AIExecutionProfile,
|
|
},
|
|
}
|
|
|
|
impl std::fmt::Display for DefaultProfileState {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
DefaultProfileState::Unsynced { .. } => write!(f, "Unsynced"),
|
|
DefaultProfileState::Synced { .. } => write!(f, "Synced"),
|
|
DefaultProfileState::Cli { .. } => write!(f, "CLI"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DefaultProfileState {
|
|
pub fn id(&self) -> ClientProfileId {
|
|
match self {
|
|
DefaultProfileState::Unsynced { id, .. } => *id,
|
|
DefaultProfileState::Synced { id } => *id,
|
|
DefaultProfileState::Cli { id, .. } => *id,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct AIExecutionProfilesModel {
|
|
/// The default profile begins as an in-memory default and becomes backed
|
|
/// by the local object repository on its first edit. CLI mode retains its
|
|
/// separate, immutable profile.
|
|
default_profile_state: DefaultProfileState,
|
|
profile_id_to_sync_id: HashMap<ClientProfileId, SyncId>,
|
|
/// Only contains entries for non-default profiles.
|
|
active_profiles_per_session: HashMap<EntityId, ClientProfileId>,
|
|
}
|
|
|
|
impl AIExecutionProfilesModel {
|
|
#[allow(unused_variables)]
|
|
pub fn new(launch_mode: &LaunchMode, ctx: &mut ModelContext<Self>) -> Self {
|
|
cfg_if::cfg_if! {
|
|
if #[cfg(feature = "agent_mode_evals")] {
|
|
let default_profile_state = DefaultProfileState::Unsynced {
|
|
id: ClientProfileId::new(),
|
|
profile: AIExecutionProfile::create_agent_mode_eval_profile(),
|
|
};
|
|
let profile_id_to_sync_id: HashMap<ClientProfileId, SyncId> = HashMap::new();
|
|
let active_profiles_per_session: HashMap<EntityId, ClientProfileId> = HashMap::new();
|
|
} else {
|
|
let all_local_profiles = LocalObjectRepository::as_ref(ctx).execution_profiles(ctx);
|
|
|
|
let default_local_profile = all_local_profiles
|
|
.iter()
|
|
.find(|obj| obj.model().string_model.is_default_profile)
|
|
.cloned();
|
|
|
|
let mut profile_id_to_sync_id: HashMap<ClientProfileId, SyncId> = HashMap::new();
|
|
let active_profiles_per_session: HashMap<EntityId, ClientProfileId> = HashMap::new();
|
|
|
|
// Insert all non-default profiles from local persistence.
|
|
for local_profile in all_local_profiles.iter().filter(|p| !p.model().string_model.is_default_profile) {
|
|
let profile_id = ClientProfileId::new();
|
|
profile_id_to_sync_id.insert(profile_id, local_profile.id);
|
|
}
|
|
|
|
let default_profile_state = match launch_mode {
|
|
// The TUI front-end shares the GUI app's locally persisted
|
|
// default execution profile.
|
|
LaunchMode::App { .. }
|
|
| LaunchMode::Test { .. }
|
|
| LaunchMode::Tui { .. } => {
|
|
match default_local_profile {
|
|
Some(p) => {
|
|
let execution_profile_id = ClientProfileId::new();
|
|
profile_id_to_sync_id.insert(execution_profile_id, p.id);
|
|
DefaultProfileState::Synced {
|
|
id: execution_profile_id,
|
|
}
|
|
}
|
|
None => DefaultProfileState::Unsynced {
|
|
id: ClientProfileId::new(),
|
|
profile: super::create_default_from_legacy_settings(ctx),
|
|
},
|
|
}
|
|
}
|
|
// When running as a CLI, we ignore the GUI default and use a more permissive default.
|
|
LaunchMode::CommandLine { is_sandboxed, computer_use_override, .. } => {
|
|
DefaultProfileState::Cli {
|
|
profile: AIExecutionProfile::create_default_cli_profile(*is_sandboxed, *computer_use_override),
|
|
id: ClientProfileId::new()
|
|
}
|
|
}
|
|
// RemoteServerProxy and RemoteServerDaemon don't use AI
|
|
// execution profiles. They never reach this code path
|
|
// since they don't go through initialize_app, but handle
|
|
// exhaustively.
|
|
LaunchMode::RemoteServerProxy | LaunchMode::RemoteServerDaemon { .. } => DefaultProfileState::Unsynced {
|
|
id: ClientProfileId::new(),
|
|
profile: super::create_default_from_legacy_settings(ctx),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
// Keep the client-ID map and subscribed views synchronized with local
|
|
// repository changes, including legacy rows adopted at startup.
|
|
if !cfg!(feature = "agent_mode_evals") {
|
|
ctx.subscribe_to_model(&LocalObjectRepository::handle(ctx), |me, _, event, ctx| {
|
|
me.handle_local_repository_event(event, ctx);
|
|
});
|
|
}
|
|
|
|
ctx.subscribe_to_model(
|
|
&TemplatableMCPServerManager::handle(ctx),
|
|
|me, _, event, ctx| {
|
|
me.handle_templatable_mcp_server_manager_event(event, ctx);
|
|
},
|
|
);
|
|
|
|
log::info!("Initialized execution profile model with state: {default_profile_state}",);
|
|
|
|
let mut model = Self {
|
|
default_profile_state,
|
|
profile_id_to_sync_id,
|
|
active_profiles_per_session,
|
|
};
|
|
|
|
model.maybe_inherit_from_legacy_settings(ctx);
|
|
model
|
|
}
|
|
|
|
/// This function performs one-time migrations from legacy settings into the default profile.
|
|
/// The issue this solves is that, whenever we migrate an existing setting into the profile object,
|
|
/// users will initialize the new field to its default value. We need to manually check to see if
|
|
/// the legacy setting hasn't been migrated and, if it hasn't, do a one-time overwrite on the new profile
|
|
/// field.
|
|
fn maybe_inherit_from_legacy_settings(&mut self, ctx: &mut ModelContext<Self>) {
|
|
let DefaultProfileState::Synced {
|
|
id: default_profile_id,
|
|
} = self.default_profile_state
|
|
else {
|
|
return;
|
|
};
|
|
|
|
if let Some(base_llm_id) = ctx
|
|
.private_user_preferences()
|
|
.read_value("PreferredAgentModeLLMId")
|
|
.ok()
|
|
.flatten()
|
|
.map(|s| serde_json::from_str::<Option<LLMId>>(&s))
|
|
.and_then(|res| res.ok())
|
|
.flatten()
|
|
{
|
|
if let Err(e) = ctx
|
|
.private_user_preferences()
|
|
.remove_value("PreferredAgentModeLLMId")
|
|
{
|
|
log::error!("Failed to remove old PreferredAgentModeLLMId user pref: {e}");
|
|
}
|
|
self.set_base_model(default_profile_id, Some(base_llm_id.clone()), ctx);
|
|
log::info!("Overwrote default profile with legacy setting for base llm: {base_llm_id}");
|
|
}
|
|
}
|
|
|
|
pub fn create_profile(&mut self, ctx: &mut ModelContext<Self>) -> Option<ClientProfileId> {
|
|
let profile_id = ClientProfileId::new();
|
|
|
|
let mut new_profile = self.default_profile(ctx).data().clone();
|
|
new_profile.name = "".to_string();
|
|
new_profile.is_default_profile = false;
|
|
new_profile.autosync_plans_to_warp_drive = true;
|
|
|
|
let sync_id = SyncId::ClientId(ClientId::new());
|
|
self.profile_id_to_sync_id.insert(profile_id, sync_id);
|
|
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
|
repository.create_execution_profile_with_id(sync_id, new_profile, ctx);
|
|
});
|
|
|
|
send_telemetry_from_ctx!(TelemetryEvent::AIExecutionProfileCreated, ctx);
|
|
|
|
ctx.emit(AIExecutionProfilesModelEvent::ProfileCreated);
|
|
|
|
Some(profile_id)
|
|
}
|
|
|
|
pub fn delete_profile(&mut self, profile_id: ClientProfileId, ctx: &mut ModelContext<Self>) {
|
|
let id = self.default_profile_state.id();
|
|
if id == profile_id {
|
|
log::warn!("Attempted to delete default profile (id: {profile_id})");
|
|
return;
|
|
}
|
|
|
|
let Some(sync_id) = self.profile_id_to_sync_id.get(&profile_id).cloned() else {
|
|
return;
|
|
};
|
|
|
|
self.active_profiles_per_session
|
|
.retain(|_, active_profile_id| *active_profile_id != profile_id);
|
|
|
|
self.profile_id_to_sync_id.remove(&profile_id);
|
|
|
|
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
|
repository.delete_execution_profile(sync_id, ctx);
|
|
});
|
|
|
|
send_telemetry_from_ctx!(TelemetryEvent::AIExecutionProfileDeleted, ctx);
|
|
ctx.emit(AIExecutionProfilesModelEvent::ProfileDeleted);
|
|
}
|
|
|
|
/// Resets the in-memory profile index to conservative defaults.
|
|
pub fn reset(&mut self) {
|
|
self.default_profile_state = DefaultProfileState::Unsynced {
|
|
id: ClientProfileId::new(),
|
|
profile: AIExecutionProfile {
|
|
is_default_profile: true,
|
|
..Default::default()
|
|
},
|
|
};
|
|
self.profile_id_to_sync_id.clear();
|
|
self.active_profiles_per_session.clear();
|
|
}
|
|
|
|
/// Returns the active permissions profile for a specific terminal view.
|
|
/// If no terminal_view is provided, returns the default profile.
|
|
///
|
|
/// If you need to account for enterprise overrides, call `BlocklistAIPermissions::active_permissions_profile` instead.
|
|
pub fn active_profile(
|
|
&self,
|
|
terminal_view_id: Option<EntityId>,
|
|
ctx: &AppContext,
|
|
) -> AIExecutionProfileInfo {
|
|
terminal_view_id
|
|
.and_then(|id| self.active_profiles_per_session.get(&id))
|
|
.and_then(|profile_id| self.get_profile_by_id(*profile_id, ctx))
|
|
.unwrap_or_else(|| self.default_profile(ctx))
|
|
}
|
|
|
|
pub fn default_profile_id(&self) -> ClientProfileId {
|
|
self.default_profile_state.id()
|
|
}
|
|
|
|
pub fn default_profile(&self, ctx: &AppContext) -> AIExecutionProfileInfo {
|
|
match &self.default_profile_state {
|
|
DefaultProfileState::Unsynced { id, profile } => AIExecutionProfileInfo {
|
|
id: *id,
|
|
sync_id: None,
|
|
data: profile.clone(),
|
|
},
|
|
DefaultProfileState::Synced { id } => {
|
|
let Some(sync_id) = self.profile_id_to_sync_id.get(id) else {
|
|
log::error!(
|
|
"Default profile is synced but no sync_id found in profile_id_to_sync_id map."
|
|
);
|
|
return AIExecutionProfileInfo {
|
|
id: *id,
|
|
sync_id: None,
|
|
data: AIExecutionProfile::default(),
|
|
};
|
|
};
|
|
let data = LocalObjectRepository::as_ref(ctx)
|
|
.execution_profile(sync_id, ctx)
|
|
.map(|o| o.model().string_model.clone())
|
|
.unwrap_or_default();
|
|
|
|
AIExecutionProfileInfo {
|
|
id: *id,
|
|
sync_id: Some(*sync_id),
|
|
data,
|
|
}
|
|
}
|
|
DefaultProfileState::Cli { id, profile } => AIExecutionProfileInfo {
|
|
id: *id,
|
|
sync_id: None,
|
|
data: profile.clone(),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Sets the active profile for a specific terminal view.
|
|
pub fn set_active_profile(
|
|
&mut self,
|
|
terminal_view_id: EntityId,
|
|
profile_id: ClientProfileId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.active_profiles_per_session
|
|
.insert(terminal_view_id, profile_id);
|
|
ctx.emit(AIExecutionProfilesModelEvent::UpdatedActiveProfile { terminal_view_id });
|
|
}
|
|
|
|
/// Returns a profile by its client ID.
|
|
/// Returns None if the profile is not found.
|
|
pub fn get_profile_by_id(
|
|
&self,
|
|
profile_id: ClientProfileId,
|
|
ctx: &AppContext,
|
|
) -> Option<AIExecutionProfileInfo> {
|
|
// Handle an unsynced default profile (including CLI)
|
|
match &self.default_profile_state {
|
|
DefaultProfileState::Unsynced { id, profile }
|
|
| DefaultProfileState::Cli { id, profile } => {
|
|
if profile_id == *id {
|
|
return Some(AIExecutionProfileInfo {
|
|
id: *id,
|
|
sync_id: None,
|
|
data: profile.clone(),
|
|
});
|
|
}
|
|
}
|
|
DefaultProfileState::Synced { .. } => {}
|
|
}
|
|
|
|
// Handle all synced profiles (default and non-default)
|
|
let sync_id = self.profile_id_to_sync_id.get(&profile_id)?;
|
|
let data = LocalObjectRepository::as_ref(ctx)
|
|
.execution_profile(sync_id, ctx)
|
|
.map(|o| o.model().string_model.clone())
|
|
.unwrap_or_default();
|
|
|
|
Some(AIExecutionProfileInfo {
|
|
id: profile_id,
|
|
sync_id: Some(*sync_id),
|
|
data,
|
|
})
|
|
}
|
|
|
|
pub fn get_all_profile_ids(&self) -> Vec<ClientProfileId> {
|
|
let default_profile_id = self.default_profile_state.id();
|
|
|
|
// Default profile is always first in the list
|
|
std::iter::once(default_profile_id)
|
|
.chain(
|
|
self.profile_id_to_sync_id
|
|
.keys()
|
|
.filter(|&&id| id != default_profile_id)
|
|
.cloned(),
|
|
)
|
|
.collect()
|
|
}
|
|
|
|
/// Look up a local client profile ID from its cloud sync ID.
|
|
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
|
pub fn get_profile_id_by_sync_id(&self, sync_id: &SyncId) -> Option<ClientProfileId> {
|
|
self.profile_id_to_sync_id
|
|
.iter()
|
|
.find_map(|(client_id, id)| {
|
|
if id == sync_id {
|
|
Some(*client_id)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
pub fn has_multiple_profiles(&self) -> bool {
|
|
let default_profile_id = self.default_profile_state.id();
|
|
|
|
self.profile_id_to_sync_id
|
|
.keys()
|
|
.any(|&id| id != default_profile_id)
|
|
}
|
|
|
|
pub fn set_base_model(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
llm_id: Option<LLMId>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.base_model != llm_id {
|
|
profile.base_model = llm_id.clone();
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
if let Some(model_id) = &llm_id {
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileModelSelected {
|
|
model_type: "base".to_string(),
|
|
model_value: model_id.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn set_coding_model(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
model_id: Option<LLMId>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.coding_model != model_id {
|
|
profile.coding_model = model_id.clone();
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
if let Some(model_id) = &model_id {
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileModelSelected {
|
|
model_type: "coding".to_string(),
|
|
model_value: model_id.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn set_cli_agent_model(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
model_id: Option<LLMId>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.cli_agent_model != model_id {
|
|
profile.cli_agent_model = model_id.clone();
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
if let Some(model_id) = &model_id {
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileModelSelected {
|
|
model_type: "cli_agent".to_string(),
|
|
model_value: model_id.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn set_computer_use_model(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
model_id: Option<LLMId>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.computer_use_model != model_id {
|
|
profile.computer_use_model = model_id.clone();
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
if let Some(model_id) = &model_id {
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileModelSelected {
|
|
model_type: "computer_use".to_string(),
|
|
model_value: model_id.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn set_context_window_limit(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
limit: Option<u32>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let changed = self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.context_window_limit != limit {
|
|
profile.context_window_limit = limit;
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
// Gate on the limit being non-empty. The limit is cleared during
|
|
// reconciliation, which runs inside an `LLMPreferences` update where the
|
|
// `LLMPreferences::as_ref` read below would panic.
|
|
if changed && limit.is_some() {
|
|
let Some(profile) = self.get_profile_by_id(profile_id, ctx) else {
|
|
return;
|
|
};
|
|
let llm_preferences = LLMPreferences::as_ref(ctx);
|
|
let model_info = profile
|
|
.data()
|
|
.base_model
|
|
.as_ref()
|
|
.and_then(|id| llm_preferences.get_llm_info(id))
|
|
.unwrap_or_else(|| llm_preferences.get_default_base_model());
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileContextWindowSelected {
|
|
tokens: limit,
|
|
model_id: model_info.id.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn set_apply_code_diffs(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
apply_code_diffs: &ActionPermission,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.apply_code_diffs != *apply_code_diffs {
|
|
profile.apply_code_diffs = *apply_code_diffs;
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileSettingUpdated {
|
|
setting_type: "apply_code_diffs".to_string(),
|
|
setting_value: format!("{apply_code_diffs:?}"),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn set_read_files(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
read_files: &ActionPermission,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.read_files != *read_files {
|
|
profile.read_files = *read_files;
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileSettingUpdated {
|
|
setting_type: "read_files".to_string(),
|
|
setting_value: format!("{read_files:?}"),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn set_execute_commands(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
execute_commands: &ActionPermission,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.execute_commands != *execute_commands {
|
|
profile.execute_commands = *execute_commands;
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileSettingUpdated {
|
|
setting_type: "execute_commands".to_string(),
|
|
setting_value: format!("{execute_commands:?}"),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn set_write_to_pty(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
write_to_pty: &WriteToPtyPermission,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.write_to_pty != *write_to_pty {
|
|
profile.write_to_pty = *write_to_pty;
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileSettingUpdated {
|
|
setting_type: "write_to_pty".to_string(),
|
|
setting_value: format!("{write_to_pty:?}"),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn set_mcp_permissions(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
mcp_permissions: &ActionPermission,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.mcp_permissions == *mcp_permissions {
|
|
return false;
|
|
}
|
|
|
|
if mcp_permissions == &ActionPermission::AlwaysAllow {
|
|
profile.mcp_allowlist.clear();
|
|
} else if mcp_permissions == &ActionPermission::AlwaysAsk {
|
|
profile.mcp_denylist.clear();
|
|
}
|
|
profile.mcp_permissions = *mcp_permissions;
|
|
true
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileSettingUpdated {
|
|
setting_type: "mcp_permissions".to_string(),
|
|
setting_value: format!("{mcp_permissions:?}"),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn set_computer_use(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
permission: &super::ComputerUsePermission,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let current_value = self
|
|
.get_profile_by_id(profile_id, ctx)
|
|
.map(|p| p.data().computer_use);
|
|
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.computer_use != *permission {
|
|
profile.computer_use = *permission;
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
if current_value != Some(*permission) {
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileSettingUpdated {
|
|
setting_type: "computer_use".to_string(),
|
|
setting_value: format!("{permission:?}"),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn set_ask_user_question(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
permission: super::AskUserQuestionPermission,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let current_value = self
|
|
.get_profile_by_id(profile_id, ctx)
|
|
.map(|p| p.data().ask_user_question);
|
|
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.ask_user_question != permission {
|
|
profile.ask_user_question = permission;
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
if current_value != Some(permission) {
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileSettingUpdated {
|
|
setting_type: "ask_user_question".to_string(),
|
|
setting_value: format!("{permission:?}"),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn set_run_agents(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
permission: super::RunAgentsPermission,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let current_value = self
|
|
.get_profile_by_id(profile_id, ctx)
|
|
.map(|p| p.data().run_agents);
|
|
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.run_agents != permission {
|
|
profile.run_agents = permission;
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
if current_value != Some(permission) {
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileSettingUpdated {
|
|
setting_type: "run_agents".to_string(),
|
|
setting_value: format!("{permission:?}"),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn set_web_search_enabled(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
enabled: bool,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.web_search_enabled != enabled {
|
|
profile.web_search_enabled = enabled;
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileSettingUpdated {
|
|
setting_type: "web_search_enabled".to_string(),
|
|
setting_value: format!("{enabled}"),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn set_autosync_plans_to_warp_drive(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
enabled: bool,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.autosync_plans_to_warp_drive != enabled {
|
|
profile.autosync_plans_to_warp_drive = enabled;
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileSettingUpdated {
|
|
setting_type: "plan_auto_sync".to_string(),
|
|
setting_value: format!("{enabled}"),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn set_profile_name(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
name: &str,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if profile.name != name {
|
|
profile.name = name.to_string();
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileSettingUpdated {
|
|
setting_type: "name".to_string(),
|
|
setting_value: name.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn add_to_command_allowlist(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
predicate: &AgentModeCommandExecutionPredicate,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if !profile.command_allowlist.contains(predicate) {
|
|
profile.command_allowlist.push(predicate.clone());
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileAddedToAllowlist {
|
|
list_type: "command".to_string(),
|
|
value: predicate.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn remove_from_command_allowlist(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
predicate: &AgentModeCommandExecutionPredicate,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
let original_len = profile.command_allowlist.len();
|
|
profile.command_allowlist.retain(|p| p != predicate);
|
|
profile.command_allowlist.len() != original_len
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileRemovedFromAllowlist {
|
|
list_type: "command".to_string(),
|
|
value: predicate.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn add_to_directory_allowlist(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
path: &PathBuf,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if !profile.directory_allowlist.contains(path) {
|
|
profile.directory_allowlist.push(path.clone());
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileAddedToAllowlist {
|
|
list_type: "directory".to_string(),
|
|
value: path.to_string_lossy().to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn remove_from_directory_allowlist(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
path: &PathBuf,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
let original_len = profile.directory_allowlist.len();
|
|
profile.directory_allowlist.retain(|p| p != path);
|
|
profile.directory_allowlist.len() != original_len
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileRemovedFromAllowlist {
|
|
list_type: "directory".to_string(),
|
|
value: path.to_string_lossy().to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn add_to_command_denylist(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
predicate: &AgentModeCommandExecutionPredicate,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if !profile.command_denylist.contains(predicate) {
|
|
profile.command_denylist.push(predicate.clone());
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileAddedToDenylist {
|
|
list_type: "command".to_string(),
|
|
value: predicate.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn remove_from_command_denylist(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
predicate: &AgentModeCommandExecutionPredicate,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
let original_len = profile.command_denylist.len();
|
|
profile.command_denylist.retain(|p| p != predicate);
|
|
profile.command_denylist.len() != original_len
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileRemovedFromDenylist {
|
|
list_type: "command".to_string(),
|
|
value: predicate.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn add_to_mcp_allowlist(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
id: &Uuid,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if !profile.mcp_allowlist.contains(id) {
|
|
profile.mcp_allowlist.push(*id);
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileAddedToAllowlist {
|
|
list_type: "mcp".to_string(),
|
|
value: id.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn remove_from_mcp_allowlist(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
id: &Uuid,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
let original_len = profile.mcp_allowlist.len();
|
|
profile.mcp_allowlist.retain(|p| p != id);
|
|
profile.mcp_allowlist.len() != original_len
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileRemovedFromAllowlist {
|
|
list_type: "mcp".to_string(),
|
|
value: id.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn add_to_mcp_denylist(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
id: &Uuid,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
if !profile.mcp_denylist.contains(id) {
|
|
profile.mcp_denylist.push(*id);
|
|
return true;
|
|
}
|
|
false
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileAddedToDenylist {
|
|
list_type: "mcp".to_string(),
|
|
value: id.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
pub fn remove_from_mcp_denylist(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
id: &Uuid,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
let original_len = profile.mcp_denylist.len();
|
|
profile.mcp_denylist.retain(|p| p != id);
|
|
profile.mcp_denylist.len() != original_len
|
|
},
|
|
ctx,
|
|
);
|
|
|
|
send_telemetry_from_ctx!(
|
|
TelemetryEvent::AIExecutionProfileRemovedFromDenylist {
|
|
list_type: "mcp".to_string(),
|
|
value: id.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
|
|
/// Edits an execution profile and persists the changed profile locally.
|
|
/// Parameters:
|
|
/// * `profile_id`: The id of the profile to edit
|
|
/// * `edit_fn`: a closure that safely modifies the AIExecutionProfile. It should return `true` if the profile was changed, `false` otherwise. When `true`, it saves the changes locally, and otherwise exits early to prevent unnecessary persistence work.
|
|
/// * `ctx`: The model context
|
|
///
|
|
/// Returns `true` if the profile was actually changed and saved,
|
|
/// `false` otherwise. Callers can use this to gate side effects such as
|
|
/// telemetry on real changes.
|
|
fn edit_profile_internal(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
edit_fn: impl FnOnce(&mut AIExecutionProfile) -> bool,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> bool {
|
|
// We don't yet support editing the default profile for the CLI.
|
|
if let DefaultProfileState::Cli { id, .. } = &self.default_profile_state {
|
|
if *id == profile_id {
|
|
log::warn!("Attempted to edit CLI default profile, which is not yet supported.");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// The default profile starts in memory so a fresh install needs no
|
|
// account or database seed. Persist it on its first edit.
|
|
if let DefaultProfileState::Unsynced { id, profile } = &self.default_profile_state {
|
|
if *id == profile_id {
|
|
let mut new_profile = profile.clone();
|
|
let value_changed = edit_fn(&mut new_profile);
|
|
if !value_changed {
|
|
return false;
|
|
}
|
|
|
|
let sync_id = SyncId::ClientId(ClientId::new());
|
|
self.default_profile_state = DefaultProfileState::Synced { id: profile_id };
|
|
self.profile_id_to_sync_id.insert(profile_id, sync_id);
|
|
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
|
repository.create_execution_profile_with_id(sync_id, new_profile, ctx);
|
|
});
|
|
|
|
log::info!("Persisted the default execution profile locally: {profile_id:?}");
|
|
ctx.emit(AIExecutionProfilesModelEvent::ProfileUpdated(profile_id));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
let mut value_changed = false;
|
|
if let Some(sync_id) = self.profile_id_to_sync_id.get(&profile_id) {
|
|
if let Some(object) = LocalObjectRepository::as_ref(ctx).execution_profile(sync_id, ctx)
|
|
{
|
|
let mut data = object.model().string_model.clone();
|
|
value_changed = edit_fn(&mut data);
|
|
if !value_changed {
|
|
return false;
|
|
}
|
|
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
|
|
repository.update_execution_profile(*sync_id, data, ctx);
|
|
});
|
|
|
|
log::info!("Edited execution profile with id: {profile_id:?}");
|
|
} else {
|
|
log::error!("Profile id is mapped but no object found: {profile_id:?}");
|
|
}
|
|
}
|
|
ctx.emit(AIExecutionProfilesModelEvent::ProfileUpdated(profile_id));
|
|
value_changed
|
|
}
|
|
|
|
fn handle_local_repository_event(
|
|
&mut self,
|
|
event: &LocalObjectRepositoryEvent,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
match event {
|
|
LocalObjectRepositoryEvent::ExecutionProfiles { id: Some(sync_id) } => {
|
|
if LocalObjectRepository::as_ref(ctx)
|
|
.execution_profile(sync_id, ctx)
|
|
.is_some()
|
|
{
|
|
self.handle_execution_profile_upserted(*sync_id, ctx);
|
|
} else {
|
|
self.handle_execution_profile_deleted(*sync_id, ctx);
|
|
}
|
|
}
|
|
LocalObjectRepositoryEvent::ExecutionProfiles { id: None } => {
|
|
self.reconcile_with_local_repository(ctx);
|
|
}
|
|
LocalObjectRepositoryEvent::Rules
|
|
| LocalObjectRepositoryEvent::Notebooks { .. }
|
|
| LocalObjectRepositoryEvent::Workflows { .. } => {}
|
|
}
|
|
}
|
|
|
|
fn reconcile_with_local_repository(&mut self, ctx: &mut ModelContext<Self>) {
|
|
let profiles = LocalObjectRepository::as_ref(ctx).execution_profiles(ctx);
|
|
let persisted_ids = profiles
|
|
.iter()
|
|
.map(|profile| profile.id)
|
|
.collect::<Vec<_>>();
|
|
let default_sync_id = profiles
|
|
.iter()
|
|
.find(|profile| profile.model().string_model.is_default_profile)
|
|
.map(|profile| profile.id);
|
|
|
|
if let DefaultProfileState::Unsynced { id, .. } = self.default_profile_state {
|
|
if let Some(sync_id) = default_sync_id {
|
|
self.default_profile_state = DefaultProfileState::Synced { id };
|
|
self.profile_id_to_sync_id.insert(id, sync_id);
|
|
ctx.emit(AIExecutionProfilesModelEvent::ProfileUpdated(id));
|
|
}
|
|
}
|
|
|
|
let removed_profile_ids = self
|
|
.profile_id_to_sync_id
|
|
.iter()
|
|
.filter_map(|(profile_id, sync_id)| {
|
|
(!persisted_ids.contains(sync_id)).then_some(*profile_id)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
for profile_id in removed_profile_ids {
|
|
let sync_id = self.profile_id_to_sync_id[&profile_id];
|
|
self.handle_execution_profile_deleted(sync_id, ctx);
|
|
}
|
|
|
|
for profile in profiles {
|
|
if !profile.model().string_model.is_default_profile
|
|
&& !self
|
|
.profile_id_to_sync_id
|
|
.values()
|
|
.any(|sync_id| *sync_id == profile.id)
|
|
{
|
|
let profile_id = ClientProfileId::new();
|
|
self.profile_id_to_sync_id.insert(profile_id, profile.id);
|
|
ctx.emit(AIExecutionProfilesModelEvent::ProfileCreated);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_templatable_mcp_server_manager_event(
|
|
&mut self,
|
|
event: &TemplatableMCPServerManagerEvent,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
match event {
|
|
TemplatableMCPServerManagerEvent::TemplatableMCPServersUpdated => {
|
|
self.remove_deleted_mcp_servers(ctx);
|
|
}
|
|
TemplatableMCPServerManagerEvent::LegacyServerConverted
|
|
| TemplatableMCPServerManagerEvent::StateChanged { uuid: _, state: _ }
|
|
| TemplatableMCPServerManagerEvent::ServerInstallationAdded(_)
|
|
| TemplatableMCPServerManagerEvent::ServerInstallationDeleted(_) => {}
|
|
}
|
|
}
|
|
|
|
fn handle_execution_profile_upserted(&mut self, sync_id: SyncId, ctx: &mut ModelContext<Self>) {
|
|
let Some(object) = LocalObjectRepository::as_ref(ctx).execution_profile(&sync_id, ctx)
|
|
else {
|
|
log::warn!(
|
|
"Received an execution profile update but no local object was found: {sync_id:?}"
|
|
);
|
|
return;
|
|
};
|
|
|
|
if let Some(profile_id) = self.get_profile_id_by_sync_id(&sync_id) {
|
|
ctx.emit(AIExecutionProfilesModelEvent::ProfileUpdated(profile_id));
|
|
return;
|
|
}
|
|
|
|
if object.model().string_model.is_default_profile {
|
|
if matches!(self.default_profile_state, DefaultProfileState::Cli { .. }) {
|
|
log::info!("Ignoring the persisted default profile in CLI mode: {sync_id:?}");
|
|
return;
|
|
}
|
|
|
|
if let DefaultProfileState::Unsynced { id, .. } = self.default_profile_state {
|
|
self.default_profile_state = DefaultProfileState::Synced { id };
|
|
self.profile_id_to_sync_id.insert(id, sync_id);
|
|
log::info!("Adopted the persisted default execution profile: {sync_id:?}");
|
|
ctx.emit(AIExecutionProfilesModelEvent::ProfileUpdated(id));
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
let profile_id = ClientProfileId::new();
|
|
self.profile_id_to_sync_id.insert(profile_id, sync_id);
|
|
log::info!("Added a local execution profile to the client map: {sync_id:?}");
|
|
ctx.emit(AIExecutionProfilesModelEvent::ProfileCreated);
|
|
}
|
|
|
|
fn handle_execution_profile_deleted(&mut self, sync_id: SyncId, ctx: &mut ModelContext<Self>) {
|
|
// Find and remove the profile from our map
|
|
let profile_id = self
|
|
.profile_id_to_sync_id
|
|
.iter()
|
|
.find_map(|(client_id, id)| {
|
|
if *id == sync_id {
|
|
Some(*client_id)
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
if let Some(profile_id) = profile_id {
|
|
self.profile_id_to_sync_id.remove(&profile_id);
|
|
|
|
// Also remove from active profiles per session
|
|
self.active_profiles_per_session
|
|
.retain(|_, active_id| *active_id != profile_id);
|
|
|
|
let is_default = matches!(&self.default_profile_state, DefaultProfileState::Synced { id } if *id == profile_id);
|
|
if is_default {
|
|
log::warn!(
|
|
"Default execution profile was deleted locally. Restoring in-memory defaults: {sync_id:?}"
|
|
);
|
|
self.default_profile_state = DefaultProfileState::Unsynced {
|
|
id: profile_id,
|
|
profile: AIExecutionProfile {
|
|
is_default_profile: true,
|
|
..Default::default()
|
|
},
|
|
};
|
|
}
|
|
|
|
log::info!("Removed local execution profile from the client map: {sync_id:?}");
|
|
ctx.emit(AIExecutionProfilesModelEvent::ProfileDeleted);
|
|
}
|
|
}
|
|
|
|
/// Handle deleted MCP servers by deleting its uuid from all profiles.
|
|
fn remove_deleted_mcp_servers(&mut self, ctx: &mut ModelContext<Self>) {
|
|
let all_valid_uuids = TemplatableMCPServerManager::get_all_cloud_synced_mcp_servers(ctx);
|
|
for profile_id in self.get_all_profile_ids() {
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
|profile| {
|
|
let original_allowlist_len = profile.mcp_allowlist.len();
|
|
let original_denylist_len = profile.mcp_denylist.len();
|
|
profile
|
|
.mcp_allowlist
|
|
.retain(|uuid| all_valid_uuids.contains_key(uuid));
|
|
profile
|
|
.mcp_denylist
|
|
.retain(|uuid| all_valid_uuids.contains_key(uuid));
|
|
profile.mcp_allowlist.len() != original_allowlist_len
|
|
|| profile.mcp_denylist.len() != original_denylist_len
|
|
},
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
|
|
// We don't want stale client ids in our map. We won't be able to find the backing cloud object when
|
|
// an edit occurs.
|
|
pub fn replace_client_id_with_server_id(&mut self, server_id: SyncId, client_id: SyncId) {
|
|
for (_, sync_id) in self.profile_id_to_sync_id.iter_mut() {
|
|
if *sync_id == client_id {
|
|
*sync_id = server_id;
|
|
log::info!("Updated profile id mapping after creating a new execution profile");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Replaces the given profile's data with CLI defaults for the given sandboxed state.
|
|
/// Use in tests to simulate the profile configuration used by the sandboxed CLI agent.
|
|
#[cfg(test)]
|
|
pub fn apply_cli_profile_defaults_for_test(
|
|
&mut self,
|
|
profile_id: ClientProfileId,
|
|
is_sandboxed: bool,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let cli_profile = AIExecutionProfile::create_default_cli_profile(is_sandboxed, None);
|
|
self.edit_profile_internal(
|
|
profile_id,
|
|
move |profile| {
|
|
*profile = cli_profile;
|
|
true
|
|
},
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::enum_variant_names)]
|
|
pub enum AIExecutionProfilesModelEvent {
|
|
ProfileUpdated(ClientProfileId),
|
|
ProfileCreated,
|
|
ProfileDeleted,
|
|
UpdatedActiveProfile { terminal_view_id: EntityId },
|
|
}
|
|
|
|
impl Entity for AIExecutionProfilesModel {
|
|
type Event = AIExecutionProfilesModelEvent;
|
|
}
|
|
|
|
impl SingletonEntity for AIExecutionProfilesModel {}
|
|
|
|
#[cfg(test)]
|
|
#[path = "profiles_tests.rs"]
|
|
mod tests;
|