use std::collections::HashSet; use std::ffi::OsString; use std::path::Path; use std::str::FromStr; use std::sync::mpsc::SyncSender; use std::sync::Once; use std::{ collections::{HashMap, VecDeque}, convert::TryInto, fs, path::PathBuf, sync::Arc, thread, }; use ai::project_context::model::ProjectRulePath; use anyhow::{anyhow, bail, Context, Result}; use chrono::{DateTime, Utc}; use diesel::{ connection::{DefaultLoadingMode, SimpleConnection}, result::Error, sqlite::SqliteConnection, BelongingToDsl, BoolExpressionMethods, Connection, ExpressionMethods, GroupedBy, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper, }; use diesel_migrations::MigrationHarness; use galaxy_graphql::scalars::time::ServerTimestamp; use galaxyui::platform::FullscreenState; use galaxyui::{AppContext, SingletonEntity}; use itertools::Itertools; use libsqlite3_sys as sqlite3; use num_traits::FromPrimitive; use pathfinder_geometry::{rect::RectF, vector::Vector2F}; use persistence::model::AMBIENT_AGENT_PANE_KIND; use uuid::Uuid; use super::agent::{delete_agent_conversations, upsert_agent_conversation}; use super::block_list::{ delete_ai_conversation, delete_blocks, save_block, update_block_agent_view_visibility, upsert_ai_query, }; use super::model::{ self, ActiveMCPServer, CurrentUserInformation, MCPEnvironmentVariables, NewActiveMCPServer, NewApp, NewCommand, NewFolder, NewNotebook, NewServerExperiment, NewTab, NewTeam, NewWindow, NewWorkspace, NewWorkspaceMetadata, NewWorkspaceTeam, ObjectMetadata, ObjectPermissions, Project, Tab, Window, WorkspaceMetadata as WorkspaceMetadataModel, AI_DOCUMENT_PANE_KIND, AI_FACT_PANE_KIND, CODE_PANE_KIND, ENV_VAR_COLLECTION_PANE_KIND, EXECUTION_PROFILE_EDITOR_PANE_KIND, MCP_SERVER_PANE_KIND, NOTEBOOK_PANE_KIND, SETTINGS_PANE_KIND, TERMINAL_PANE_KIND, WELCOME_PANE_KIND, WORKFLOW_PANE_KIND, }; use super::schema; use super::{ BlockCompleted, FinishedCommandMetadata, ModelEvent, PersistedData, StartedCommandMetadata, WriterHandles, }; use crate::ai::agent::conversation::AIConversationId; use crate::ai::ambient_agents::scheduled::{ CloudScheduledAmbientAgent, CloudScheduledAmbientAgentModel, }; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::cloud_environments::{ CloudAmbientAgentEnvironment, CloudAmbientAgentEnvironmentModel, }; use crate::ai::document::ai_document_model::AIDocumentId; use crate::ai::execution_profiles::{CloudAIExecutionProfile, CloudAIExecutionProfileModel}; use crate::ai::facts::{CloudAIFact, CloudAIFactModel}; use crate::ai::mcp::templatable::{CloudTemplatableMCPServer, CloudTemplatableMCPServerModel}; use crate::ai::mcp::templatable_installation::VariableValue; use crate::ai::mcp::{ CloudMCPServer, CloudMCPServerModel, TemplatableMCPServer, TemplatableMCPServerInstallation, }; use crate::ai::persisted_workspace::EnablementState; use crate::app_state::{ AIFactPaneSnapshot, AmbientAgentPaneSnapshot, CodeReviewPaneSnapshot, EnvVarCollectionPaneSnapshot, LeftPanelSnapshot, RightPanelSnapshot, SettingsPaneSnapshot, WorkflowPaneSnapshot, }; 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::generic_string_model::{CloudStringObject, GenericStringObjectId}; use crate::cloud_object::{ CloudObject, JsonObjectType, ObjectIdType, ObjectType, Owner, RevisionAndLastEditor, GENERIC_STRING_OBJECT_PREFIX, JSON_OBJECT_PREFIX, }; use crate::code::editor_management::CodeSource; use crate::drive::folders::{CloudFolder, CloudFolderModel, FolderId}; use crate::drive::OpenGalaxyDriveObjectSettings; use crate::env_vars::{CloudEnvVarCollection, CloudEnvVarCollectionModel}; use crate::features::FeatureFlag; use crate::notebooks::{CloudNotebook, NotebookId}; use crate::persistence::agent::read_agent_conversations; use crate::persistence::block_list::{get_all_restored_blocks, read_ai_queries}; use crate::persistence::model::{ NewCloudObjectsRefresh, NewGenericStringObject, NewPersistedObjectAction, NewTeamSettings, ProjectRules, UserProfile, CODE_REVIEW_PANE_KIND, GET_STARTED_PANE_KIND, }; use crate::server::experiments::ServerExperiment; use crate::server::ids::{ClientId, HashableId, ServerId, SyncId, ToServerId}; use crate::server::telemetry::TelemetryEvent; use crate::settings::cloud_preferences::{CloudPreference, CloudPreferenceModel}; use crate::settings_view::SettingsSection; use crate::suggestions::ignored_suggestions_model::SuggestionType; use crate::tab::SelectedTabColor; use crate::terminal::history::PersistedCommand; use crate::terminal::ShellLaunchData; use crate::themes::theme::AnsiColorIdentifier; use crate::workflows::workflow_enum::{CloudWorkflowEnum, CloudWorkflowEnumModel}; use crate::workflows::{CloudWorkflow, WorkflowId}; use crate::workspaces::team::Team as TeamMetadata; use crate::workspaces::workspace::Workspace as WorkspaceMetadata; use crate::workspaces::workspace::WorkspaceUid; use crate::{ app_state::{ AppState, BranchSnapshot, CodePaneSnapShot, CodePaneTabSnapshot, LeafContents, LeafSnapshot, NotebookPaneSnapshot, PaneFlex, PaneNodeSnapshot, SplitDirection, TabSnapshot, TerminalPaneSnapshot, WindowSnapshot, }, workspaces::user_profiles::UserProfileWithUID, }; use crate::{ cloud_object::{CloudObjectMetadata, NumInFlightRequests, Revision, ServerCreationInfo}, notebooks::CloudNotebookModel, }; use crate::{ cloud_object::{CloudObjectPermissions, CloudObjectStatuses, CloudObjectSyncStatus}, workflows::CloudWorkflowModel, }; use crate::{report_error, report_if_error, safe_info, send_telemetry_from_app_ctx}; use lsp::supported_servers::LSPServerType; diesel::define_sql_function! { fn json_extract(target: diesel::sql_types::Text, path: diesel::sql_types::Text) -> diesel::sql_types::Text; } // Choose a power of 2 that seems to be a reasonable upper bound for how many // events to queue. const CHANNEL_SIZE: usize = 1024; const COMMANDS_COUNT_LIMIT: i64 = 10000; use galaxy_server_client::persistence::{upsert_cloud_object, CloudObjectId}; const WARP_SQLITE_FILE_NAME: &str = "galaxy.sqlite"; const LEGACY_SQLITE_FILE_NAME: &str = "warp.sqlite"; /// When delete a cloud object, this callback is used to delete the cloud /// object. It takes the id of the cloud object to delete as a parameter. /// The supplied conn has already started a transaction. type DeleteCloudObjectFn = Box Result<(), Error>>; /// Runs any migrations and creates the Sqlite database if it doesn't exist. /// Reads from the sqlite database to get the app state for session restoration. /// Starts a writer thread that listens for ModelEvents and processes them. pub fn initialize(ctx: &mut AppContext) -> (Option, Option) { unsafe { // Set up logging before any SQLite calls. init_logging(); } let database_path = database_file_path(); match init_db() { Ok(mut conn) => { let user_uid = AuthStateProvider::as_ref(ctx).get().user_id(); let app_state = match read_sqlite_data(&mut conn, user_uid) { Ok(app_state) => { log::info!( "[session-restore] read_sqlite_data: windows={} conversations={}", app_state.app_state.windows.len(), app_state.multi_agent_conversations.len(), ); Some(app_state) } Err(err) => { send_telemetry_from_app_ctx!( TelemetryEvent::DatabaseReadError(err.to_string()), ctx ); report_error!(anyhow::Error::new(err).context("Failed to read app state")); None } }; let writer_handles = match start_writer(conn, database_path) { Ok(writer_handles) => Some(writer_handles), Err(err) => { send_telemetry_from_app_ctx!( TelemetryEvent::DatabaseWriteError(err.to_string()), ctx ); report_db_error("starting writer", err, &database_file_path()); None } }; (app_state, writer_handles) } Err(err) => { send_telemetry_from_app_ctx!( TelemetryEvent::DatabaseStartUpError(err.to_string()), ctx ); report_db_error("initialization", err, &database_path); (None, None) } } } /// Returns a read-only connection to the sqlite database. /// We want only one write connection to exist and use event processing to write any data needed. pub fn establish_ro_connection(database_url: &str) -> Result { establish_connection(database_url, true) } fn establish_connection(database_url: &str, read_only: bool) -> Result { let full_database_url = if read_only { &format!("file:{database_url}?mode=ro") } else { database_url }; let mut conn = SqliteConnection::establish(full_database_url)?; conn.batch_execute( r#" PRAGMA foreign_keys = ON; -- enforce foreign key constraints PRAGMA busy_timeout = 1000; -- sleep for up to 1s if the database is busy "#, )?; // Enable WAL mode, checkpointing whenever the log is at least 500 pages long (in theory, // around 2MB). In addition, SQLite will automatically checkpoint when the app closes its // database connection. // The auto-checkpoint interval is lowered from the default of 1000 because all writes // already run in a background thread and can afford to checkpoint slightly more often. // At the default value, the WAL can grow larger than a typical database (for our usage). conn.batch_execute( r#" PRAGMA journal_mode=WAL; PRAGMA wal_autocheckpoint=500; "#, ) .context("Failed to enable WAL")?; Ok(conn) } /// Set up SQLite [error logging](https://www.sqlite.org/errlog.html) /// /// ## Safety /// Setting up SQLite logging is not thread-safe. No other SQLite calls may be made while this /// function is running. unsafe fn init_logging() { use std::ffi::{c_char, c_int, c_void, CStr}; use std::panic; use std::ptr; extern "C-unwind" fn log_callback(_data: *mut c_void, err_code: c_int, msg: *const c_char) { // `err_code` is an extended error code (https://www.sqlite.org/rescode.html#primary_result_codes_versus_extended_result_codes). // In general, the least-significant byte of an extended error code is the primary error // code it belongs to. Each primary error code can also be used where an extended error // code is expected (for example, `SQLITE_SCHEMA` has no extended error codes). let primary_error_code = err_code & 0xFF; let level = match (primary_error_code, err_code) { // This usually means that a schema change invalidated a prepared statement. (sqlite3::SQLITE_SCHEMA, _) => log::Level::Debug, // These are used with sqlite3_log, in extensions. (sqlite3::SQLITE_NOTICE | sqlite3::SQLITE_WARNING, _) => log::Level::Warn, // According to the docs, this error means that the database file was moved (or deleted), // so SQLite can't safely modify it and the rollback journal: // https://www.sqlite.org/rescode.html#readonly_dbmoved // This is mostly outside of Warp's control (e.g. the user or some system program is // moving around files in the user data directory), so downgrade to a warning. (_, sqlite3::SQLITE_READONLY_DBMOVED) => log::Level::Warn, _ => log::Level::Error, }; // Safety: the message pointer came from the SQLite library, which promises that it's a // valid C string pointer. let msg = unsafe { CStr::from_ptr(msg) }; let err_message = String::from_utf8_lossy(msg.to_bytes()); // Sentry shouldn't panic, but to be safe, make sure we don't unwind across the FFI // boundary. let _ = panic::catch_unwind(|| { // We report SQLite errors to Sentry in a more-structured format so that they have // better grouping (all are under the same Sentry issue, with details for the specific // error kind). Warning and debug SQLite messages are logged - with the default // sentry_log configuration, warnings are added as breadcrumbs to other events and // debug messages are ignored. // In local builds without crash reporting, all SQLite messages get logged locally. #[cfg(feature = "crash_reporting")] if level == log::Level::Error { sentry::with_scope( |scope| { let mut context = std::collections::BTreeMap::new(); context.insert("message".to_string(), err_message.into()); context.insert("code".to_string(), err_code.into()); context.insert( "code_description".to_string(), sqlite3::code_to_str(err_code).into(), ); scope.set_context("sqlite", sentry::protocol::Context::Other(context)); }, || { sentry::capture_message( "Sqlite Error", sentry_log::convert_log_level(level), ) }, ); return; } log::log!( level, "SQLite error {} ({}): {}", err_code, sqlite3::code_to_str(err_code), err_message ); }); } static INIT: Once = Once::new(); INIT.call_once(|| { let null: *const c_void = ptr::null(); // Diesel doesn't expose SQLite's logging/tracing APIs, but the FFI bindings do. let status = sqlite3::sqlite3_config( sqlite3::SQLITE_CONFIG_LOG, log_callback as extern "C-unwind" fn(_, _, _), null, ); if status != sqlite3::SQLITE_OK { log::error!( "Error setting up SQLite logging: {}", sqlite3::code_to_str(status) ); } }); } /// Determines the db path, establishes a connection and runs any migrations. pub(super) fn init_db() -> Result { // First, make sure the parent directory of the file exists, otherwise // we'll get an error if the file doesn't already exist. let db_path = database_file_path(); log::info!( "[session-restore] init_db: target={} exists={}", db_path.display(), db_path.exists() ); // If we fail to create the necessary directories, log a warning and // continue; we'll return a sqlite error if it actually fails to initialize // a database connection. if let Err(err) = std::fs::create_dir_all( db_path .parent() .expect("database file path should be absolute"), ) { log::warn!( "Encountered an error while creating parent directories for sqlite database: {err:#}" ); } // Migrate from legacy "warp.sqlite" filename to "galaxy.sqlite" (same directory). if !db_path.exists() { let legacy_same_dir = db_path .parent() .expect("database file path should be absolute") .join(LEGACY_SQLITE_FILE_NAME); if legacy_same_dir.exists() { match std::fs::rename(&legacy_same_dir, &db_path) { Ok(_) => { log::info!("Migrated legacy warp.sqlite to galaxy.sqlite"); let old_wal = legacy_same_dir.with_extension("sqlite-wal"); let old_shm = legacy_same_dir.with_extension("sqlite-shm"); let new_wal = db_path.with_extension("sqlite-wal"); let new_shm = db_path.with_extension("sqlite-shm"); let _ = std::fs::rename(&old_wal, &new_wal); let _ = std::fs::rename(&old_shm, &new_shm); } Err(err) => { log::warn!("Failed to migrate legacy warp.sqlite: {err:#}"); } } } } // Migrate from legacy "warp.sqlite" in state_dir (handles users who never // got the in-place rename above because they were on the old app ID path). if !db_path.exists() { let legacy_state_dir = galaxy_core::paths::state_dir().join(LEGACY_SQLITE_FILE_NAME); if legacy_state_dir.exists() { match std::fs::rename(&legacy_state_dir, &db_path) { Ok(_) => { log::info!( "Migrated legacy warp.sqlite from state_dir to {}", db_path.display() ); let old_wal = legacy_state_dir.with_extension("sqlite-wal"); let old_shm = legacy_state_dir.with_extension("sqlite-shm"); let new_wal = db_path.with_extension("sqlite-wal"); let new_shm = db_path.with_extension("sqlite-shm"); let _ = std::fs::rename(&old_wal, &new_wal); let _ = std::fs::rename(&old_shm, &new_shm); } Err(err) => { log::warn!("Failed to migrate legacy warp.sqlite from state_dir: {err:#}"); } } } } // Migrate old SQLite files into the secure application container. let old_db_path = galaxy_core::paths::state_dir().join(WARP_SQLITE_FILE_NAME); if old_db_path != db_path && old_db_path.exists() && !db_path.exists() { match std::fs::rename(&old_db_path, &db_path) { Ok(_) => { safe_info!( safe: ("Migrated SQLite database into application container"), full: ("Migrated SQLite database from `{}` to `{}`", old_db_path.display(), db_path.display()) ); // Also migrate the associated WAL and SHM files. let old_wal = old_db_path.with_extension("sqlite-wal"); let old_shm = old_db_path.with_extension("sqlite-shm"); let new_wal = db_path.with_extension("sqlite-wal"); let new_shm = db_path.with_extension("sqlite-shm"); if let Err(err) = std::fs::rename(&old_wal, &new_wal) { if err.kind() != std::io::ErrorKind::NotFound { report_error!(anyhow::Error::new(err) .context("Failed to migrate SQLite WAL into application container")); } } else { log::info!("Migrated SQLite WAL into application container"); } if let Err(err) = std::fs::rename(&old_shm, &new_shm) { if err.kind() != std::io::ErrorKind::NotFound { report_error!(anyhow::Error::new(err) .context("Failed to migrate SQLite SHM into application container")); } } else { log::info!("Migrated SQLite shared memory file into application container"); } } Err(err) => { report_error!(anyhow::Error::new(err) .context("Failed to migrate SQLite database into application container")); } } } setup_database(&database_file_path()) } /// Creates or connects to the database at `database_path` and runs any migrations. fn setup_database(database_path: &Path) -> Result { let db_url = database_path .to_str() .ok_or_else(|| anyhow!("Failed to convert db path to a string"))?; let mut conn = establish_connection(db_url, false)?; safe_info!( safe: ("Connecting to SQLite database"), full: ("Connecting to SQLite database at {db_url}") ); conn.run_pending_migrations(persistence::MIGRATIONS) .map_err(|e| anyhow!(e)) .context("Failed to perform migrations")?; Ok(conn) } /// The path at which the sqlite database is located. /// /// Integration tests that initialize the database with known data should use /// this function to determine where to create the database file. pub fn database_file_path() -> PathBuf { galaxy_core::paths::secure_state_dir() .unwrap_or_else(galaxy_core::paths::state_dir) .join(WARP_SQLITE_FILE_NAME) } pub(super) fn remove(sender: SyncSender) { // Instruct the writer thread to remove the database and pause processing // events. // Ideally, we'd drop any other events in the channel, but it's not worth the complexity right // now. Having the writer thread remove the database file prevents race conditions if the // thread is in the middle of another update. report_if_error!(sender .send(ModelEvent::PauseAndRemoveDatabase) .context("Error requesting database deletion")); } pub(super) fn reconstruct(sender: SyncSender) { report_if_error!(sender .send(ModelEvent::ReconstructAndResume) .context("Error resuming SQLite thread")); } fn reconstruct_database(path: &Path) -> Result { // If the DB still exists, logout might have failed. However, it's more likely that something // else wrote to it before the user logged back in. if std::fs::metadata(path).is_ok() { log::info!("Reconstructing database, but it already exists"); } // Always reinitialize DB - setup_database will only create it if it doesn't exist. setup_database(path) } fn start_writer(conn: SqliteConnection, database_path: PathBuf) -> Result { let (tx, rx) = std::sync::mpsc::sync_channel(CHANNEL_SIZE); let mut current_conn = conn; let handle = thread::Builder::new() .name("SQLite Writer".into()) .spawn(move || { let mut paused = false; loop { let events = match rx.recv() { Ok(event) => { // Wait for there to be at least one event, but collect any other pending // events too. This way, we can start dropping redundant events if the // writer thread is falling behind. let mut events = vec![event]; events.extend(rx.try_iter()); deduplicate_events(events) } Err(_) => { log::warn!( "SQLite event sender has closed; terminating SQLite writer thread." ); break; } }; for event in events { match event { ModelEvent::ReconstructAndResume => { match reconstruct_database(&database_path) { Ok(conn) => { current_conn = conn; paused = false; log::info!("SQLite Writer is resumed"); } Err(err) => { report_db_error("reconstruction", err, &database_path); } } } ModelEvent::PauseAndRemoveDatabase => { paused = true; log::info!("SQLite Writer is paused"); if let Err(err) = std::fs::remove_file(&database_path) { report_error!(anyhow::Error::new(err) .context("Error removing SQLite database")); } else { log::info!("Removed SQLite database"); } } ModelEvent::Terminate => { log::info!("Shutting down SQLite writer thread"); return; } event => { if paused { log::info!("Ignoring event as SQLite Writer is on pause"); continue; } if let Err(err) = handle_model_event(event, &mut current_conn) { report_db_error("Model", err, &database_path); } } } } } })?; Ok(WriterHandles { handle, sender: tx }) } /// Handles a single [`ModelEvent`] by dispatching to an event-specific function. /// Events which affect the SQLite writer event loop _must_ instead be handled by the event loop itself: /// * [`ModelEvent::PauseAndRemoveDatabase`] /// * [`ModelEvent::ReconstructAndResume`] /// * [`ModelEvent::Terminate`] fn handle_model_event(event: ModelEvent, connection: &mut SqliteConnection) -> anyhow::Result<()> { match event { ModelEvent::PauseAndRemoveDatabase | ModelEvent::ReconstructAndResume | ModelEvent::Terminate => { panic!("Unhandled control-flow event {event:?}"); } ModelEvent::SaveBlock(BlockCompleted { pane_id, block, is_local, }) => save_block(connection, pane_id, &block, is_local).context("error saving block"), ModelEvent::DeleteBlocks(pane_id) => { // Delete the blocks even if the setting is off so users can still remove // panes and have their data deleted locally. delete_blocks(connection, pane_id).context("error deleting blocks") } ModelEvent::Snapshot(app_state) => { save_app_state(connection, &app_state).context("error saving app state") } ModelEvent::UpsertWorkflows(workflows) => { upsert_workflows(connection, workflows).context("error saving workflows") } ModelEvent::UpsertNotebooks(notebooks) => { upsert_notebooks(connection, notebooks).context("error saving notebooks") } ModelEvent::UpsertFolders(folders) => { upsert_folders(connection, folders).context("error saving folders") } ModelEvent::UpsertGenericStringObject { object } => { upsert_generic_string_objects(connection, vec![object]) .context("error upserting generic object") } ModelEvent::UpsertGenericStringObjects(objects) => { upsert_generic_string_objects(connection, objects) .context("error upserting generic objects") } ModelEvent::UpsertNotebook { notebook } => { upsert_notebooks(connection, vec![notebook]).context("error upserting notebook") } ModelEvent::UpsertWorkflow { workflow } => { upsert_workflows(connection, vec![workflow]).context("error upserting workflow") } ModelEvent::UpsertFolder { folder } => { upsert_folders(connection, vec![folder]).context("error upserting folder") } ModelEvent::MarkObjectAsSynced { revision_and_editor, metadata_ts, hashed_sqlite_id, } => mark_object_as_synced( connection, hashed_sqlite_id, revision_and_editor, metadata_ts, ) .context("error marking object as synced"), ModelEvent::IncrementRetryCount(id) => { increment_retry_count(connection, id).context("error incrementing retry count") } ModelEvent::DeleteObjects { ids } => { delete_objects(connection, ids).context("error deleting objects") } ModelEvent::UpdateObjectAfterServerCreation { client_id, server_creation_info, } => update_object_after_server_creation(connection, client_id, server_creation_info) .context("error executing object creation succeeded callback"), ModelEvent::UpsertCodebaseIndexMetadata { index_metadata } => { save_codebase_index_metadata(connection, *index_metadata) .context("error upserting codebase index metadata") } ModelEvent::DeleteCodebaseIndexMetadata { repo_path } => { delete_codebase_index_metadata(connection, &repo_path) .context("error deleting codebase index metadata") } ModelEvent::UpsertProject { project } => { save_project(connection, project).context("error upserting project") } ModelEvent::DeleteProject { path } => { delete_project(connection, &path).context("error deleting project") } ModelEvent::UpsertWorkspace { workspace } => { save_workspace(connection, *workspace).context("error upserting workspace") } ModelEvent::UpsertWorkspaces { workspaces } => { save_workspaces(connection, workspaces).context("error upserting workspaces") } ModelEvent::SetCurrentWorkspace { workspace_uid } => { set_current_workspace(connection, workspace_uid) .context("error setting current workspace") } ModelEvent::UpdateObjectMetadata { id, metadata } => { update_object_metadata(connection, id, metadata).context("error updating metadata") } ModelEvent::InsertCommand { metadata } => { insert_command(connection, metadata).context("error inserting command") } ModelEvent::UpdateFinishedCommand { metadata } => { update_finished_command(connection, metadata).context("error updating finished command") } ModelEvent::UpsertUserProfiles { profiles } => { upsert_user_profiles(connection, profiles).context("error updating user profiles") } ModelEvent::ClearUserProfiles => { clear_user_profiles(connection).context("error clearing user profiles") } ModelEvent::RecordTimeOfNextRefresh { timestamp } => { record_time_of_next_refresh(connection, timestamp) .context("error marking object refresh as completed") } ModelEvent::InsertObjectAction { object_action } => { insert_object_action(connection, object_action).context("error inserting object action") } ModelEvent::SyncObjectActions { actions_to_sync: objects_to_sync, } => { sync_object_actions(connection, objects_to_sync).context("error syncing object actions") } ModelEvent::SaveExperiments { experiments } => { save_experiments(connection, experiments).context("error saving experiments") } ModelEvent::UpsertAIQuery { query } => { upsert_ai_query(connection, query).context("error upserting AI query") } ModelEvent::DeleteAIConversation { conversation_id } => { delete_ai_conversation(connection, &conversation_id) .context("error deleting AI conversation") } ModelEvent::UpdateMultiAgentConversation { conversation_id, updated_tasks, conversation_data, } => upsert_agent_conversation( connection, &conversation_id, &updated_tasks, conversation_data, ) .map_err(anyhow::Error::from), ModelEvent::DeleteMultiAgentConversations { conversation_ids } => { delete_agent_conversations(connection, conversation_ids) .map_err(anyhow::Error::from) .context("error deleting multi-agent conversation") } ModelEvent::UpsertCurrentUserInformation { user_information } => { upsert_current_user_information(connection, user_information) .context("error upserting user information") } ModelEvent::UpsertMCPServerEnvironmentVariables { mcp_server_uuid, environment_variables, } => upsert_mcp_server_environment_variables( connection, mcp_server_uuid, environment_variables, ) .context("error upserting mcp server mcp_environment variables"), ModelEvent::UpsertProjectRules { project_rule_paths } => { upsert_project_rules(connection, project_rule_paths) .context("error upserting project rules") } ModelEvent::DeleteProjectRules { path } => { delete_project_rules(connection, path).context("error deleting project rules") } ModelEvent::AddIgnoredSuggestion { suggestion, suggestion_type, } => add_ignored_suggestion(connection, suggestion, suggestion_type) .context("error adding ignored suggestion"), ModelEvent::RemoveIgnoredSuggestion { suggestion, suggestion_type, } => remove_ignored_suggestion(connection, suggestion, suggestion_type) .context("error removing ignored suggestion"), ModelEvent::UpsertMCPServerInstallation { mcp_server_installation, } => upsert_mcp_server_installation(connection, mcp_server_installation), ModelEvent::DeleteMCPServerInstallations { installation_uuids } => { delete_mcp_server_installations(connection, installation_uuids) } ModelEvent::DeleteMCPServerInstallationsByTemplateUuid { template_uuid } => { delete_mcp_server_installations_by_template_uuid(connection, template_uuid) } ModelEvent::UpdateMCPInstallationRunning { installation_uuid, running, } => update_mcp_server_running(connection, installation_uuid, running) .context("Error updating running field for MCP installation"), ModelEvent::UpsertWorkspaceLanguageServer { workspace_path, lsp_type, enabled, } => upsert_workspace_language_server(connection, &workspace_path, lsp_type, enabled) .context("error upserting workspace language server"), ModelEvent::UpdateBlockAgentViewVisibility { block_id, agent_view_visibility, } => update_block_agent_view_visibility(connection, &block_id, &agent_view_visibility) .context("error updating block agent view visibility"), ModelEvent::SaveAIDocumentContent { document_id, content, version, title, } => save_ai_document_content(connection, &document_id, &content, version, &title) .context("error saving AI document content"), } } /// Report a database error and additional context for debugging. fn report_db_error(err_kind: &str, err: anyhow::Error, database_path: &Path) { // Sentry reports indicate that the database is sometimes missing/inaccessible, so check its // permissions and whether or not it exists. fn log_access(prefix: &str, path: &Path) { match fs::metadata(path) { Ok(metadata) => { cfg_if::cfg_if! { if #[cfg(windows)] { use async_fs::windows::MetadataExt; // Windows does not have the same notion of permissions as Unix-based file systems. // See more about what File Attributes contain [here](https://learn.microsoft.com/en-us/windows/win32/fileio/file-attribute-constants). let attributes = metadata.file_attributes(); safe_info!( safe: ("{prefix} attributes: {attributes}"), full: ("{prefix} {} attributes: {attributes}", path.display()) ); } else { use async_fs::unix::PermissionsExt; let mode = metadata.permissions().mode(); safe_info!( safe: ("{prefix} permissions: {mode:o}"), full: ("{prefix} {} permissions: {mode:o}", path.display()) ); } } } Err(err) => { safe_info!( safe: ("{prefix} is inaccessible: {err}"), full: ("{prefix} {} is inaccessible: {err}", path.display()) ); } } } if let Some(parent) = database_path.parent() { log_access("Database directory", parent); } log_access("Database", database_path); report_error!(err.context(format!("SQLite {err_kind} error"))); } /// Filter a collection of model events to remove skippable events: /// * [`ModelEvent::Snapshot`] includes the entire app state, so we only need the latest one. fn deduplicate_events(events: Vec) -> Vec { let last_snapshot = events .iter() .enumerate() .rfind(|(_, event)| matches!(event, ModelEvent::Snapshot(_))); match last_snapshot { Some((last_snapshot_index, _)) => events .into_iter() .enumerate() .filter_map(|(index, event)| match event { ModelEvent::Snapshot(_) if index < last_snapshot_index => None, event => Some(event), }) .collect(), None => events, } } // Used in the save_app_state function to help make the code more readable. struct SaveAppStateNodeTraversal<'a> { node: &'a PaneNodeSnapshot, flex: Option, parent_pane_node_id: Option, } // Saves the app state snapshot in the sqlite database. Removes any old app state. // Does so in a transaction so we're never in a partial state. fn save_app_state(conn: &mut SqliteConnection, app_state: &AppState) -> Result<()> { conn.transaction::<(), Error, _>(|conn| { // Remove old app state diesel::delete(schema::app::dsl::app).execute(conn)?; diesel::delete(schema::terminal_panes::dsl::terminal_panes).execute(conn)?; diesel::delete(schema::notebook_panes::dsl::notebook_panes).execute(conn)?; diesel::delete(schema::code_panes::dsl::code_panes).execute(conn)?; diesel::delete(schema::env_var_collection_panes::dsl::env_var_collection_panes) .execute(conn)?; diesel::delete(schema::workflow_panes::dsl::workflow_panes).execute(conn)?; diesel::delete(schema::settings_panes::dsl::settings_panes).execute(conn)?; diesel::delete(schema::ai_memory_panes::dsl::ai_memory_panes).execute(conn)?; diesel::delete(schema::ai_document_panes::dsl::ai_document_panes).execute(conn)?; diesel::delete(schema::mcp_server_panes::dsl::mcp_server_panes).execute(conn)?; diesel::delete(schema::code_review_panes::dsl::code_review_panes).execute(conn)?; diesel::delete(schema::ambient_agent_panes::dsl::ambient_agent_panes).execute(conn)?; diesel::delete(schema::welcome_panes::dsl::welcome_panes).execute(conn)?; diesel::delete(schema::pane_leaves::dsl::pane_leaves).execute(conn)?; diesel::delete(schema::pane_branches::dsl::pane_branches).execute(conn)?; diesel::delete(schema::pane_nodes::dsl::pane_nodes).execute(conn)?; diesel::delete(schema::tabs::dsl::tabs).execute(conn)?; diesel::delete(schema::windows::dsl::windows).execute(conn)?; diesel::delete(schema::active_mcp_servers::dsl::active_mcp_servers).execute(conn)?; diesel::delete(schema::panels::dsl::panels).execute(conn)?; let mut active_window_id = None; for (idx, window) in app_state.windows.iter().enumerate() { // Just save zero as the tab index, if we overflow when converting // unsigned to signed. let active_tab_index: i32 = window.active_tab_index.try_into().unwrap_or(0); // In the database each individual field is nullable but in practice these // fields are either all null or all non-null as they together represent // the stored window bound. let (window_width, window_height, origin_x, origin_y) = match window.bounds { Some(rect) => ( Some(rect.size().x()), Some(rect.size().y()), Some(rect.origin().x()), Some(rect.origin().y()), ), _ => (None, None, None, None), }; let new_window = NewWindow { active_tab_index, window_width, window_height, origin_x, origin_y, quake_mode: window.quake_mode, universal_search_width: window.universal_search_width, warp_ai_width: window.warp_ai_width, voltron_width: window.voltron_width, warp_drive_index_width: window.warp_drive_index_width, left_panel_open: Some(window.left_panel_open), vertical_tabs_panel_open: Some(window.vertical_tabs_panel_open), fullscreen_state: window.fullscreen_state as i32, agent_management_filters: window .agent_management_filters .as_ref() .and_then(|f| serde_json::to_string(f).ok()), }; diesel::insert_into(schema::windows::dsl::windows) .values(new_window) .execute(conn)?; // We cannot directly return the id from the insert so perform // a second query for the id https://github.com/diesel-rs/diesel/issues/771. let window_id: i32 = schema::windows::dsl::windows .select(schema::windows::columns::id) .order(schema::windows::columns::id.desc()) .first(conn)?; if app_state .active_window_index .map(|id| id == idx) .unwrap_or(false) { active_window_id = Some(window_id) } let tabs: Vec = window .tabs .iter() .map(|tab| NewTab { window_id, custom_title: tab.custom_title.clone(), // We only persist and restore the selected color here // (the default color based on the pwd is separately persisted and then applied on-restore) color: match tab.selected_color { // Keep the column NULL for the common no-override case SelectedTabColor::Unset => None, _ => serde_yaml::to_string(&tab.selected_color).ok(), }, }) .collect(); diesel::insert_into(schema::tabs::dsl::tabs) .values(tabs) .execute(conn)?; // Same ID issue as above. let tab_ids: Vec = schema::tabs::dsl::tabs .filter(schema::tabs::columns::window_id.eq(window_id)) .select(schema::tabs::columns::id) .order(schema::tabs::columns::id.desc()) .load(conn)?; // Since we retrieved the tab ids in descending order, we need to reverse them when we // iterate to restore the correct order. for (tab_id, tab) in tab_ids.iter().rev().zip(window.tabs.iter()) { let mut pane_nodes = VecDeque::new(); pane_nodes.push_back(SaveAppStateNodeTraversal { node: &tab.root, flex: None, parent_pane_node_id: None, }); if tab.left_panel.is_some() || tab.right_panel.is_some() { let new_panel = model::NewPanel { tab_id: *tab_id, left_panel: tab .left_panel .as_ref() .and_then(|p| serde_json::to_string(p).ok()), right_panel: tab .right_panel .as_ref() .and_then(|p| serde_json::to_string(p).ok()), }; diesel::insert_into(schema::panels::dsl::panels) .values(new_panel) .execute(conn)?; } while !pane_nodes.is_empty() { let SaveAppStateNodeTraversal { node: pane_node, flex, parent_pane_node_id, } = pane_nodes.pop_front().expect("Should have node"); // Skip leaves whose content types don't get a // corresponding `pane_leaves` row on save. Otherwise the // `pane_nodes` insert below would create an orphan row // (is_leaf=true, but no matching row in `pane_leaves`), // and `read_node` would fail to resolve the leaf on // restore, causing the entire surrounding tab to be // dropped. See `LeafContents::is_persisted`. if let PaneNodeSnapshot::Leaf(leaf) = pane_node { if !leaf.contents.is_persisted() { continue; } } let is_leaf = matches!(pane_node, PaneNodeSnapshot::Leaf(_)); let new_pane_node = model::NewPaneNode { tab_id: *tab_id, parent_pane_node_id, flex, is_leaf, }; diesel::insert_into(schema::pane_nodes::dsl::pane_nodes) .values(new_pane_node) .execute(conn)?; // Same ID issue as above. let pane_node_id = schema::pane_nodes::dsl::pane_nodes .select(schema::pane_nodes::columns::id) .order(schema::pane_nodes::columns::id.desc()) .first(conn)?; match pane_node { PaneNodeSnapshot::Branch(pane_group) => { let new_pane_branch = model::NewPaneBranch { pane_node_id, horizontal: pane_group.direction == SplitDirection::Horizontal, }; diesel::insert_into(schema::pane_branches::dsl::pane_branches) .values(new_pane_branch) .execute(conn)?; for (flex, child_pane_node) in &pane_group.children { pane_nodes.push_back(SaveAppStateNodeTraversal { node: child_pane_node, flex: Some(flex.0), parent_pane_node_id: Some(pane_node_id), }); } } PaneNodeSnapshot::Leaf(pane) => { save_pane_state(conn, pane_node_id, pane)?; } } } } } let new_app = NewApp { active_window_id }; diesel::insert_into(schema::app::dsl::app) .values(new_app) .execute(conn)?; // Save active MCP servers let active_mcp_servers: Vec = app_state .running_mcp_servers .iter() .map(|uuid| NewActiveMCPServer { mcp_server_uuid: uuid.to_string(), }) .collect(); if !active_mcp_servers.is_empty() { diesel::insert_into(schema::active_mcp_servers::dsl::active_mcp_servers) .values(active_mcp_servers) .execute(conn)?; } Ok(()) })?; Ok(()) } /// Saves the state of an individual pane, after the corresponding `pane_nodes` entry /// has been written. fn save_pane_state( conn: &mut SqliteConnection, id: i32, snapshot: &LeafSnapshot, ) -> Result<(), Error> { // The pane_leaves row must be inserted first to satisfy foreign key constraints on the // kind-specific tables. let kind = match &snapshot.contents { LeafContents::Terminal(_) => TERMINAL_PANE_KIND, LeafContents::Notebook(_) => NOTEBOOK_PANE_KIND, LeafContents::EnvVarCollection(_) => ENV_VAR_COLLECTION_PANE_KIND, LeafContents::Code(_) => CODE_PANE_KIND, LeafContents::Workflow(_) => WORKFLOW_PANE_KIND, LeafContents::Settings(_) => SETTINGS_PANE_KIND, LeafContents::AIFact(_) => AI_FACT_PANE_KIND, LeafContents::CodeReview(_) => CODE_REVIEW_PANE_KIND, LeafContents::AmbientAgent(_) => AMBIENT_AGENT_PANE_KIND, LeafContents::ExecutionProfileEditor => EXECUTION_PROFILE_EDITOR_PANE_KIND, LeafContents::GetStarted => GET_STARTED_PANE_KIND, LeafContents::Welcome { .. } => WELCOME_PANE_KIND, LeafContents::AIDocument(_) => AI_DOCUMENT_PANE_KIND, LeafContents::EnvironmentManagement(_) | LeafContents::NetworkLog => { // These pane types are filtered out before this function is // called; see `LeafContents::is_persisted` and the skip in // `save_app_state`. Reaching this arm would mean a `pane_nodes` // row had already been inserted with no corresponding // `pane_leaves` row, which would break restoration. debug_assert!( false, "save_pane_state called for non-persisted LeafContents variant" ); return Ok(()); } }; let leaf = model::NewPane { pane_node_id: id, kind: kind.into(), is_focused: snapshot.is_focused, custom_vertical_tabs_title: snapshot.custom_vertical_tabs_title.clone(), }; diesel::insert_into(schema::pane_leaves::dsl::pane_leaves) .values(leaf) .execute(conn)?; match &snapshot.contents { LeafContents::Terminal(terminal_snapshot) => { let conversation_ids = if terminal_snapshot.conversation_ids_to_restore.is_empty() { None } else { let ids: Vec = terminal_snapshot .conversation_ids_to_restore .iter() .map(|id| id.to_string()) .collect(); serde_json::to_string(&ids).ok() }; let terminal = model::NewTerminalPane { id, uuid: terminal_snapshot.uuid.clone(), cwd: terminal_snapshot.cwd.clone(), is_active: terminal_snapshot.is_active, shell_launch_data: terminal_snapshot .shell_launch_data .as_ref() .and_then(|shell| serde_json::to_string(shell).ok()), input_config: terminal_snapshot .input_config .as_ref() .and_then(|config| serde_json::to_string(config).ok()), llm_model_override: terminal_snapshot.llm_model_override.clone(), active_profile_id: terminal_snapshot .active_profile_id .as_ref() .and_then(|sync_id| serde_json::to_string(sync_id).ok()), conversation_ids, active_conversation_id: terminal_snapshot .active_conversation_id .map(|id| id.to_string()), }; diesel::insert_into(schema::terminal_panes::dsl::terminal_panes) .values(terminal) .execute(conn)?; } LeafContents::Notebook(notebook_snapshot) => { let (notebook_id, local_path) = match notebook_snapshot { NotebookPaneSnapshot::CloudNotebook { notebook_id, settings: _, } => ( notebook_id.map(|id| id.sqlite_uid_hash(ObjectIdType::Notebook)), None, ), NotebookPaneSnapshot::LocalFileNotebook { path } => { (None, path.clone().map(encode_path)) } }; let notebook = model::NewNotebookPane { id, notebook_id, local_path, }; diesel::insert_into(schema::notebook_panes::dsl::notebook_panes) .values(notebook) .execute(conn)?; } LeafContents::Code(code_snapshot) => { let CodePaneSnapShot::Local { tabs, active_tab_index, source, } = code_snapshot; let serialized_source = source.as_ref().and_then(|s| serde_json::to_string(s).ok()); let code = model::NewCodePane { id, active_tab_index: *active_tab_index as i32, source_data: serialized_source, }; diesel::insert_into(schema::code_panes::dsl::code_panes) .values(code) .execute(conn)?; // Write ordered tab rows. for (tab_idx, tab) in tabs.iter().enumerate() { let tab_row = model::NewCodePaneTab { code_pane_id: id, tab_index: tab_idx as i32, local_path: tab.path.clone().map(encode_path), }; diesel::insert_into(schema::code_pane_tabs::dsl::code_pane_tabs) .values(tab_row) .execute(conn)?; } } LeafContents::EnvVarCollection(env_var_collection_snapshot) => { let env_var_collection_id = match env_var_collection_snapshot { EnvVarCollectionPaneSnapshot::CloudEnvVarCollection { env_var_collection_id, } => env_var_collection_id .map(|id| id.sqlite_uid_hash(ObjectIdType::GenericStringObject)), }; let env_var_collection = model::NewEnvVarCollectionPane { id, env_var_collection_id, }; diesel::insert_into(schema::env_var_collection_panes::dsl::env_var_collection_panes) .values(env_var_collection) .execute(conn)?; } LeafContents::Workflow(workflow_pane_snapshot) => { let workflow_id = match workflow_pane_snapshot { WorkflowPaneSnapshot::CloudWorkflow { workflow_id, settings: _, } => workflow_id.map(|id| id.sqlite_uid_hash(ObjectIdType::Workflow)), }; let workflow = model::NewWorkflowPane { id, workflow_id }; diesel::insert_into(schema::workflow_panes::dsl::workflow_panes) .values(workflow) .execute(conn)?; } LeafContents::EnvironmentManagement(_) => { // Unreachable: filtered by `is_persisted` in `save_app_state`. } LeafContents::Settings(settings_pane_snapshot) => { let current_page = match settings_pane_snapshot { SettingsPaneSnapshot::Local { current_page, .. } => current_page, }; let settings_pane = model::NewSettingsPane { id, current_page: current_page.to_string(), }; diesel::insert_into(schema::settings_panes::dsl::settings_panes) .values(settings_pane) .execute(conn)?; } LeafContents::AIFact(_ai_fact_pane_snapshot) => { let ai_fact = model::NewAIFactPane { id }; diesel::insert_into(schema::ai_memory_panes::dsl::ai_memory_panes) .values(ai_fact) .execute(conn)?; } LeafContents::CodeReview(code_review_pane_snapshot) => { let CodeReviewPaneSnapshot::Local { terminal_uuid, repo_path, } = code_review_pane_snapshot; let code_review = model::NewCodeReviewPane { id, terminal_uuid: terminal_uuid.clone(), repo_path: repo_path.to_string_lossy().into_owned(), }; diesel::insert_into(schema::code_review_panes::dsl::code_review_panes) .values(code_review) .execute(conn)?; } LeafContents::ExecutionProfileEditor => { // TODO: Implement execution profile editor pane saving. } LeafContents::GetStarted => { // Stateless } LeafContents::Welcome { startup_directory } => { let welcome_pane = model::NewWelcomePane { id, startup_directory: startup_directory .as_ref() .map(|path| path.to_string_lossy().into_owned()), }; diesel::insert_into(schema::welcome_panes::dsl::welcome_panes) .values(welcome_pane) .execute(conn)?; } LeafContents::AIDocument(ai_document_snapshot) => match ai_document_snapshot { crate::app_state::AIDocumentPaneSnapshot::Local { document_id, version, content, title, } => { let ai_document_pane = model::NewAIDocumentPane { id, document_id: document_id.clone(), version: *version, content: content.clone(), title: title.clone(), }; diesel::insert_into(schema::ai_document_panes::dsl::ai_document_panes) .values(ai_document_pane) .execute(conn)?; } }, LeafContents::AmbientAgent(snapshot) => { let ambient_agent_pane = model::NewAmbientAgentPane { id, uuid: snapshot.uuid.clone(), task_id: snapshot.task_id.map(|t| t.to_string()), }; diesel::insert_into(schema::ambient_agent_panes::dsl::ambient_agent_panes) .values(ambient_agent_pane) .execute(conn)?; } LeafContents::NetworkLog => { // Unreachable: filtered by `is_persisted` in `save_app_state`. } } Ok(()) } /// Update the content, version, and title of an AI document pane in SQLite. fn save_ai_document_content( conn: &mut SqliteConnection, doc_id: &str, doc_content: &str, doc_version: i32, doc_title: &str, ) -> Result<()> { use schema::ai_document_panes::dsl::*; diesel::update(ai_document_panes.filter(document_id.eq(doc_id))) .set(( content.eq(Some(doc_content)), version.eq(doc_version), title.eq(Some(doc_title)), )) .execute(conn)?; Ok(()) } /// Encode a path into a platform-specific byte representation for persistence. fn encode_path(path: PathBuf) -> Vec { if path == PathBuf::new() { // bytemuck will throw a TargetAlignmentGreaterAndInputNotAligned error // if we don't special-case the empty path. return Vec::new(); } cfg_if::cfg_if! { if #[cfg(unix)] { use std::os::unix::ffi::OsStringExt; path.into_os_string().into_vec() } else if #[cfg(windows)] { use std::os::windows::ffi::OsStrExt; let wide_char_sequence: Vec = path.into_os_string().encode_wide().collect(); // We need to deal with slices (not Vec) because otherwise we will get a PodCastError::AlignmentMismatch. let slice: &[u8] = bytemuck::cast_slice(wide_char_sequence.as_slice()); slice.to_vec() } } } /// Decode a path from its platform-specific byte representation. fn decode_path(bytes: Vec) -> PathBuf { if bytes.is_empty() { // bytemuck will throw a TargetAlignmentGreaterAndInputNotAligned error // if we don't special-case the empty path. return PathBuf::new(); } cfg_if::cfg_if! { if #[cfg(unix)] { use std::os::unix::ffi::OsStringExt; OsString::from_vec(bytes).into() } else if #[cfg(windows)] { use std::os::windows::ffi::OsStringExt; // We need to deal with slices (not Vec) because otherwise we will get a PodCastError::AlignmentMismatch. let wide_char_sequence: &[u16] = bytemuck::cast_slice(bytes.as_slice()); OsString::from_wide(wide_char_sequence).into() } } } fn save_codebase_index_metadata( conn: &mut SqliteConnection, index_metadata: ai::workspace::WorkspaceMetadata, ) -> Result<()> { use schema::workspace_metadata::dsl::*; let new_metadata: NewWorkspaceMetadata = index_metadata.into(); diesel::insert_into(workspace_metadata) .values(new_metadata.clone()) .on_conflict(repo_path) .do_update() .set(&new_metadata) .execute(conn)?; Ok(()) } fn get_all_codebase_index_metadata( conn: &mut SqliteConnection, ) -> Result, diesel::result::Error> { use schema::workspace_metadata::dsl::*; Ok(workspace_metadata .load_iter::(conn)? .filter_map(|item| item.ok().map(ai::workspace::WorkspaceMetadata::from)) .collect_vec()) } fn get_all_workspace_language_servers_by_workspace( conn: &mut SqliteConnection, ) -> Result>, diesel::result::Error> { use schema::workspace_language_server::dsl::*; use schema::workspace_metadata; let results = workspace_language_server .inner_join(workspace_metadata::table) .select((workspace_metadata::repo_path, language_server_name, enabled)) .load::<(String, String, String)>(conn)?; let mut grouped: HashMap> = HashMap::new(); for (path_str, server_name, enablement_str) in results { let path = PathBuf::from(path_str); let Some(server_type) = serde_json::from_str(&server_name).ok() else { continue; }; let Some(enablement) = serde_json::from_str(&enablement_str).ok() else { continue; }; grouped .entry(path) .or_default() .insert(server_type, enablement); } Ok(grouped) } fn upsert_workspace_language_server( conn: &mut SqliteConnection, workspace_path: &Path, server_type: LSPServerType, enablement: EnablementState, ) -> Result<()> { use schema::workspace_language_server::dsl::*; use schema::workspace_metadata::dsl::*; let path_string = workspace_path.to_string_lossy().to_string(); // Try to find existing workspace let metadata = workspace_metadata .filter(repo_path.eq(&path_string)) .first::(conn) .optional()? .ok_or(anyhow::anyhow!("Can't find workspace for path"))?; let ws_id = metadata.id; let server_name = serde_json::to_string(&server_type)?; // Now upsert the language server setting // Check if record already exists let existing = workspace_language_server .filter(workspace_id.eq(ws_id)) .filter(language_server_name.eq(server_name.clone())) .first::(conn) .optional()?; let enablement_str = serde_json::to_string(&enablement)?; if let Some(existing_record) = existing { // Update existing record diesel::update(workspace_language_server.find(existing_record.id)) .set(enabled.eq(enablement_str)) .execute(conn)?; } else { // Insert new record let new_language_server = model::NewWorkspaceLanguageServer { workspace_id: ws_id, language_server_name: server_name, enabled: enablement_str.to_string(), }; diesel::insert_into(workspace_language_server) .values(&new_language_server) .execute(conn)?; } Ok(()) } fn delete_codebase_index_metadata(conn: &mut SqliteConnection, index_path: &Path) -> Result<()> { use schema::workspace_metadata::dsl::*; let target_path = index_path.to_string_lossy().to_string(); diesel::delete(workspace_metadata.filter(repo_path.eq(target_path))).execute(conn)?; Ok(()) } fn save_project(conn: &mut SqliteConnection, project: Project) -> Result<()> { use schema::projects::dsl::*; diesel::insert_into(projects) .values(project.clone()) .on_conflict(path) .do_update() .set(&project) .execute(conn)?; Ok(()) } fn get_all_projects(conn: &mut SqliteConnection) -> Result, diesel::result::Error> { use schema::projects::dsl::*; Ok(projects .load_iter::(conn)? .filter_map(|item| item.ok()) .collect_vec()) } fn delete_project(conn: &mut SqliteConnection, project_path: &str) -> Result<()> { use schema::projects::dsl::*; diesel::delete(projects.filter(path.eq(project_path))).execute(conn)?; Ok(()) } fn get_all_project_rules( conn: &mut SqliteConnection, ) -> Result, diesel::result::Error> { use schema::project_rules::dsl::*; Ok(project_rules .load_iter::(conn)? .filter_map(|item| match item { Ok(rule) => Some(ProjectRulePath { path: PathBuf::from(rule.path), project_root: PathBuf::from(rule.project_root), }), Err(_) => None, }) .collect_vec()) } fn upsert_project_rules( conn: &mut SqliteConnection, new_project_rules: Vec, ) -> Result<()> { use schema::project_rules::dsl::*; // SQLite doesn't support batch upserts, so we need to iterate for rule in new_project_rules { let new_rule = model::NewProjectRules { path: rule.path.to_string_lossy().to_string(), project_root: rule.project_root.to_string_lossy().to_string(), }; diesel::insert_into(project_rules) .values(&new_rule) .on_conflict(path) .do_update() .set(&new_rule) .execute(conn)?; } Ok(()) } fn delete_project_rules(conn: &mut SqliteConnection, rules_paths: Vec) -> Result<()> { use schema::project_rules::dsl::*; // Convert PathBuf to String for comparison let path_strings: Vec = rules_paths .into_iter() .map(|p| p.to_string_lossy().to_string()) .collect(); diesel::delete(project_rules.filter(path.eq_any(path_strings))).execute(conn)?; Ok(()) } fn get_all_ignored_suggestions( conn: &mut SqliteConnection, ) -> Result, diesel::result::Error> { use schema::ignored_suggestions::dsl::*; Ok(ignored_suggestions .select((suggestion, suggestion_type)) .load::<(String, String)>(conn)? .into_iter() .filter_map(|(suggestion_text, suggestion_type_str)| { SuggestionType::from_str(&suggestion_type_str) .map(|parsed_suggestion_type| (suggestion_text, parsed_suggestion_type)) }) .collect()) } fn get_all_mcp_server_installations( conn: &mut SqliteConnection, ) -> Result, diesel::result::Error> { use schema::mcp_server_installations::dsl::*; let rows: Vec<(String, String, String)> = mcp_server_installations .select((id, templatable_mcp_server, variable_values)) .load::<(String, String, String)>(conn)?; let rows_len = rows.len(); let result: HashMap = rows .into_iter() .filter_map(|(id_str, templ_mcp, vars_json)| { let uuid = uuid::Uuid::parse_str(&id_str).ok()?; // Parse variable_values JSON into a flat HashMap let vars: HashMap = match serde_json::from_str::>(&vars_json) { Ok(map) => map, Err(_) => return None, }; let mcp_server = match serde_json::from_str::(&templ_mcp) { Ok(map) => map, Err(_) => return None, }; Some(( uuid, TemplatableMCPServerInstallation::new(uuid, mcp_server, vars), )) }) .collect(); let improper_rows = rows_len - result.len(); if improper_rows > 0 { log::warn!("Skipping {improper_rows} rows from mcp_server_installations table due to malformation."); } Ok(result) } fn upsert_mcp_server_installation( conn: &mut SqliteConnection, mcp_server_installation: TemplatableMCPServerInstallation, ) -> Result<()> { use schema::mcp_server_installations::dsl::*; let new_installation = model::NewMCPServerInstallation { id: mcp_server_installation.uuid().to_string(), templatable_mcp_server: serde_json::to_string( mcp_server_installation.templatable_mcp_server(), )?, // TODO(pei): Change this to be the timestamp of the Cloud object template_version_ts: Utc::now().naive_utc(), variable_values: serde_json::to_string(mcp_server_installation.variable_values())?, restore_running: false, last_modified_at: Utc::now().naive_utc(), }; conn.transaction::<_, Error, _>(|conn| { diesel::insert_into(mcp_server_installations) .values(&new_installation) .on_conflict(id) .do_update() .set(&new_installation) .execute(conn)?; Ok(()) })?; Ok(()) } fn delete_mcp_server_installations(conn: &mut SqliteConnection, uuids: Vec) -> Result<()> { use schema::mcp_server_installations::dsl::*; let id_strings: Vec = uuids.iter().map(|uuid| uuid.to_string()).collect(); diesel::delete(mcp_server_installations.filter(id.eq_any(id_strings))).execute(conn)?; Ok(()) } fn delete_mcp_server_installations_by_template_uuid( conn: &mut SqliteConnection, target_template_uuid: Uuid, ) -> Result<()> { use schema::mcp_server_installations::dsl::*; diesel::delete(mcp_server_installations.filter( json_extract(templatable_mcp_server, "$.uuid").eq(target_template_uuid.to_string()), )) .execute(conn)?; Ok(()) } fn get_mcp_servers_to_restore( conn: &mut SqliteConnection, ) -> Result, diesel::result::Error> { use schema::mcp_server_installations::dsl::*; let rows = mcp_server_installations .filter(restore_running.eq(true)) .select(id) .load::(conn)?; let installation_uuid = rows .iter() .filter_map(|uuid| uuid::Uuid::parse_str(uuid).ok()) .collect(); Ok(installation_uuid) } fn update_mcp_server_running( conn: &mut SqliteConnection, installation_uuid: Uuid, running: bool, ) -> Result<(), diesel::result::Error> { use schema::mcp_server_installations::dsl::*; diesel::update(mcp_server_installations.find(installation_uuid.to_string())) .set(( restore_running.eq(running), last_modified_at.eq(Utc::now().naive_utc()), )) .execute(conn)?; Ok(()) } fn add_ignored_suggestion( conn: &mut SqliteConnection, suggestion_text: String, suggestion_type_param: SuggestionType, ) -> Result<()> { use schema::ignored_suggestions::dsl::*; let new_suggestion = model::NewIgnoredSuggestion { suggestion: suggestion_text, suggestion_type: suggestion_type_param.as_str().to_string(), }; diesel::insert_into(ignored_suggestions) .values(&new_suggestion) .on_conflict((suggestion, suggestion_type)) .do_nothing() .execute(conn)?; Ok(()) } fn remove_ignored_suggestion( conn: &mut SqliteConnection, suggestion_text: String, suggestion_type_param: SuggestionType, ) -> Result<()> { use schema::ignored_suggestions::dsl::*; diesel::delete( ignored_suggestions.filter( suggestion .eq(suggestion_text) .and(suggestion_type.eq(suggestion_type_param.as_str())), ), ) .execute(conn)?; Ok(()) } fn save_workspace(conn: &mut SqliteConnection, workspace: WorkspaceMetadata) -> Result<()> { // Set all existing workspaces as not selected diesel::update(workspaces) .set(is_selected.eq(false)) .execute(conn)?; // Save new workspace and set it as current workspace use schema::workspaces::dsl::*; let new_workspace = NewWorkspace { name: workspace.name, server_uid: workspace.uid.into(), is_selected: true, }; diesel::insert_into(workspaces) .values(&new_workspace) .on_conflict(schema::workspaces::dsl::server_uid) .do_update() // If there's already a workspace with this server_uid, then lets just update the other values .set(&new_workspace) .execute(conn)?; // Save teams for workspace for team in workspace.teams { use schema::teams::dsl::*; use schema::workspace_teams::dsl::*; let new_team = NewTeam { name: team.name, server_uid: team.uid.into(), billing_metadata_json: serde_json::to_string(&team.billing_metadata).ok(), }; diesel::insert_into(teams) .values(&new_team) .on_conflict(server_uid) .do_update() // If there's already a team with this server_uid, then lets just update the other values .set(&new_team) .execute(conn)?; let team_db_id: i32 = schema::teams::dsl::teams .filter(schema::teams::dsl::server_uid.eq::(team.uid.into())) .select(schema::teams::dsl::id) .first(conn)?; diesel::delete( schema::team_members::dsl::team_members .filter(schema::team_members::dsl::team_id.eq(team_db_id)), ) .execute(conn)?; for member in &team.members { let new_member = model::NewTeamMember { team_id: team_db_id, user_uid: member.uid.as_string(), email: member.email.clone(), role: serde_json::to_string(&member.role).unwrap_or_default(), }; diesel::insert_into(schema::team_members::dsl::team_members) .values(&new_member) .execute(conn)?; } let new_workspace_team = NewWorkspaceTeam { workspace_server_uid: workspace.uid.into(), team_server_uid: team.uid.into(), }; diesel::insert_into(workspace_teams) .values(&new_workspace_team) .on_conflict((workspace_server_uid, team_server_uid)) .do_update() .set(&new_workspace_team) .execute(conn)?; } Ok(()) } fn save_workspaces( conn: &mut SqliteConnection, workspaces_to_insert: Vec, ) -> Result<()> { use schema::team_settings::dsl::*; use schema::teams::dsl::*; use schema::workspace_teams::dsl::*; use schema::workspaces::dsl::*; // Get currently selected workspace uid if there is one let current_workspace_uid: Option = workspaces .filter(is_selected.eq(true)) .select(schema::workspaces::dsl::server_uid) .first::(conn) .optional()? .map(|uid| uid.into()); // Remove all team_members/team_settings/workspaces/teams/workspace_teams stored locally. diesel::delete(schema::team_members::dsl::team_members).execute(conn)?; diesel::delete(team_settings).execute(conn)?; diesel::delete(workspace_teams).execute(conn)?; diesel::delete(teams).execute(conn)?; diesel::delete(workspaces).execute(conn)?; // Insert workspaces returned by server (doing nothing on conflict), set is_selected // to true for the current_workspace_uid if it is in the list of workspaces. let new_workspace_values: Vec = workspaces_to_insert .clone() .into_iter() .map(|workspace| NewWorkspace { server_uid: workspace.uid.into(), name: workspace.name, is_selected: current_workspace_uid .map(|current_uid| workspace.uid == current_uid) .unwrap_or(false), }) .collect(); diesel::insert_or_ignore_into(workspaces) .values(&new_workspace_values) .execute(conn)?; // Insert teams returned by server (doing nothing on conflict) let new_team_values: Vec = workspaces_to_insert .clone() .into_iter() .flat_map(|workspace| { workspace .teams .into_iter() .map(|team| NewTeam { server_uid: team.uid.into(), name: team.name.clone(), billing_metadata_json: serde_json::to_string(&team.billing_metadata).ok(), }) .collect::>() }) .collect(); diesel::insert_or_ignore_into(teams) .values(&new_team_values) .execute(conn)?; // We cannot directly return the id from the insert so perform // a second query for the id https://github.com/diesel-rs/diesel/issues/771. let teams_with_id: Vec<(i32, String)> = schema::teams::dsl::teams .select((schema::teams::dsl::id, schema::teams::dsl::server_uid)) .load(conn)?; let teams_by_server_uid: HashMap<&String, i32> = HashMap::from_iter( teams_with_id .iter() .map(|(table_id, table_server_uid)| (table_server_uid, *table_id)), ); // Insert workspace_teams returned by server (doing nothing on conflict) let workspace_teams_values: Vec = workspaces_to_insert .clone() .into_iter() .flat_map(|workspace| { workspace .teams .into_iter() .map(|team| NewWorkspaceTeam { workspace_server_uid: workspace.uid.into(), team_server_uid: team.uid.into(), }) .collect::>() }) .collect(); diesel::insert_or_ignore_into(workspace_teams) .values(&workspace_teams_values) .execute(conn)?; // Cache workspace settings returned by the server (overwriting any existing settings) let team_settings_values: Vec = workspaces_to_insert .clone() .into_iter() .flat_map(|workspace| { workspace.teams.into_iter().filter_map(|team| { let serialized_settings_json = serde_json::to_string(&team.organization_settings).ok()?; let team_id_match = teams_by_server_uid.get(&team.uid.uid())?; Some(NewTeamSettings { team_id: *team_id_match, settings_json: serialized_settings_json, }) }) }) .collect(); diesel::insert_into(schema::team_settings::dsl::team_settings) .values(&team_settings_values) .execute(conn)?; // Cache team members let team_member_values: Vec = workspaces_to_insert .clone() .into_iter() .flat_map(|workspace| { workspace.teams.into_iter().flat_map(|team| { let team_id_match = teams_by_server_uid.get(&team.uid.uid()).copied(); team.members.into_iter().filter_map(move |member| { Some(model::NewTeamMember { team_id: team_id_match?, user_uid: member.uid.as_string(), email: member.email, role: serde_json::to_string(&member.role).unwrap_or_default(), }) }) }) }) .collect(); if !team_member_values.is_empty() { diesel::insert_into(schema::team_members::dsl::team_members) .values(&team_member_values) .execute(conn)?; } if let Some(current_workspace_uid) = current_workspace_uid { if !workspaces_to_insert .iter() .any(|workspace| workspace.uid == current_workspace_uid) { // If the currently selected workspace is not in the list of workspaces, set // the first workspace as the current workspace. if let Some(first_workspace) = workspaces_to_insert.first() { diesel::update(workspaces.filter( schema::workspaces::dsl::server_uid.eq::(first_workspace.uid.into()), )) .set(is_selected.eq(true)) .execute(conn)?; } } } Ok(()) } fn set_current_workspace(conn: &mut SqliteConnection, workspace_uid: WorkspaceUid) -> Result<()> { use schema::workspaces::dsl::*; // Set all existing workspaces as not selected diesel::update(workspaces) .set(is_selected.eq(false)) .execute(conn)?; diesel::update( workspaces.filter(schema::workspaces::dsl::server_uid.eq::(workspace_uid.into())), ) .set(is_selected.eq(true)) .execute(conn)?; Ok(()) } /// Mark a shareable object as no longer having pending changes. fn mark_object_as_synced( conn: &mut SqliteConnection, hashed_sqlite_id: String, new_revision_and_editor: RevisionAndLastEditor, new_metadata_ts: Option, ) -> Result<(), Error> { use schema::object_metadata::dsl::*; conn.transaction::<(), Error, _>(|conn| { diesel::update(object_metadata.filter(server_id.eq(Some(hashed_sqlite_id.as_str())))) .set(is_pending.eq(false)) .execute(conn)?; diesel::update(object_metadata.filter(server_id.eq(Some(hashed_sqlite_id.clone())))) .set(( revision_ts.eq(new_revision_and_editor.revision.timestamp_micros()), last_editor_uid.eq(new_revision_and_editor.last_editor_uid), )) .execute(conn)?; if let Some(metadata_ts) = new_metadata_ts { diesel::update(object_metadata.filter(server_id.eq(Some(hashed_sqlite_id)))) .set((metadata_last_updated_ts.eq(metadata_ts.timestamp_micros()),)) .execute(conn)?; } Ok(()) }) } fn increment_retry_count( conn: &mut SqliteConnection, server_id_string: String, ) -> Result<(), Error> { use schema::object_metadata::dsl::*; conn.transaction::<(), Error, _>(|conn| { diesel::update(object_metadata.filter(server_id.eq(Some(server_id_string)))) .set(retry_count.eq(retry_count + 1)) .execute(conn)?; Ok(()) }) } fn update_object_after_server_creation( conn: &mut SqliteConnection, client_id_string: String, server_creation_info: ServerCreationInfo, ) -> Result<(), Error> { use schema::commands::dsl::*; use schema::object_metadata::dsl::*; conn.transaction::<(), Error, _>(|conn| { diesel::update(object_metadata.filter(client_id.eq(Some(client_id_string.clone())))) .set(( server_id.eq(Some( server_creation_info .server_id_and_type .sqlite_type_and_uid_hash(), )), creator_uid.eq(server_creation_info.creator_uid), )) .execute(conn)?; diesel::update(commands.filter(cloud_workflow_id.eq(Some(client_id_string)))) .set( cloud_workflow_id.eq(Some( server_creation_info .server_id_and_type .sqlite_type_and_uid_hash(), )), ) .execute(conn)?; Ok(()) }) } /// Helper function to delete a cloud object identified by `sync_id`. If a valid object metadata row /// for the object is found, `delete_object_fn` is called to delete the actual object. fn delete_cloud_object( conn: &mut SqliteConnection, sync_id: SyncId, object_id_type: ObjectIdType, delete_object_fn: DeleteCloudObjectFn, ) -> Result<(), Error> { use schema::object_metadata::dsl::*; // Filter to find metadata row. // The diesel types for `filter`s are dependent on the columns being filtered // so while the `hashed_sync_id` will only match one of `client_id` and `server_id`, // we filter on both here for ergonomics. let hashed_sync_id = sync_id.sqlite_uid_hash(object_id_type); let metadata_filter = object_metadata .filter(client_id.eq(Some(hashed_sync_id.as_str()))) .or_filter(server_id.eq(Some(hashed_sync_id.as_str()))); let metadata: ObjectMetadata = metadata_filter.first(conn)?; let object_id = metadata.shareable_object_id; diesel::delete(object_metadata.filter(id.eq(metadata.id))).execute(conn)?; diesel::delete( schema::object_permissions::dsl::object_permissions .filter(schema::object_permissions::object_metadata_id.eq(metadata.id)), ) .execute(conn)?; diesel::delete( schema::object_actions::dsl::object_actions .filter(schema::object_actions::hashed_object_id.eq(hashed_sync_id)), ) .execute(conn)?; delete_object_fn(conn, object_id)?; Ok(()) } /// SQLite endpoint for the ObjectMetadataUpdated RTC message that updates the metadata ts and other /// metadata like current team_id of the object. fn update_object_metadata( conn: &mut SqliteConnection, hashed_id: String, metadata: CloudObjectMetadata, ) -> Result<(), Error> { use schema::object_metadata::dsl::*; let metadata_last_updated_at = metadata .metadata_last_updated_ts .map(|ts| ts.timestamp_micros()); let trashed_timestamp = metadata.trashed_ts.map(|ts| ts.timestamp_micros()); let folder_id_str = metadata .folder_id .map(|folder_sync_id| folder_sync_id.sqlite_uid_hash(ObjectIdType::Folder)); conn.transaction::<(), Error, _>(|conn| { diesel::update(object_metadata.filter(server_id.eq(Some(hashed_id.as_str())))) .set(( metadata_last_updated_ts.eq(metadata_last_updated_at), trashed_ts.eq(trashed_timestamp), folder_id.eq(folder_id_str), current_editor.eq(metadata.current_editor_uid), )) .execute(conn)?; Ok(()) }) } fn upsert_generic_string_objects( conn: &mut SqliteConnection, cloud_generic_string_objects: Vec>, ) -> Result<(), Error> { use schema::generic_string_objects::dsl::*; conn.transaction::<(), Error, _>(|conn| { for object in cloud_generic_string_objects { let serialized_data = Arc::new(object.serialized().take()); let serialized_data_clone = serialized_data.clone(); upsert_cloud_object( conn, ObjectType::GenericStringObject(object.generic_string_object_format()), object.id(), object.metadata().clone(), object.permissions().clone(), Box::new(move |conn| { let new_object = NewGenericStringObject { data: serialized_data.as_ref(), }; diesel::insert_into( schema::generic_string_objects::dsl::generic_string_objects, ) .values(new_object) .execute(conn)?; let object_id: i32 = schema::generic_string_objects::dsl::generic_string_objects .select(schema::generic_string_objects::columns::id) .order(schema::generic_string_objects::columns::id.desc()) .first(conn)?; Ok(object_id) }), Box::new(move |conn, object_id| { diesel::update( generic_string_objects .filter(schema::generic_string_objects::dsl::id.eq(object_id)), ) .set((data.eq(serialized_data_clone.as_ref()),)) .execute(conn)?; Ok(()) }), )? } Ok(()) }) } fn upsert_workflows( conn: &mut SqliteConnection, cloud_workflows: Vec, ) -> Result<(), Error> { use schema::workflows::dsl::*; conn.transaction::<(), Error, _>(|conn| { // todo: wrap in an arc to avoid unnecessary cloning. for cloud_workflow in cloud_workflows { let workflow_id = cloud_workflow.id; if let Ok(serialized_workflow) = serde_json::to_string(&cloud_workflow.model().data) { let serialized_workflow_clone = serialized_workflow.clone(); upsert_cloud_object( conn, ObjectType::Workflow, workflow_id, cloud_workflow.metadata, cloud_workflow.permissions, Box::new(move |conn| { let workflow = model::NewWorkflow { data: serialized_workflow.clone(), }; diesel::insert_into(schema::workflows::dsl::workflows) .values(workflow) .execute(conn)?; let workflow_id: i32 = schema::workflows::dsl::workflows .select(schema::workflows::columns::id) .order(schema::workflows::columns::id.desc()) .first(conn)?; Ok(workflow_id) }), Box::new(move |conn, workflow_id| { diesel::update( workflows.filter(schema::workflows::dsl::id.eq(workflow_id)), ) .set((data.eq(serialized_workflow_clone),)) .execute(conn)?; Ok(()) }), )? } } Ok(()) }) } fn upsert_notebooks( conn: &mut SqliteConnection, cloud_notebooks: Vec, ) -> Result<(), Error> { use schema::notebooks::dsl::*; conn.transaction::<(), Error, _>(|conn| { for cloud_notebook in cloud_notebooks { // todo: wrap in an arc to avoid unnecessary cloning. let notebook_clone = cloud_notebook.clone(); let title_clone = cloud_notebook.model().title.clone(); let data_clone = cloud_notebook.model().data.clone(); let ai_document_id_clone = cloud_notebook .model() .ai_document_id .as_ref() .map(|doc_id| doc_id.to_string()); upsert_cloud_object( conn, ObjectType::Notebook, cloud_notebook.id, cloud_notebook.metadata, cloud_notebook.permissions, Box::new(move |conn| { let new_notebook = NewNotebook { title: Some(title_clone), data: Some(data_clone), ai_document_id: ai_document_id_clone, }; diesel::insert_into(schema::notebooks::dsl::notebooks) .values(new_notebook) .execute(conn)?; let notebook_id: i32 = schema::notebooks::dsl::notebooks .select(schema::notebooks::columns::id) .order(schema::notebooks::columns::id.desc()) .first(conn)?; Ok(notebook_id) }), Box::new(move |conn, notebook_id| { diesel::update(notebooks.filter(schema::notebooks::dsl::id.eq(notebook_id))) .set(( title.eq(notebook_clone.model().title.clone()), data.eq(notebook_clone.model().data.clone()), ai_document_id.eq(notebook_clone .model() .ai_document_id .as_ref() .map(|doc_id| doc_id.to_string())), )) .execute(conn)?; Ok(()) }), )? } Ok(()) }) } fn upsert_folders( conn: &mut SqliteConnection, cloud_folders: Vec, ) -> Result<(), Error> { use schema::folders::dsl::*; conn.transaction::<(), Error, _>(|conn| { for cloud_folder in cloud_folders { let folder_clone = cloud_folder.clone(); let folder_name = cloud_folder.model().name.clone(); let folder_is_open = cloud_folder.model().is_open; let folder_is_warp_pack = cloud_folder.model().is_warp_pack; upsert_cloud_object( conn, ObjectType::Folder, cloud_folder.id, cloud_folder.metadata, cloud_folder.permissions, Box::new(move |conn| { let new_folder = NewFolder { name: folder_name, is_open: folder_is_open, is_warp_pack: folder_is_warp_pack, }; diesel::insert_into(schema::folders::dsl::folders) .values(new_folder) .execute(conn)?; let folder_id: i32 = schema::folders::dsl::folders .select(schema::folders::columns::id) .order(schema::folders::columns::id.desc()) .first(conn)?; Ok(folder_id) }), Box::new(move |conn, folder_id| { diesel::update(folders.filter(schema::folders::dsl::id.eq(folder_id))) .set(( name.eq(folder_clone.model().name.clone()), is_open.eq(folder_clone.model().is_open), is_warp_pack.eq(folder_clone.model().is_warp_pack), )) .execute(conn)?; Ok(()) }), )? } Ok(()) }) } /// Parse conversation IDs from JSON string. fn parse_conversation_ids(ids_json: &Option) -> Vec { let Some(ids_str) = ids_json.as_ref() else { return vec![]; }; let Ok(id_strings) = serde_json::from_str::>(ids_str) else { log::warn!("Failed to deserialize conversation IDs from column"); return vec![]; }; id_strings .into_iter() .map(AIConversationId::try_from) .collect::, _>>() .unwrap_or_else(|_| { log::warn!("Failed to parse conversation IDs"); vec![] }) } fn read_root_node(conn: &mut SqliteConnection, tab_id_val: i32) -> Result { use schema::pane_nodes::dsl::*; let pane_node: model::PaneNode = schema::pane_nodes::dsl::pane_nodes .filter(tab_id.eq(tab_id_val)) .filter(parent_pane_node_id.is_null()) .first(conn)?; read_node(conn, pane_node) } /// Reads a saved node back into a snapshot. fn read_node(conn: &mut SqliteConnection, node: model::PaneNode) -> Result { match node.is_leaf { true => { let pane = schema::pane_leaves::dsl::pane_leaves .filter(schema::pane_leaves::columns::pane_node_id.eq(node.id)) .first::(conn)?; let contents = match pane.kind.as_ref() { TERMINAL_PANE_KIND => { let terminal_pane = schema::terminal_panes::dsl::terminal_panes .find(node.id) .select(model::TerminalPane::as_select()) .first(conn)?; let shell_launch_data: Option = terminal_pane .shell_launch_data .and_then(|shell_str| serde_json::from_str(&shell_str).ok()); let input_config = terminal_pane .input_config .and_then(|config_str| serde_json::from_str(&config_str).ok()); let active_profile_id = terminal_pane .active_profile_id .and_then(|profile_str| serde_json::from_str(&profile_str).ok()); // Don't provide a fallback here - let the higher-level code with AppContext handle it let conversation_ids_to_restore = parse_conversation_ids(&terminal_pane.conversation_ids); let active_conversation_id = terminal_pane .active_conversation_id .and_then(|id_str| AIConversationId::try_from(id_str).ok()); log::info!( "[session-db-read] terminal_pane: cwd={:?} is_active={} \ conversations={} active_conversation={:?} \ has_shell_launch_data={} has_input_config={}", terminal_pane.cwd, terminal_pane.is_active, conversation_ids_to_restore.len(), active_conversation_id, shell_launch_data.is_some(), input_config.is_some(), ); LeafContents::Terminal(TerminalPaneSnapshot { uuid: terminal_pane.uuid, cwd: terminal_pane.cwd, is_active: terminal_pane.is_active, is_read_only: false, shell_launch_data, input_config, llm_model_override: terminal_pane.llm_model_override, active_profile_id, conversation_ids_to_restore, active_conversation_id, }) } NOTEBOOK_PANE_KIND => { let notebook_pane = schema::notebook_panes::dsl::notebook_panes .find(node.id) .select(model::NotebookPane::as_select()) .first(conn)?; let notebook_id = notebook_pane.notebook_id.and_then(|id| { ClientId::from_hash(&id).map(SyncId::ClientId).or_else(|| { NotebookId::from_hash(&id).map(|id| SyncId::ServerId(id.into())) }) }); let local_path = notebook_pane.local_path.map(decode_path); // In the database schema, both the `notebook_id` and `local_path` are // nullable. It's possible for either a file pane or a notebook pane to be open // to an uneditable notebook. In that case, bias towards cloud notebooks. If // both are null, it's more likely that the pane was a new, empty cloud // notebook than an unreadable local file. LeafContents::Notebook(match local_path { Some(path) => NotebookPaneSnapshot::LocalFileNotebook { path: Some(path) }, None => NotebookPaneSnapshot::CloudNotebook { notebook_id, settings: OpenGalaxyDriveObjectSettings::default(), }, }) } WORKFLOW_PANE_KIND => { let workflow_pane = schema::workflow_panes::dsl::workflow_panes .find(node.id) .select(model::WorkflowPane::as_select()) .first(conn)?; let workflow_id = workflow_pane.workflow_id.and_then(|id| { ClientId::from_hash(&id).map(SyncId::ClientId).or_else(|| { WorkflowId::from_hash(&id).map(|id| SyncId::ServerId(id.into())) }) }); LeafContents::Workflow(WorkflowPaneSnapshot::CloudWorkflow { workflow_id, settings: OpenGalaxyDriveObjectSettings::default(), }) } CODE_PANE_KIND => { let code_pane = schema::code_panes::dsl::code_panes .find(node.id) .select(model::CodePane::as_select()) .first(conn)?; // Read child code_pane_tabs rows ordered by tab_index. let tab_rows: Vec = schema::code_pane_tabs::dsl::code_pane_tabs .filter(schema::code_pane_tabs::columns::code_pane_id.eq(code_pane.id)) .order(schema::code_pane_tabs::columns::tab_index.asc()) .select(model::CodePaneTab::as_select()) .load(conn)?; let tabs: Vec = tab_rows .into_iter() .map(|row| CodePaneTabSnapshot { path: row.local_path.map(decode_path), }) .collect(); let active_tab_index = code_pane.active_tab_index as usize; let source = code_pane .source_data .as_deref() .and_then(|data| serde_json::from_str::(data).ok()); LeafContents::Code(CodePaneSnapShot::Local { tabs, active_tab_index, source, }) } ENV_VAR_COLLECTION_PANE_KIND => { let env_var_collection_pane = schema::env_var_collection_panes::dsl::env_var_collection_panes .find(node.id) .select(model::EnvVarCollectionPane::as_select()) .first(conn)?; let env_var_collection_id = env_var_collection_pane .env_var_collection_id .and_then(|id| { ClientId::from_hash(&id).map(SyncId::ClientId).or_else(|| { GenericStringObjectId::from_hash(&id) .map(|id| SyncId::ServerId(id.into())) }) }); LeafContents::EnvVarCollection( EnvVarCollectionPaneSnapshot::CloudEnvVarCollection { env_var_collection_id, }, ) } SETTINGS_PANE_KIND => { let settings_pane = schema::settings_panes::dsl::settings_panes .find(node.id) .select(model::SettingsPane::as_select()) .first(conn)?; let current_page = SettingsSection::from_str(&settings_pane.current_page) .ok() .unwrap_or_default(); LeafContents::Settings(SettingsPaneSnapshot::Local { current_page, search_query: None, }) } AI_FACT_PANE_KIND => LeafContents::AIFact(AIFactPaneSnapshot::Personal), MCP_SERVER_PANE_KIND => { // Legacy MCP server panes are no longer supported. bail!("Legacy MCP server panes are no longer supported") } CODE_REVIEW_PANE_KIND => { let code_review_pane = schema::code_review_panes::dsl::code_review_panes .find(node.id) .select(model::CodeReviewPane::as_select()) .first(conn) .ok(); match code_review_pane { Some(pane) => LeafContents::CodeReview(CodeReviewPaneSnapshot::Local { terminal_uuid: pane.terminal_uuid, repo_path: PathBuf::from(pane.repo_path), }), None => { // Return empty fields; will be skipped during restoration LeafContents::CodeReview(CodeReviewPaneSnapshot::Local { terminal_uuid: Vec::new(), repo_path: PathBuf::from(""), }) } } } GET_STARTED_PANE_KIND => LeafContents::GetStarted, WELCOME_PANE_KIND => { let welcome_pane = schema::welcome_panes::dsl::welcome_panes .find(node.id) .select(model::WelcomePane::as_select()) .first(conn)?; LeafContents::Welcome { startup_directory: welcome_pane.startup_directory.map(PathBuf::from), } } AI_DOCUMENT_PANE_KIND => { let ai_document_pane = schema::ai_document_panes::dsl::ai_document_panes .find(node.id) .select(model::AIDocumentPane::as_select()) .first(conn)?; LeafContents::AIDocument(crate::app_state::AIDocumentPaneSnapshot::Local { document_id: ai_document_pane.document_id, version: ai_document_pane.version, content: ai_document_pane.content, title: ai_document_pane.title, }) } AMBIENT_AGENT_PANE_KIND => { let pane = schema::ambient_agent_panes::dsl::ambient_agent_panes .find(node.id) .select(model::AmbientAgentPane::as_select()) .first(conn)?; let task_id = pane .task_id .and_then(|id_str| id_str.parse::().ok()); LeafContents::AmbientAgent(AmbientAgentPaneSnapshot { uuid: pane.uuid, task_id, }) } other => bail!("Unrecognized pane kind: {other}"), }; Ok(PaneNodeSnapshot::Leaf(LeafSnapshot { is_focused: pane.is_focused, custom_vertical_tabs_title: pane.custom_vertical_tabs_title, contents, })) } false => { let pane_branch = schema::pane_branches::dsl::pane_branches .filter(schema::pane_branches::columns::pane_node_id.eq(node.id)) .first::(conn)?; let child_nodes = schema::pane_nodes::dsl::pane_nodes .filter(schema::pane_nodes::columns::parent_pane_node_id.eq(node.id)) .order(schema::pane_nodes::columns::id.asc()) .load::(conn)?; let mut children = Vec::new(); for child_node in child_nodes { children.push(( PaneFlex(child_node.flex.unwrap_or(1.)), read_node(conn, child_node)?, )); } let direction = match pane_branch.horizontal { true => SplitDirection::Horizontal, false => SplitDirection::Vertical, }; Ok(PaneNodeSnapshot::Branch(BranchSnapshot { direction, children, })) } } } /// This is not in a transaction. The interface for a transaction is a bit awkward, /// and makes it invalid to write the logic recursively. It's ok it's not in a /// transaction because we should be the only connection using the database. /// /// One notable exception is the case where there may be two warp apps running /// in the same bundle. In this case, we may read some garbage, but all that will /// happen is the user won't have session restoration. /// /// In the future, the awkwardness of the transaction interface is resolved in diesel 2.0.0. fn read_sqlite_data( conn: &mut SqliteConnection, current_user_id: Option, ) -> Result { use schema::windows::dsl::*; let active_window_id = schema::app::dsl::app .select(schema::app::dsl::active_window_id) .first::>(conn) .optional()? .flatten(); let db_windows = windows.load::(conn)?; let mut active_window_index: Option = None; let db_tabs = Tab::belonging_to(&db_windows) .order_by(schema::tabs::columns::id.asc()) .load::(conn)? .grouped_by(&db_windows); let db_panels = schema::panels::dsl::panels .load::(conn)? .into_iter() .map(|p| (p.tab_id, p)) .collect::>(); let saved_windows: Vec<_> = db_windows .into_iter() .enumerate() .zip(db_tabs) .map(|((idx, window), tabs_for_window)| { let saved_tabs: Vec<_> = tabs_for_window .into_iter() .filter_map(|tab| { let root = match read_root_node(conn, tab.id) { Ok(node) => node, Err(err) => { log::warn!( "[session-restore] Failed to read root node for tab {}: {err}. \ This tab will not be restored.", tab.id, ); return None; } }; let panel = db_panels.get(&tab.id); let left_panel = panel .and_then(|p| p.left_panel.as_ref()) .and_then(|s| serde_json::from_str::(s).ok()); let right_panel = panel .and_then(|p| p.right_panel.as_ref()) .and_then(|s| serde_json::from_str::(s).ok()); Some(TabSnapshot { root, custom_title: tab.custom_title, default_directory_color: None, selected_color: tab .color .as_deref() .and_then(|s| { serde_yaml::from_str::(s) .ok() .or_else(|| { // Fall back to the old format which stored a bare AnsiColorIdentifier serde_yaml::from_str::(s) .ok() .map(SelectedTabColor::Color) }) }) .unwrap_or_default(), left_panel, right_panel, }) }) .collect(); if active_window_id .map(|window_id| window.id == window_id) .unwrap_or(false) { active_window_index = Some(idx); } // Default active tab index to 0 if we overflow when converting. let tab_index: usize = window.active_tab_index.try_into().unwrap_or(0); let fullscreen_state_val = FullscreenState::from_i32(window.fullscreen_state).unwrap_or_default(); // The origin and size of the bound should be all null or all non-null. let bounds = match ( window.window_width, window.window_height, window.origin_x, window.origin_y, ) { (Some(mut width), Some(mut height), Some(x), Some(y)) => { // When fullscreen or maximized, the `inner_size` we snapshotted will be the // size of the full screen. This will cause problems with winit. When you set // maximized/fullscreen, setting the inner_size will by the size the window // takes _after_ the user toggles _out_ of fullscreen/maximized. Therefore, we // don't want to set the size to take the full screen because the window will // appear to remain in maximized/fullscreen. We multiply each dimension by 0.8 // to prevent taking the full screen while choosing a reasonable size. if !cfg!(target_os = "macos") && fullscreen_state_val != FullscreenState::Normal { width *= 0.8; height *= 0.8; } Some(RectF::new( Vector2F::new(x, y), Vector2F::new(width, height), )) } _ => None, }; let left_panel_width: Option = saved_tabs.get(tab_index).and_then(|tab| match tab .left_panel .as_ref() { Some(LeftPanelSnapshot { width, .. }) => Some(*width as f32), _ => None, }); let right_panel_width: Option = saved_tabs .get(tab_index) .and_then(|tab| match tab.right_panel.as_ref() { Some(RightPanelSnapshot { width, .. }) => Some(*width as f32), _ => None, }); let window_left_panel_open = window.left_panel_open.unwrap_or_else(|| { saved_tabs .get(tab_index) .and_then(|tab| tab.left_panel.as_ref()) .is_some() }); WindowSnapshot { tabs: saved_tabs, active_tab_index: tab_index, quake_mode: window.quake_mode, bounds, universal_search_width: window.universal_search_width, warp_ai_width: window.warp_ai_width, voltron_width: window.voltron_width, warp_drive_index_width: window.warp_drive_index_width, left_panel_open: window_left_panel_open, vertical_tabs_panel_open: window.vertical_tabs_panel_open.unwrap_or(false), fullscreen_state: fullscreen_state_val, left_panel_width, right_panel_width, agent_management_filters: window .agent_management_filters .and_then(|s| serde_json::from_str(&s).ok()), } }) .collect(); let object_metadata = schema::object_metadata::dsl::object_metadata.load::(conn)?; let object_permissions = schema::object_permissions::dsl::object_permissions .load::(conn)?; // Cache metadata and permissions by id so that we aren't doing an n^2 lookups for each object type. let metadata_by_id = object_metadata .into_iter() .map(|metadata| { let object_type = if metadata .object_type .starts_with(GENERIC_STRING_OBJECT_PREFIX) { GENERIC_STRING_OBJECT_PREFIX.to_owned() } else { metadata.object_type.to_owned() }; // Shareable object ids aren't unique across object types, so the object type needs to be // part of the hashmap key. For generic objects, they are all in the same table, // so it's safe to use the generic prefix as part of the key. ((metadata.shareable_object_id, object_type), metadata) }) .collect::>(); let permissions_by_id = object_permissions .into_iter() .map(|permissions| (permissions.object_metadata_id, permissions)) .collect::>(); let mut cloud_objects: Vec> = Vec::new(); cloud_objects.extend( schema::workflows::dsl::workflows .load::(conn)? .iter() .filter_map(|workflow| { metadata_by_id .get(&( workflow.id, ObjectType::Workflow.sqlite_object_type_as_str().to_string(), )) .and_then(|metadata| { let workflow_content = serde_json::from_str(workflow.data.as_str()).ok(); let workflow_id = id_from_metadata::(metadata); let permissions = permissions_by_id.get(&metadata.id)?; let cloud_object_permissions = to_cloud_object_permissions(permissions, current_user_id)?; workflow_content .zip(workflow_id) .map(|(content, workflow_id)| { let boxed: Box = Box::new(CloudWorkflow::new( workflow_id, CloudWorkflowModel::new(content), to_cloud_object_metadata(metadata), cloud_object_permissions, )); boxed }) }) }) .collect::>(), ); cloud_objects.extend( schema::notebooks::dsl::notebooks .load::(conn)? .iter() .filter_map(|notebook| { metadata_by_id .get(&( notebook.id, ObjectType::Notebook.sqlite_object_type_as_str().to_string(), )) .and_then(|metadata| { let notebook_id = id_from_metadata::(metadata); let permissions = permissions_by_id.get(&metadata.id)?; let cloud_object_permissions = to_cloud_object_permissions(permissions, current_user_id)?; notebook_id.map(|server_id| { let ai_document_id = notebook.ai_document_id.as_ref().and_then(|doc_id_str| { AIDocumentId::try_from(doc_id_str.as_str()).ok() }); let boxed: Box = Box::new(CloudNotebook::new( server_id, CloudNotebookModel { title: notebook.title.clone().unwrap_or_default(), data: notebook.data.clone().unwrap_or_default(), ai_document_id, conversation_id: None, }, to_cloud_object_metadata(metadata), cloud_object_permissions, )); boxed }) }) }) .collect::>(), ); cloud_objects.extend( schema::folders::dsl::folders .load::(conn)? .iter() .filter_map(|folder| { metadata_by_id .get(&( folder.id, ObjectType::Folder.sqlite_object_type_as_str().to_string(), )) .and_then(|metadata| { let folder_id = id_from_metadata::(metadata); let permissions = permissions_by_id.get(&metadata.id)?; let cloud_object_permissions = to_cloud_object_permissions(permissions, current_user_id)?; folder_id.map(|server_id| { let boxed: Box = Box::new(CloudFolder::new( server_id, CloudFolderModel { name: folder.name.clone(), is_open: folder.is_open, is_warp_pack: folder.is_warp_pack, }, to_cloud_object_metadata(metadata), cloud_object_permissions, )); boxed }) }) }) .collect::>(), ); cloud_objects.extend( schema::generic_string_objects::dsl::generic_string_objects .load::(conn)? .iter() .filter_map(|object| { metadata_by_id .get(&(object.id, GENERIC_STRING_OBJECT_PREFIX.to_owned())) .and_then(|metadata| { let object_id = id_from_metadata::(metadata); let permissions = permissions_by_id.get(&metadata.id)?; let cloud_object_permissions = to_cloud_object_permissions(permissions, current_user_id)?; let json_object_type: JsonObjectType = metadata .object_type .strip_prefix(&format!( "{GENERIC_STRING_OBJECT_PREFIX}{JSON_OBJECT_PREFIX}" ))? .try_into() .ok()?; object_id.and_then(|server_id| match json_object_type { JsonObjectType::Preference => { let model = CloudPreferenceModel::deserialize_owned(&object.data); model.ok().map(|model| { let boxed: Box = Box::new(CloudPreference::new( server_id, model, to_cloud_object_metadata(metadata), cloud_object_permissions, )); boxed }) } JsonObjectType::EnvVarCollection => { let model = CloudEnvVarCollectionModel::deserialize_owned(&object.data); model.ok().map(|model| { let boxed: Box = Box::new(CloudEnvVarCollection::new( server_id, model, to_cloud_object_metadata(metadata), cloud_object_permissions, )); boxed }) } JsonObjectType::WorkflowEnum => { let model = CloudWorkflowEnumModel::deserialize_owned(&object.data); model.ok().map(|model| { let boxed: Box = Box::new(CloudWorkflowEnum::new( server_id, model, to_cloud_object_metadata(metadata), cloud_object_permissions, )); boxed }) } JsonObjectType::AIFact => { let model = CloudAIFactModel::deserialize_owned(&object.data); model.ok().map(|model| { let boxed: Box = Box::new(CloudAIFact::new( server_id, model, to_cloud_object_metadata(metadata), cloud_object_permissions, )); boxed }) } JsonObjectType::MCPServer => { let model = CloudMCPServerModel::deserialize_owned(&object.data); model.ok().map(|model| { let boxed: Box = Box::new(CloudMCPServer::new( server_id, model, to_cloud_object_metadata(metadata), cloud_object_permissions, )); boxed }) } JsonObjectType::TemplatableMCPServer => { let model = CloudTemplatableMCPServerModel::deserialize_owned(&object.data); model.ok().map(|model| { let boxed: Box = Box::new(CloudTemplatableMCPServer::new( server_id, model, to_cloud_object_metadata(metadata), cloud_object_permissions, )); boxed }) } JsonObjectType::AIExecutionProfile => { let model = CloudAIExecutionProfileModel::deserialize_owned(&object.data); model.ok().map(|model| { let boxed: Box = Box::new(CloudAIExecutionProfile::new( server_id, model, to_cloud_object_metadata(metadata), cloud_object_permissions, )); boxed }) } JsonObjectType::CloudEnvironment => { let model = CloudAmbientAgentEnvironmentModel::deserialize_owned( &object.data, ); model.ok().map(|model| { let boxed: Box = Box::new(CloudAmbientAgentEnvironment::new( server_id, model, to_cloud_object_metadata(metadata), cloud_object_permissions, )); boxed }) } JsonObjectType::ScheduledAmbientAgent => { let model = CloudScheduledAmbientAgentModel::deserialize_owned( &object.data, ); model.ok().map(|model| { let boxed: Box = Box::new(CloudScheduledAmbientAgent::new( server_id, model, to_cloud_object_metadata(metadata), cloud_object_permissions, )); boxed }) } // TODO: Implement CloudAgentConfig model when full sync support is added JsonObjectType::CloudAgentConfig => None, }) }) }) .collect::>(), ); let db_teams: Vec = schema::teams::dsl::teams.load(conn)?; let team_member_rows: Vec = schema::team_members::dsl::team_members.load(conn)?; let members_by_team_id: HashMap> = team_member_rows .into_iter() .fold(HashMap::new(), |mut acc, row| { let member = crate::workspaces::team::TeamMember { uid: UserUid::new(&row.user_uid), email: row.email, role: serde_json::from_str(&row.role) .unwrap_or(crate::workspaces::team::MembershipRole::User), }; acc.entry(row.team_id).or_default().push(member); acc }); let team_settings_rows: Vec = schema::team_settings::dsl::team_settings.load(conn)?; let settings_by_team_id: HashMap = team_settings_rows .into_iter() .map(|ts| (ts.team_id, ts.settings_json)) .collect(); let teams: Vec = db_teams .into_iter() .map(|team| { let team_settings = settings_by_team_id .get(&team.id) .and_then(|json| serde_json::from_str(json).ok()); let billing_metadata = team .billing_metadata_json .as_ref() .and_then(|json| serde_json::from_str(json).ok()); let members = members_by_team_id.get(&team.id).cloned(); TeamMetadata::from_local_cache( ServerId::from_string_lossy(team.server_uid), team.name, team_settings, billing_metadata, members, ) }) .collect(); let workspace_teams: Vec = schema::workspace_teams::dsl::workspace_teams .load_iter::(conn)? .filter_map(|workspace_team| workspace_team.ok()) .collect(); let workspaces: Vec = schema::workspaces::dsl::workspaces .load_iter::(conn)? .filter_map(|workspace| { workspace.ok().map(|workspace| { let teams_for_workspace = workspace_teams .iter() .filter_map(|workspace_team| { if workspace_team.workspace_server_uid == workspace.server_uid { teams.iter().find(|team| { team.uid == ServerId::from_string_lossy(&workspace_team.team_server_uid) }) } else { None } }) .cloned() .collect(); WorkspaceMetadata::from_local_cache( workspace.server_uid.into(), workspace.name, Some(teams_for_workspace), ) }) }) .collect(); let current_workspace_uid: Option = schema::workspaces::dsl::workspaces .filter(schema::workspaces::dsl::is_selected.eq(true)) .select(schema::workspaces::dsl::server_uid) .first::(conn) .optional()? .map(|uid| uid.into()); let commands = schema::commands::dsl::commands // Ensure the commands come into memory sorted chronologically. .order(schema::commands::columns::id.desc()) .load_iter::(conn)? .filter_map(|command| command.ok()) .map(PersistedCommand::from) .collect(); let user_profiles = schema::user_profiles::dsl::user_profiles .load_iter::(conn)? .filter_map(|user_profile| user_profile.ok()) .map(UserProfileWithUID::from) .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()) .collect(); let server_experiments = schema::server_experiments::dsl::server_experiments .load_iter::(conn)? .filter_map(|server_experiment| server_experiment.ok()) .filter_map(|server_experiment| { ServerExperiment::from_string(server_experiment.experiment).ok() }) .collect(); let restored_blocks = get_all_restored_blocks(conn)?; // Load active MCP servers from database let running_mcp_servers = load_active_mcp_servers(conn)?; let app_state = AppState { windows: saved_windows, active_window_index, block_lists: Arc::new(restored_blocks), running_mcp_servers, }; // Find the smallest refresh timestamp to pass into CloudModel. let time_of_next_force_object_refresh: Option> = schema::cloud_objects_refreshes::dsl::cloud_objects_refreshes .load_iter::(conn)? .filter_map(|refresh| refresh.ok()) .map(|refresh| refresh.time_of_next_refresh.and_utc()) .min(); let ai_queries = read_ai_queries(conn)?; let codebase_indices = get_all_codebase_index_metadata(conn)?; let workspace_language_servers = get_all_workspace_language_servers_by_workspace(conn)?; let multi_agent_conversations = read_agent_conversations(conn)?; let projects = get_all_projects(conn)?; let project_rules = get_all_project_rules(conn)?; let ignored_suggestions = get_all_ignored_suggestions(conn)?; let mcp_server_installations = get_all_mcp_server_installations(conn)?; let mcp_servers_to_restore = get_mcp_servers_to_restore(conn)?; Ok(PersistedData { app_state, cloud_objects, workspaces, current_workspace_uid, command_history: commands, user_profiles, time_of_next_force_object_refresh, object_actions, experiments: server_experiments, ai_queries, codebase_indices, workspace_language_servers, multi_agent_conversations, projects, project_rules, ignored_suggestions, mcp_server_installations, mcp_servers_to_restore, }) } fn id_from_metadata(metadata: &ObjectMetadata) -> Option { match (&metadata.server_id, &metadata.client_id) { (Some(server_id), _) => { K::from_hash(server_id).map(|id| SyncId::ServerId(id.to_server_id())) } (None, Some(client_id)) => ClientId::from_hash(client_id).map(SyncId::ClientId), _ => None, } } fn to_cloud_object_metadata(metadata: &ObjectMetadata) -> CloudObjectMetadata { CloudObjectMetadata { current_editor_uid: metadata.current_editor.clone(), metadata_last_updated_ts: metadata .metadata_last_updated_ts .and_then(|epoch| ServerTimestamp::from_unix_timestamp_micros(epoch).ok()), revision: metadata .revision_ts .and_then(|epoch| Revision::from_unix_timestamp_micros(epoch).ok()), pending_changes_statuses: CloudObjectStatuses { pending_delete: false, content_sync_status: if metadata.is_pending { CloudObjectSyncStatus::InFlight(NumInFlightRequests(1)) } else { CloudObjectSyncStatus::NoLocalChanges }, has_pending_metadata_change: false, has_pending_permissions_change: false, pending_untrash: false, }, trashed_ts: metadata .trashed_ts .and_then(|epoch| ServerTimestamp::from_unix_timestamp_micros(epoch).ok()), folder_id: metadata.folder_id.as_ref().and_then(|folder_id_str| { // First, attempt to convert the string into a server id. let as_server_id = FolderId::from_hash(folder_id_str).map(|id| SyncId::ServerId(id.into())); // If the string cannot be converted to server id, it may be a client id. if as_server_id.is_none() { ClientId::from_hash(folder_id_str).map(SyncId::ClientId) } else { as_server_id } }), is_welcome_object: metadata.is_welcome_object, creator_uid: metadata.creator_uid.clone(), last_editor_uid: metadata.last_editor_uid.clone(), last_task_run_ts: None, } } fn to_cloud_object_permissions( permissions: &ObjectPermissions, default_user_id: Option, ) -> Option { let owner = owner_for_permissions(permissions, default_user_id)?; let permissions_last_updated_ts = permissions .permissions_last_updated_at .and_then(|ts| ServerTimestamp::from_unix_timestamp_micros(ts).ok()); let guests = if FeatureFlag::SharedWithMe.is_enabled() { permissions .object_guests .as_deref() // If deserializing guests fails, default to None and wait for an eventual refresh. .and_then(|guests| super::cloud_objects::decode_guests(guests).ok()) .unwrap_or_default() } else { Default::default() }; let anyone_with_link = if FeatureFlag::SharedWithMe.is_enabled() { permissions .anyone_with_link_access_level .as_deref() .and_then(|access_level| { super::cloud_objects::decode_link_sharing( access_level, permissions.anyone_with_link_source.as_deref(), ) // If deserializing link sharing fails, default to None and wait for an // eventual refresh. .ok() }) } else { None }; Some(CloudObjectPermissions { owner, permissions_last_updated_ts, guests, anyone_with_link, }) } fn owner_for_permissions( permissions: &ObjectPermissions, default_user_id: Option, ) -> Option { match permissions.subject_type.as_str() { "USER" => { let user_uid = permissions .subject_id .as_deref() .map(UserUid::new) .or(default_user_id)?; Some(Owner::User { user_uid }) } "TEAM" => Some(Owner::Team { team_uid: ServerId::from_string_lossy(&permissions.subject_uid), }), _ => None, } } impl From for model::NewCommand { fn from(metadata: StartedCommandMetadata) -> Self { Self { command: metadata.command, exit_code: None, start_ts: metadata.start_ts.map(|ts| ts.naive_utc()), completed_ts: None, pwd: metadata.pwd, shell: metadata.shell, username: metadata.username, hostname: metadata.hostname, session_id: metadata.session_id.and_then(|id| { // The `SessionID` is a wrapper around a `u64`. However diesel only allows // writing signed values for sqlite, which means we must convert it into an `i64`. // This is a shortcoming of how we represent the `SessionID`: we aren't guaranteed // (from a type safety perspective) that we can write it into SQLite. This is // another reason why the `SessionID` should be created within Rust and then passed // to our bootstrap scripts instead of the other way around: it would allow us to // create a random ID that could either be a `u16` or a `u32`. let id: u64 = id.into(); id.try_into().ok() }), git_branch: metadata.git_branch, cloud_workflow_id: metadata .cloud_workflow_id .map(|id| id.sqlite_uid_hash(ObjectIdType::Workflow)), workflow_command: metadata.workflow_command, is_agent_executed: Some(metadata.is_agent_executed), } } } fn insert_command( conn: &mut SqliteConnection, command_metadata: StartedCommandMetadata, ) -> Result<(), Error> { use schema::commands::dsl::*; conn.transaction::<(), Error, _>(|conn| { let command_count: i64 = commands.count().first(conn)?; if command_count == COMMANDS_COUNT_LIMIT { let oldest_command_id: i32 = commands.select(id).order(id.asc()).limit(1).first(conn)?; diesel::delete(commands.filter(id.eq(oldest_command_id))).execute(conn)?; } let new_command: NewCommand = command_metadata.into(); diesel::insert_into(schema::commands::dsl::commands) .values(new_command) .execute(conn)?; Ok(()) }) } fn update_finished_command( conn: &mut SqliteConnection, completed_command: FinishedCommandMetadata, ) -> Result<(), Error> { use schema::commands::dsl::*; let completed_command_session_id: Option = completed_command.session_id.as_u64().try_into().ok(); conn.transaction::<(), Error, _>(|conn| { diesel::update(commands) .filter(start_ts.eq(Some(completed_command.start_ts.naive_utc()))) .filter(session_id.eq(completed_command_session_id)) .set(( exit_code.eq(completed_command.exit_code.value()), completed_ts.eq(completed_command.completed_ts.naive_utc()), )) .execute(conn)?; Ok(()) }) } fn upsert_user_profiles( conn: &mut SqliteConnection, profiles: Vec, ) -> Result<(), Error> { use schema::user_profiles::dsl::*; conn.transaction::<(), Error, _>(|conn| { for profile in profiles { // Delete any stale profile with that uid diesel::delete( schema::user_profiles::dsl::user_profiles .filter(firebase_uid.eq(profile.firebase_uid.to_string())), ) .execute(conn)?; // Insert a new user profile row let new_user_profile = UserProfile { firebase_uid: profile.firebase_uid.to_string(), photo_url: profile.photo_url, display_name: profile.display_name, email: profile.email, }; diesel::insert_into(schema::user_profiles::dsl::user_profiles) .values(new_user_profile) .execute(conn)?; } Ok(()) }) } fn save_experiments( conn: &mut SqliteConnection, experiments: Vec, ) -> Result<(), Error> { conn.transaction::<(), Error, _>(|conn| { diesel::delete(schema::server_experiments::dsl::server_experiments).execute(conn)?; let new_experiments = experiments .into_iter() .map(|experiment| NewServerExperiment { experiment: experiment.to_string(), }) .collect_vec(); diesel::insert_into(schema::server_experiments::dsl::server_experiments) .values(new_experiments) .execute(conn)?; Ok(()) }) } fn clear_user_profiles(conn: &mut SqliteConnection) -> Result<(), Error> { conn.transaction::<(), Error, _>(|conn| { diesel::delete(schema::user_profiles::dsl::user_profiles).execute(conn)?; Ok(()) }) } fn record_time_of_next_refresh( conn: &mut SqliteConnection, timestamp: DateTime, ) -> Result<(), Error> { use schema::cloud_objects_refreshes::dsl::*; let refresh = NewCloudObjectsRefresh { time_of_next_refresh: timestamp.naive_utc(), }; conn.transaction::<(), Error, _>(|conn| { diesel::delete(cloud_objects_refreshes).execute(conn)?; diesel::insert_into(cloud_objects_refreshes) .values(refresh) .execute(conn)?; Ok(()) }) } fn upsert_current_user_information( conn: &mut SqliteConnection, user_information: PersistedCurrentUserInformation, ) -> Result<(), Error> { conn.transaction::<(), Error, _>(|conn| { diesel::delete(schema::current_user_information::dsl::current_user_information) .execute(conn)?; diesel::insert_into(schema::current_user_information::dsl::current_user_information) .values(CurrentUserInformation { email: user_information.email, }) .execute(conn)?; Ok(()) }) } fn upsert_mcp_server_environment_variables( conn: &mut SqliteConnection, mcp_server_uuid: Vec, environment_variables: String, ) -> Result<(), Error> { conn.transaction::<(), Error, _>(|conn| { let env_vars = MCPEnvironmentVariables { mcp_server_uuid, environment_variables, }; diesel::insert_into(schema::mcp_environment_variables::dsl::mcp_environment_variables) .values(&env_vars) .on_conflict(schema::mcp_environment_variables::dsl::mcp_server_uuid) .do_update() .set(&env_vars) .execute(conn)?; Ok(()) }) } fn load_active_mcp_servers(conn: &mut SqliteConnection) -> Result, Error> { use schema::active_mcp_servers::dsl::*; Ok(active_mcp_servers .load::(conn)? .into_iter() .filter_map(|active_server| uuid::Uuid::parse_str(&active_server.mcp_server_uuid).ok()) .collect()) } /// Converts the ObjectAction type into a uniform type that can be inserted into /// the sqlite table. impl From 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 insert_object_action( conn: &mut SqliteConnection, object_action: ObjectAction, ) -> Result<(), Error> { let action: NewPersistedObjectAction = object_action.into(); conn.transaction::<(), Error, _>(|conn| { diesel::insert_into(schema::object_actions::dsl::object_actions) .values(action) .execute(conn)?; Ok(()) }) } fn sync_object_actions( conn: &mut SqliteConnection, actions_to_sync: Vec, ) -> Result<(), Error> { use schema::object_actions::dsl::*; 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(); conn.transaction::<(), Error, _>(|conn| { // Erase all the actions that currently have this object ID for hashed_sqlite_id in ids_to_delete { diesel::delete(object_actions.filter(hashed_object_id.eq(hashed_sqlite_id))) .execute(conn)?; } // Insert the new ones diesel::insert_into(schema::object_actions::dsl::object_actions) .values(new_actions) .execute(conn)?; Ok(()) }) } fn delete_objects( conn: &mut SqliteConnection, ids: Vec<(SyncId, ObjectIdType)>, ) -> Result<(), Error> { conn.transaction::<(), Error, _>(|conn| { for (sync_id, object_id_type) in ids { match object_id_type { ObjectIdType::Notebook => delete_cloud_object( conn, sync_id, object_id_type, Box::new(|conn, notebook_id| { use schema::notebooks::dsl::*; diesel::delete(notebooks.filter(id.eq(notebook_id))).execute(conn)?; Ok(()) }), )?, ObjectIdType::Workflow => delete_cloud_object( conn, sync_id, object_id_type, Box::new(|conn, workflow_id| { use schema::workflows::dsl::*; diesel::delete(workflows.filter(id.eq(workflow_id))).execute(conn)?; Ok(()) }), )?, ObjectIdType::Folder => delete_cloud_object( conn, sync_id, object_id_type, Box::new(|conn, folder_id| { use schema::folders::dsl::*; diesel::delete(folders.filter(id.eq(folder_id))).execute(conn)?; Ok(()) }), )?, ObjectIdType::GenericStringObject => delete_cloud_object( conn, sync_id, object_id_type, Box::new(|conn, gso_id| { use schema::generic_string_objects::dsl::*; diesel::delete(generic_string_objects.filter(id.eq(gso_id))) .execute(conn)?; Ok(()) }), )?, } } Ok(()) }) } #[cfg(test)] #[path = "sqlite_tests.rs"] mod tests;