Files
galaxy/app/src/persistence/sqlite.rs
T
Ryan WardandClaude Opus 4.6 57c843d1de Fix CLI subagent task routing, Ctrl+C cancellation, and session restore reliability
- Construct ServerTask directly for Bedrock CLI subagents so messages route
  correctly without needing a server CreateTask upgrade
- Handle CliAgentUserQuery input type in both request translators, including
  running command context and terminal output
- Add force_cancel_all_streaming_exchanges fallback for when Ctrl+C finds no
  in-flight streams (stuck subagent / unexpected stream end)
- Cancel active conversation on Ctrl+C in agent view compose state
- Skip agent view entry when agent is tagged-in for a running command
- Set root_task_id on Bedrock requests for proper optimistic task upgrade
- Persist app state on will_terminate to avoid losing sessions
- Trust persisted CWD without is_dir() recheck (fixes network mount restore)
- Log warning instead of silently dropping tabs with unreadable root nodes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-25 08:43:10 -05:00

3793 lines
152 KiB
Rust

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<dyn FnOnce(&mut SqliteConnection, CloudObjectId) -> 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<PersistedData>, Option<WriterHandles>) {
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<SqliteConnection> {
establish_connection(database_url, true)
}
fn establish_connection(database_url: &str, read_only: bool) -> Result<SqliteConnection> {
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<SqliteConnection> {
// 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<SqliteConnection> {
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<ModelEvent>) {
// 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<ModelEvent>) {
report_if_error!(sender
.send(ModelEvent::ReconstructAndResume)
.context("Error resuming SQLite thread"));
}
fn reconstruct_database(path: &Path) -> Result<SqliteConnection> {
// 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<WriterHandles> {
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<ModelEvent>) -> Vec<ModelEvent> {
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<f32>,
parent_pane_node_id: Option<i32>,
}
// 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<NewTab> = 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<i32> = 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<NewActiveMCPServer> = 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<String> = 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<u8> {
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<u16> = 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<u8>) -> 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<Vec<ai::workspace::WorkspaceMetadata>, diesel::result::Error> {
use schema::workspace_metadata::dsl::*;
Ok(workspace_metadata
.load_iter::<WorkspaceMetadataModel, DefaultLoadingMode>(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<HashMap<PathBuf, HashMap<LSPServerType, EnablementState>>, 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<PathBuf, HashMap<LSPServerType, EnablementState>> = 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::<WorkspaceMetadataModel>(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::<model::WorkspaceLanguageServer>(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<Vec<Project>, diesel::result::Error> {
use schema::projects::dsl::*;
Ok(projects
.load_iter::<Project, DefaultLoadingMode>(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<Vec<ProjectRulePath>, diesel::result::Error> {
use schema::project_rules::dsl::*;
Ok(project_rules
.load_iter::<ProjectRules, DefaultLoadingMode>(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<ProjectRulePath>,
) -> 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<PathBuf>) -> Result<()> {
use schema::project_rules::dsl::*;
// Convert PathBuf to String for comparison
let path_strings: Vec<String> = 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<Vec<(String, SuggestionType)>, 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<HashMap<Uuid, TemplatableMCPServerInstallation>, 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<Uuid, TemplatableMCPServerInstallation> = 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<String, String>
let vars: HashMap<String, VariableValue> =
match serde_json::from_str::<HashMap<String, VariableValue>>(&vars_json) {
Ok(map) => map,
Err(_) => return None,
};
let mcp_server = match serde_json::from_str::<TemplatableMCPServer>(&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<Uuid>) -> Result<()> {
use schema::mcp_server_installations::dsl::*;
let id_strings: Vec<String> = 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<Vec<Uuid>, diesel::result::Error> {
use schema::mcp_server_installations::dsl::*;
let rows = mcp_server_installations
.filter(restore_running.eq(true))
.select(id)
.load::<String>(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::<String>(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<WorkspaceMetadata>,
) -> 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<WorkspaceUid> = workspaces
.filter(is_selected.eq(true))
.select(schema::workspaces::dsl::server_uid)
.first::<String>(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<NewWorkspace> = 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<NewTeam> = 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::<Vec<NewTeam>>()
})
.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<NewWorkspaceTeam> = 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::<Vec<NewWorkspaceTeam>>()
})
.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<NewTeamSettings> = 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<model::NewTeamMember> = 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::<String>(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::<String>(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<ServerTimestamp>,
) -> 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<Box<dyn CloudStringObject>>,
) -> 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<CloudWorkflow>,
) -> 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<CloudNotebook>,
) -> 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<CloudFolder>,
) -> 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<String>) -> Vec<AIConversationId> {
let Some(ids_str) = ids_json.as_ref() else {
return vec![];
};
let Ok(id_strings) = serde_json::from_str::<Vec<String>>(ids_str) else {
log::warn!("Failed to deserialize conversation IDs from column");
return vec![];
};
id_strings
.into_iter()
.map(AIConversationId::try_from)
.collect::<Result<Vec<_>, _>>()
.unwrap_or_else(|_| {
log::warn!("Failed to parse conversation IDs");
vec![]
})
}
fn read_root_node(conn: &mut SqliteConnection, tab_id_val: i32) -> Result<PaneNodeSnapshot> {
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<PaneNodeSnapshot> {
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::<model::PaneLeaf>(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<ShellLaunchData> = 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<model::CodePaneTab> =
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<CodePaneTabSnapshot> = 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::<CodeSource>(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::<AmbientAgentTaskId>().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::<model::PaneBranch>(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::<model::PaneNode>(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<UserUid>,
) -> Result<PersistedData, Error> {
use schema::windows::dsl::*;
let active_window_id = schema::app::dsl::app
.select(schema::app::dsl::active_window_id)
.first::<Option<i32>>(conn)
.optional()?
.flatten();
let db_windows = windows.load::<Window>(conn)?;
let mut active_window_index: Option<usize> = None;
let db_tabs = Tab::belonging_to(&db_windows)
.order_by(schema::tabs::columns::id.asc())
.load::<Tab>(conn)?
.grouped_by(&db_windows);
let db_panels = schema::panels::dsl::panels
.load::<model::Panel>(conn)?
.into_iter()
.map(|p| (p.tab_id, p))
.collect::<HashMap<_, _>>();
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::<LeftPanelSnapshot>(s).ok());
let right_panel = panel
.and_then(|p| p.right_panel.as_ref())
.and_then(|s| serde_json::from_str::<RightPanelSnapshot>(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::<SelectedTabColor>(s)
.ok()
.or_else(|| {
// Fall back to the old format which stored a bare AnsiColorIdentifier
serde_yaml::from_str::<AnsiColorIdentifier>(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<f32> = 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<f32> =
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::<model::ObjectMetadata>(conn)?;
let object_permissions = schema::object_permissions::dsl::object_permissions
.load::<model::ObjectPermissions>(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::<HashMap<_, _>>();
let permissions_by_id = object_permissions
.into_iter()
.map(|permissions| (permissions.object_metadata_id, permissions))
.collect::<HashMap<_, _>>();
let mut cloud_objects: Vec<Box<dyn CloudObject>> = Vec::new();
cloud_objects.extend(
schema::workflows::dsl::workflows
.load::<model::Workflow>(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::<WorkflowId>(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<dyn CloudObject> = Box::new(CloudWorkflow::new(
workflow_id,
CloudWorkflowModel::new(content),
to_cloud_object_metadata(metadata),
cloud_object_permissions,
));
boxed
})
})
})
.collect::<Vec<_>>(),
);
cloud_objects.extend(
schema::notebooks::dsl::notebooks
.load::<model::Notebook>(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::<NotebookId>(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<dyn CloudObject> = 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::<Vec<_>>(),
);
cloud_objects.extend(
schema::folders::dsl::folders
.load::<model::Folder>(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::<FolderId>(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<dyn CloudObject> = 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::<Vec<_>>(),
);
cloud_objects.extend(
schema::generic_string_objects::dsl::generic_string_objects
.load::<model::GenericStringObject>(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::<GenericStringObjectId>(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<dyn CloudObject> =
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<dyn CloudObject> =
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<dyn CloudObject> =
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<dyn CloudObject> = 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<dyn CloudObject> =
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<dyn CloudObject> =
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<dyn CloudObject> =
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<dyn CloudObject> =
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<dyn CloudObject> =
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::<Vec<_>>(),
);
let db_teams: Vec<model::Team> = schema::teams::dsl::teams.load(conn)?;
let team_member_rows: Vec<model::TeamMemberRow> =
schema::team_members::dsl::team_members.load(conn)?;
let members_by_team_id: HashMap<i32, Vec<crate::workspaces::team::TeamMember>> =
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<model::TeamSetting> =
schema::team_settings::dsl::team_settings.load(conn)?;
let settings_by_team_id: HashMap<i32, String> = team_settings_rows
.into_iter()
.map(|ts| (ts.team_id, ts.settings_json))
.collect();
let teams: Vec<TeamMetadata> = 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<model::WorkspaceTeam> = schema::workspace_teams::dsl::workspace_teams
.load_iter::<model::WorkspaceTeam, DefaultLoadingMode>(conn)?
.filter_map(|workspace_team| workspace_team.ok())
.collect();
let workspaces: Vec<WorkspaceMetadata> = schema::workspaces::dsl::workspaces
.load_iter::<model::Workspace, DefaultLoadingMode>(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<WorkspaceUid> = schema::workspaces::dsl::workspaces
.filter(schema::workspaces::dsl::is_selected.eq(true))
.select(schema::workspaces::dsl::server_uid)
.first::<String>(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::<model::Command, DefaultLoadingMode>(conn)?
.filter_map(|command| command.ok())
.map(PersistedCommand::from)
.collect();
let user_profiles = schema::user_profiles::dsl::user_profiles
.load_iter::<model::UserProfile, DefaultLoadingMode>(conn)?
.filter_map(|user_profile| user_profile.ok())
.map(UserProfileWithUID::from)
.collect();
let object_actions: Vec<ObjectAction> = schema::object_actions::dsl::object_actions
.load_iter::<model::PersistedObjectAction, DefaultLoadingMode>(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::<model::ServerExperiment, DefaultLoadingMode>(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<DateTime<Utc>> =
schema::cloud_objects_refreshes::dsl::cloud_objects_refreshes
.load_iter::<model::CloudObjectsRefresh, DefaultLoadingMode>(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<K: HashableId + ToServerId>(metadata: &ObjectMetadata) -> Option<SyncId> {
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<UserUid>,
) -> Option<CloudObjectPermissions> {
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<UserUid>,
) -> Option<Owner> {
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<StartedCommandMetadata> 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<i64> =
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<UserProfileWithUID>,
) -> 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<ServerExperiment>,
) -> 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<Utc>,
) -> 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<u8>,
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<Vec<uuid::Uuid>, Error> {
use schema::active_mcp_servers::dsl::*;
Ok(active_mcp_servers
.load::<ActiveMCPServer>(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<ObjectAction> 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<ObjectAction>,
) -> Result<(), Error> {
use schema::object_actions::dsl::*;
let ids_to_delete: HashSet<String> =
HashSet::from_iter(actions_to_sync.iter().map(|a| a.hashed_sqlite_id.clone()));
// Insert the new ones
let new_actions: Vec<NewPersistedObjectAction> =
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;