Complete local-first content migration slice

This commit is contained in:
2026-08-05 16:24:04 -05:00
parent 993abb96df
commit f850bae77c
60 changed files with 2755 additions and 2729 deletions
@@ -69,6 +69,9 @@ fn assert_context_window_limit_for_request(
app.add_singleton_model(|_| NetworkStatus::new());
app.add_singleton_model(UserWorkspaces::default_mock);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(None, None, ctx)
});
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(SyncQueue::mock);
app.add_singleton_model(UpdateManager::mock);
@@ -81,12 +84,12 @@ fn assert_context_window_limit_for_request(
let profile_model_id = model.id.clone();
let available_model_id = profile_model_id.clone();
llm_preferences.update(&mut app, move |preferences, ctx| {
preferences.update_feature_model_choices(
Ok(ModelsByFeature {
preferences.set_models_by_feature_for_test(
ModelsByFeature {
agent_mode: AvailableLLMs::new(available_model_id, [model], None)
.expect("test model should create available LLMs"),
..Default::default()
}),
},
ctx,
);
});
+118 -302
View File
@@ -3,27 +3,20 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use galaxy_core::channel::ChannelState;
use galaxy_core::user_preferences::GetUserPreferences;
use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::{
AIExecutionProfile, ActionPermission, CloudAIExecutionProfileModel, WriteToPtyPermission,
};
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::model::generic_string_model::GenericStringObjectId;
use crate::cloud_object::model::persistence::{CloudModelEvent, UpdateSource};
use crate::cloud_object::{CloudObject as _, GenericStringObjectFormat, JsonObjectType};
use crate::drive::CloudObjectTypeAndId;
use crate::server::cloud_objects::update_manager::UpdateManager;
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::workspaces::user_workspaces::UserWorkspaces;
use crate::{send_telemetry_from_ctx, CloudModel, LaunchMode, TelemetryEvent};
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.
@@ -58,7 +51,7 @@ impl AIExecutionProfileInfo {
&self.id
}
/// The Warp Drive sync ID of this profile, if it has been synced.
/// 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
@@ -109,15 +102,9 @@ impl DefaultProfileState {
}
pub struct AIExecutionProfilesModel {
/// The default profile can be in one of three states:
/// - Unsynced: No cloud object backing the profile. It's purely local read-only data.
/// - Synced: A cloud object backs the profile, created either when edited locally or received from cloud.
/// - CLI: When running in CLI mode, a more permissive default profile that doesn't sync to cloud.
///
/// Note that the default_profile_state becomes synced either (1) when an edit happens on
/// this client or (2) when a default profile is received from the cloud model (say, it was
/// created for the user on another client). Once the profile is synced, it's never unsynced
/// again. CLI profiles are currently never synced.
/// 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.
@@ -136,33 +123,29 @@ impl AIExecutionProfilesModel {
let profile_id_to_sync_id: HashMap<ClientProfileId, SyncId> = HashMap::new();
let active_profiles_per_session: HashMap<EntityId, ClientProfileId> = HashMap::new();
} else {
let cloud_model = CloudModel::handle(ctx).as_ref(ctx);
let all_profiles_from_cloud: Vec<&super::CloudAIExecutionProfile> = cloud_model
.get_all_objects_of_type::<GenericStringObjectId, CloudAIExecutionProfileModel>()
.filter(|p| Self::is_owned_by_current_user(p, ctx))
.collect();
let all_local_profiles = LocalObjectRepository::as_ref(ctx).execution_profiles(ctx);
let default_profile_from_cloud: Option<&super::CloudAIExecutionProfile> = all_profiles_from_cloud
let default_local_profile = all_local_profiles
.iter()
.find(|obj| obj.model().string_model.is_default_profile)
.copied();
.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 the cloud
for cloud_profile in all_profiles_from_cloud.iter().filter(|p| !p.model().string_model.is_default_profile) {
// 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, cloud_profile.id);
profile_id_to_sync_id.insert(profile_id, local_profile.id);
}
let default_profile_state = match launch_mode {
// The TUI front-end is an app-style client, so it shares the
// GUI app's cloud-synced default execution profile.
// The TUI front-end shares the GUI app's locally persisted
// default execution profile.
LaunchMode::App { .. }
| LaunchMode::Test { .. }
| LaunchMode::Tui { .. } => {
match default_profile_from_cloud {
match default_local_profile {
Some(p) => {
let execution_profile_id = ClientProfileId::new();
profile_id_to_sync_id.insert(execution_profile_id, p.id);
@@ -195,13 +178,11 @@ impl AIExecutionProfilesModel {
}
}
// We have to listen for changes to AIExecutionProfiles for a few reasons:
// (1) In case the default profile is unsynced AND a default profile arrives from the cloud
// (2) Let views subscribed to us know whenever a backing profile changes.
// (3) Keep profile_id_to_sync_id map up to date when profiles are created/deleted remotely
// 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(&CloudModel::handle(ctx), |me, _, event, ctx| {
me.handle_cloud_model_event(event, ctx);
ctx.subscribe_to_model(&LocalObjectRepository::handle(ctx), |me, _, event, ctx| {
me.handle_local_repository_event(event, ctx);
});
}
@@ -212,32 +193,6 @@ impl AIExecutionProfilesModel {
},
);
// In dev, it's possible the SQLite data read in for the default profile actually comes from a different environment
// (say, we switch between local and staging servers). When that happens the default profile starts as synced but
// then the profile is deleted when initial load returns. To fix that, we listen for the deletion of the default
// profile and reset the model state when that happens.
if ChannelState::channel().is_dogfood() {
if let DefaultProfileState::Synced { id } = &default_profile_state {
let sync_id_of_default_profile = *profile_id_to_sync_id
.get(id)
.expect("default profile is synced but no sync id found");
ctx.subscribe_to_model(&CloudModel::handle(ctx), move |me, _, event, _| {
if let CloudModelEvent::ObjectDeleted {
type_and_id: CloudObjectTypeAndId::GenericStringObject {
id: deleted_sync_id,
..
},
..
} = event {
if *deleted_sync_id == sync_id_of_default_profile {
log::info!("Resetting execution profile model because default profile was deleted.");
me.reset();
}
}
});
}
}
log::info!("Initialized execution profile model with state: {default_profile_state}",);
let mut model = Self {
@@ -250,15 +205,6 @@ impl AIExecutionProfilesModel {
model
}
fn is_owned_by_current_user(
profile: &super::CloudAIExecutionProfile,
ctx: &AppContext,
) -> bool {
UserWorkspaces::as_ref(ctx)
.personal_drive(ctx)
.is_some_and(|owner| profile.permissions().owner == owner)
}
/// 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
@@ -295,25 +241,17 @@ impl AIExecutionProfilesModel {
pub fn create_profile(&mut self, ctx: &mut ModelContext<Self>) -> Option<ClientProfileId> {
let profile_id = ClientProfileId::new();
let Some(owner) = UserWorkspaces::as_ref(ctx).personal_drive(ctx) else {
log::error!("Failed to create AI execution profile: personal drive not available");
return None;
};
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 update_manager = UpdateManager::handle(ctx);
let client_id = ClientId::default();
update_manager.update(ctx, |update_manager, ctx| {
update_manager.create_ai_execution_profile(new_profile, client_id, owner, ctx);
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);
});
self.profile_id_to_sync_id
.insert(profile_id, SyncId::ClientId(client_id));
send_telemetry_from_ctx!(TelemetryEvent::AIExecutionProfileCreated, ctx);
ctx.emit(AIExecutionProfilesModelEvent::ProfileCreated);
@@ -337,16 +275,15 @@ impl AIExecutionProfilesModel {
self.profile_id_to_sync_id.remove(&profile_id);
let update_manager = UpdateManager::handle(ctx);
update_manager.update(ctx, |update_manager, ctx| {
update_manager.delete_ai_execution_profile(sync_id, ctx);
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);
}
// On logout, we need to clear any existing profile state.
/// Resets the in-memory profile index to conservative defaults.
pub fn reset(&mut self) {
self.default_profile_state = DefaultProfileState::Unsynced {
id: ClientProfileId::new(),
@@ -396,11 +333,8 @@ impl AIExecutionProfilesModel {
data: AIExecutionProfile::default(),
};
};
let cloud_model = CloudModel::as_ref(ctx);
let data = cloud_model
.get_object_of_type::<GenericStringObjectId, CloudAIExecutionProfileModel>(
sync_id,
)
let data = LocalObjectRepository::as_ref(ctx)
.execution_profile(sync_id, ctx)
.map(|o| o.model().string_model.clone())
.unwrap_or_default();
@@ -454,9 +388,8 @@ impl AIExecutionProfilesModel {
// Handle all synced profiles (default and non-default)
let sync_id = self.profile_id_to_sync_id.get(&profile_id)?;
let cloud_model = CloudModel::as_ref(ctx);
let data = cloud_model
.get_object_of_type::<GenericStringObjectId, CloudAIExecutionProfileModel>(sync_id)
let data = LocalObjectRepository::as_ref(ctx)
.execution_profile(sync_id, ctx)
.map(|o| o.model().string_model.clone())
.unwrap_or_default();
@@ -1241,13 +1174,13 @@ impl AIExecutionProfilesModel {
);
}
/// `edit_profile_internal` edits an AIExecutionProfile and upserts the changed profile to the cloud
/// 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 syncs the changes to the cloud, and otherwise exits early to prevent excessive cloud operations if no changes occurred.
/// * `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 synced),
/// 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(
@@ -1264,54 +1197,24 @@ impl AIExecutionProfilesModel {
}
}
// Case: this might be an edit to a not-yet-created default profile object. If so, we need to create
// a cloud object to back the default profile.
// 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();
// If the edit function didn't make any changes to the profile, it's still the default profile, so we don't need to sync it
let value_changed = edit_fn(&mut new_profile);
if !value_changed {
return false;
}
if let Some(owner) = UserWorkspaces::as_ref(ctx).personal_drive(ctx) {
let update_manager = UpdateManager::handle(ctx);
let client_id = ClientId::default();
update_manager.update(ctx, |update_manager, ctx| {
update_manager.create_ai_execution_profile(
new_profile,
client_id,
owner,
ctx,
);
});
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);
});
// For forever on, the default profile state is synced.
let sync_id = SyncId::ClientId(client_id);
self.default_profile_state = DefaultProfileState::Synced { id: profile_id };
self.profile_id_to_sync_id.insert(profile_id, sync_id);
log::info!(
"Creating a cloud object for the default execution profile: {profile_id:?}"
);
} else {
// The user isn't logged in yet (or personal drive isn't available),
// so we can't create a cloud object. Persist the edit locally on the
// Unsynced profile so it isn't silently dropped; it will be promoted
// to a Synced cloud object the next time an edit runs after login.
// Without this, onboarding-driven edits (e.g. autonomy permissions
// written by `apply_agent_settings`) disappear when onboarding is
// completed before login.
self.default_profile_state = DefaultProfileState::Unsynced {
id: profile_id,
profile: new_profile,
};
log::info!(
"Updated local unsynced default execution profile (no personal drive yet): {profile_id:?}"
);
}
log::info!("Persisted the default execution profile locally: {profile_id:?}");
ctx.emit(AIExecutionProfilesModelEvent::ProfileUpdated(profile_id));
return true;
}
@@ -1319,19 +1222,15 @@ impl AIExecutionProfilesModel {
let mut value_changed = false;
if let Some(sync_id) = self.profile_id_to_sync_id.get(&profile_id) {
let cloud_model = CloudModel::as_ref(ctx);
if let Some(object) = cloud_model
.get_object_of_type::<GenericStringObjectId, CloudAIExecutionProfileModel>(sync_id)
if let Some(object) = LocalObjectRepository::as_ref(ctx).execution_profile(sync_id, ctx)
{
let mut data = object.model().string_model.clone();
// If the edit function didn't make any changes to the profile, we should exit early
value_changed = edit_fn(&mut data);
if !value_changed {
return false;
}
let update_manager = UpdateManager::handle(ctx);
update_manager.update(ctx, |update_manager, ctx| {
update_manager.update_ai_execution_profile(data, *sync_id, None, ctx);
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.update_execution_profile(*sync_id, data, ctx);
});
log::info!("Edited execution profile with id: {profile_id:?}");
@@ -1343,117 +1242,73 @@ impl AIExecutionProfilesModel {
value_changed
}
/// Handle CloudModel events to keep the profile_id_to_sync_id map and default profile state up to date.
fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ModelContext<Self>) {
fn handle_local_repository_event(
&mut self,
event: &LocalObjectRepositoryEvent,
ctx: &mut ModelContext<Self>,
) {
match event {
CloudModelEvent::ObjectCreated {
type_and_id:
CloudObjectTypeAndId::GenericStringObject {
object_type:
GenericStringObjectFormat::Json(JsonObjectType::AIExecutionProfile),
id,
},
} => {
self.handle_ai_execution_profile_created(*id, ctx);
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);
}
}
CloudModelEvent::ObjectDeleted {
type_and_id:
CloudObjectTypeAndId::GenericStringObject {
object_type:
GenericStringObjectFormat::Json(JsonObjectType::AIExecutionProfile),
id,
},
folder_id: _,
} => {
self.handle_ai_execution_profile_deleted(*id, ctx);
LocalObjectRepositoryEvent::ExecutionProfiles { id: None } => {
self.reconcile_with_local_repository(ctx);
}
CloudModelEvent::ObjectDeleted {
type_and_id:
CloudObjectTypeAndId::GenericStringObject {
object_type: GenericStringObjectFormat::Json(JsonObjectType::MCPServer),
id: _,
},
folder_id: _,
} => {
// Legacy MCP servers are converted to templatable on startup;
// no action needed when a legacy cloud object is deleted.
}
CloudModelEvent::ObjectUpdated {
type_and_id:
CloudObjectTypeAndId::GenericStringObject {
object_type:
GenericStringObjectFormat::Json(JsonObjectType::AIExecutionProfile),
id,
},
source,
} => {
self.handle_ai_execution_profile_updated(*id, *source, ctx);
}
CloudModelEvent::InitialLoadCompleted => {
self.reconcile_with_cloud_state_after_initial_load(ctx);
}
_ => {}
LocalObjectRepositoryEvent::Rules
| LocalObjectRepositoryEvent::Notebooks { .. }
| LocalObjectRepositoryEvent::Workflows { .. } => {}
}
}
/// Reconcile model state with `CloudModel` once an initial bulk load
/// completes.
///
/// The initial load path (`update_objects_from_initial_load`) inserts
/// cloud objects into `CloudModel` *without* emitting per-object
/// `ObjectCreated` events — it emits a single
/// `CloudModelEvent::InitialLoadCompleted` afterward instead. That means
/// our normal `handle_ai_execution_profile_created` handler never fires
/// for execution profiles that arrived via initial load, and the model
/// stays in `Unsynced` even though the user already has a cloud default
/// profile.
///
/// Without this reconciliation, a subsequent edit from `apply_agent_settings`
/// (onboarding) would hit the `Unsynced` branch of `edit_profile_internal`
/// and *create a duplicate* cloud default profile rather than editing the
/// existing one. That manifests as the default profile showing neither
/// the user's prior cloud values nor the onboarding choices — because the
/// UI ends up reading a fresh client-side default with only a few fields
/// touched.
fn reconcile_with_cloud_state_after_initial_load(&mut self, ctx: &mut ModelContext<Self>) {
let cloud_model = CloudModel::as_ref(ctx);
let all_profiles: Vec<(SyncId, bool)> = cloud_model
.get_all_objects_of_type::<GenericStringObjectId, CloudAIExecutionProfileModel>()
.filter(|o| Self::is_owned_by_current_user(o, ctx))
.map(|o| (o.id, o.model().string_model.is_default_profile))
.collect();
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);
// Transition Unsynced -> Synced if cloud has a default profile.
if let DefaultProfileState::Unsynced { id, .. } = self.default_profile_state {
if let Some((sync_id, _)) = all_profiles.iter().find(|(_, is_default)| *is_default) {
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);
log::info!(
"Reconciled default execution profile with cloud after initial load: \
profile_id={id:?}, sync_id={sync_id:?}"
);
self.profile_id_to_sync_id.insert(id, sync_id);
ctx.emit(AIExecutionProfilesModelEvent::ProfileUpdated(id));
}
}
// Register non-default profiles from cloud that we aren't
// already tracking so later edits find their backing sync_id.
let mut added_non_default = false;
for (sync_id, is_default) in all_profiles {
if is_default {
continue;
}
if !self.profile_id_to_sync_id.values().any(|s| *s == sync_id) {
let profile_id = ClientProfileId::new();
self.profile_id_to_sync_id.insert(profile_id, sync_id);
log::info!(
"Registered existing cloud execution profile after initial load: {sync_id:?}"
);
added_non_default = true;
}
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);
}
if added_non_default {
ctx.emit(AIExecutionProfilesModelEvent::ProfileCreated);
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);
}
}
}
@@ -1473,62 +1328,43 @@ impl AIExecutionProfilesModel {
}
}
/// Handle a newly created AI execution profile from the cloud.
fn handle_ai_execution_profile_created(
&mut self,
sync_id: SyncId,
ctx: &mut ModelContext<Self>,
) {
let cloud_model = CloudModel::as_ref(ctx);
let Some(object) = cloud_model
.get_object_of_type::<GenericStringObjectId, CloudAIExecutionProfileModel>(&sync_id)
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 ObjectCreated event for AI execution profile but object not found in CloudModel: {sync_id:?}");
log::warn!(
"Received an execution profile update but no local object was found: {sync_id:?}"
);
return;
};
if !Self::is_owned_by_current_user(object, ctx) {
log::info!("Ignoring non-owned execution profile from cloud: {sync_id:?}");
if let Some(profile_id) = self.get_profile_id_by_sync_id(&sync_id) {
ctx.emit(AIExecutionProfilesModelEvent::ProfileUpdated(profile_id));
return;
}
// Check if this is the default profile
if object.model().string_model.is_default_profile {
// Don't add the cloud default profile if we're in CLI mode
if matches!(self.default_profile_state, DefaultProfileState::Cli { .. }) {
log::info!("Ignoring cloud default profile in CLI mode: {sync_id:?}");
log::info!("Ignoring the persisted default profile in CLI mode: {sync_id:?}");
return;
}
// If we're in an unsynced state, transition to synced
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!(
"Received default execution profile from cloud. Marking profile as synced: {sync_id:?}"
);
log::info!("Adopted the persisted default execution profile: {sync_id:?}");
ctx.emit(AIExecutionProfilesModelEvent::ProfileUpdated(id));
}
return;
}
// For non-default profiles, add to the map if not already present
let profile_exists = self.profile_id_to_sync_id.values().any(|id| *id == sync_id);
if !profile_exists {
let profile_id = ClientProfileId::new();
self.profile_id_to_sync_id.insert(profile_id, sync_id);
log::info!("Added new execution profile to map: {sync_id:?}");
ctx.emit(AIExecutionProfilesModelEvent::ProfileCreated);
}
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);
}
/// Handle a deleted AI execution profile from the cloud.
fn handle_ai_execution_profile_deleted(
&mut self,
sync_id: SyncId,
ctx: &mut ModelContext<Self>,
) {
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
@@ -1548,10 +1384,11 @@ impl AIExecutionProfilesModel {
self.active_profiles_per_session
.retain(|_, active_id| *active_id != profile_id);
// If the default profile was deleted, transition back to unsynced state
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 from cloud. Transitioning to unsynced state: {sync_id:?}");
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 {
@@ -1561,32 +1398,11 @@ impl AIExecutionProfilesModel {
};
}
log::info!("Removed execution profile from map: {sync_id:?}");
log::info!("Removed local execution profile from the client map: {sync_id:?}");
ctx.emit(AIExecutionProfilesModelEvent::ProfileDeleted);
}
}
/// Handle an updated AI execution profile from the cloud.
fn handle_ai_execution_profile_updated(
&mut self,
sync_id: SyncId,
source: UpdateSource,
ctx: &mut ModelContext<Self>,
) {
// Only notify about updates from the server (not local updates, which we already handle)
if source != UpdateSource::Server {
return;
}
// Find the client profile ID for this sync ID
let profile_id = self.get_profile_id_by_sync_id(&sync_id);
if let Some(profile_id) = profile_id {
log::info!("Execution profile updated from server: {sync_id:?}");
ctx.emit(AIExecutionProfilesModelEvent::ProfileUpdated(profile_id));
}
}
/// 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);
+46 -16
View File
@@ -84,20 +84,21 @@ fn install_singletons(app: &mut App, auth_state: AuthStateProvider) {
app.add_singleton_model(TeamTesterStatus::mock);
app.add_singleton_model(UpdateManager::mock);
app.add_singleton_model(CloudModel::mock);
app.add_singleton_model(|ctx| {
crate::local_object_repository::LocalObjectRepository::new(
None,
Some(Owner::mock_current_user()),
ctx,
)
});
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
app.add_singleton_model(PrivacySettings::mock);
app.add_singleton_model(UserWorkspaces::default_mock);
}
/// Regression test for the onboarding autonomy bug where
/// `edit_profile_internal` would silently drop edits made to an `Unsynced`
/// default profile whenever `personal_drive` returned `None` (logged-out
/// users). `apply_agent_settings` calls `set_*` on the default profile the
/// moment onboarding completes, which can happen before the user logs in
/// (e.g. `LoginSlideEvent::LoginLaterConfirmed`), so those edits must
/// persist on the local `Unsynced` state rather than being dropped.
/// A fresh, logged-out install persists its default profile on first edit.
#[test]
fn edits_persist_on_unsynced_default_profile_when_logged_out() {
fn edits_persist_default_profile_locally_when_logged_out() {
App::test((), |mut app| async move {
install_singletons(&mut app, AuthStateProvider::new_logged_out_for_test());
let profile_model = app.add_singleton_model(|ctx| {
@@ -118,21 +119,50 @@ fn edits_persist_on_unsynced_default_profile_when_logged_out() {
);
});
// Apply the edit that onboarding would make for the Full autonomy
// preset. Before the fix, this call no-ops because
// `personal_drive` is `None` while the profile is `Unsynced` — the
// `set_apply_code_diffs` value was cloned, mutated, then dropped
// without being written back to `default_profile_state`.
profile_model.update(&mut app, |model, ctx| {
model.set_apply_code_diffs(default_profile_id, &ActionPermission::AlwaysAllow, ctx);
});
profile_model.read(&app, |model, ctx| {
let default_profile = model.default_profile(ctx);
assert_eq!(
model.default_profile(ctx).data().apply_code_diffs,
default_profile.data().apply_code_diffs,
ActionPermission::AlwaysAllow,
"edit was dropped: default profile still has the baseline \
apply_code_diffs value after an edit made while logged out",
"the local default profile should retain the edit",
);
let persisted_id = default_profile
.sync_id()
.expect("the first edit should persist the default profile");
assert!(
crate::local_object_repository::LocalObjectRepository::as_ref(ctx)
.execution_profile(&persisted_id, ctx)
.is_some()
);
});
})
}
#[test]
fn creates_custom_profile_locally_when_logged_out() {
App::test((), |mut app| async move {
install_singletons(&mut app, AuthStateProvider::new_logged_out_for_test());
let profile_model = app.add_singleton_model(|ctx| {
AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx)
});
let profile_id = profile_model
.update(&mut app, |model, ctx| model.create_profile(ctx))
.expect("custom profile should not require an account");
profile_model.read(&app, |model, ctx| {
let profile = model
.get_profile_by_id(profile_id, ctx)
.expect("created profile");
let persisted_id = profile.sync_id().expect("persisted profile ID");
assert!(
crate::local_object_repository::LocalObjectRepository::as_ref(ctx)
.execution_profile(&persisted_id, ctx)
.is_some()
);
});
})