first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+4 -4
View File
@@ -26,10 +26,10 @@ This will create a new folder with an up.sql and down.sql.
## Step 3: Run the migration + generate the schema
```
cd <warp-internal repo>
cd <repo root>
diesel migration run --database-url="/Users/$USER/Library/Application Support/dev.warp.Warp-Local/warp.sqlite"
```
This will run the migration on the same warp that runs when you run the app locally. This automatically generates or updates the `app/src/persistence/schema.rs`. We do not make manual edits to `schema.rs`.
This will run the migration on the same warp that runs when you run the app locally. This automatically generates or updates the `crates/persistence/src/schema.rs`. We do not make manual edits to `schema.rs`.
You can also print the schema from a database that already has the migration with:
```
@@ -49,12 +49,12 @@ diesel migration redo --database-url="/Users/$USER/Library/Application Support/d
- If there's a table `foos` and a table `bars`, and `bars` has a column with a foreign key that references `foos.id`, name it `foo_id`.
# The `schema.patch` file
The `app/src/persistence/schema.patch` is manually updated by us. This file lets us make manual
The `crates/persistence/schema.patch` is manually updated by us. This file lets us make manual
changes on top of the auto-generated `schema.rs` file produced by `diesel_cli`.
To create the `schema.patch` file, we:
1. Run the diesel migrations
1. Manually edit `schema.rs`
1. Run `git diff -U6 > app/src/persistence/schema.patch`.
1. Run `git diff -U6 > crates/persistence/schema.patch`.
You can read more about this patch file in the official [Diesel documentation](https://diesel.rs/guides/configuring-diesel-cli.html#the-patch_file-field).
+122 -20
View File
@@ -1,7 +1,11 @@
use diesel::associations::HasTable;
use diesel::{prelude::*, result::Error, SqliteConnection};
use prost::Message;
use std::collections::{HashMap, HashSet};
use chrono::NaiveDateTime;
use diesel::associations::HasTable;
use diesel::prelude::*;
use diesel::result::Error;
use diesel::SqliteConnection;
use prost::Message;
use warp_multi_agent_api as api;
use super::model::{AgentConversation, AgentConversationData};
@@ -31,17 +35,21 @@ pub(super) enum UpsertConversationError {
DB(#[from] diesel::result::Error),
}
/// Maximum number of `agent_conversations` rows we retain on disk before
/// `select_conversations_to_evict` starts dropping trees. 200 gives roughly
/// 1040 orchestration sessions of headroom; trees are kept atomically, so
/// an active session is never split even if it pushes past the cap.
pub(super) const MAX_PERSISTED_CONVERSATION_COUNT: usize = 200;
pub(super) fn upsert_agent_conversation<'a>(
conn: &mut SqliteConnection,
conversation_id_param: &str,
tasks: impl IntoIterator<Item = &'a api::Task>,
conversation_data_param: AgentConversationData,
) -> Result<(), UpsertConversationError> {
use diesel::ExpressionMethods;
use diesel::QueryDsl;
use schema::agent_conversations::dsl::*;
use schema::agent_tasks::dsl as tasks_dsl;
const MAX_PERSISTED_CONVERSATION_COUNT: i64 = 100;
let serialized_conversation_data = serde_json::to_string(&conversation_data_param)?;
@@ -80,17 +88,21 @@ pub(super) fn upsert_agent_conversation<'a>(
}
}
// Prune old conversations if we exceed MAX_PERSISTED_CONVERSATION_COUNT conversations
// Prune old conversations if we exceed MAX_PERSISTED_CONVERSATION_COUNT.
//
// Eviction is tree-aware: parents and children are an atomic unit, so
// we never delete a parent whose child still lives in the DB (or vice
// versa). See `select_conversations_to_evict`.
let conversation_count: i64 = agent_conversations::table().count().get_result(conn)?;
if conversation_count > MAX_PERSISTED_CONVERSATION_COUNT {
// Remove the oldest conversations, keeping only the most recent MAX_PERSISTED_CONVERSATION_COUNT
let conversations_to_remove: Vec<String> = agent_conversations::table()
.order(last_modified_at.asc())
.limit(conversation_count - MAX_PERSISTED_CONVERSATION_COUNT)
.select(conversation_id)
if conversation_count > MAX_PERSISTED_CONVERSATION_COUNT as i64 {
let all_rows: Vec<AgentConversationRecord> = agent_conversations::table()
.select(AgentConversationRecord::as_select())
.load(conn)?;
delete_agent_conversations(conn, conversations_to_remove)?;
let conversations_to_remove =
select_conversations_to_evict(&all_rows, MAX_PERSISTED_CONVERSATION_COUNT);
if !conversations_to_remove.is_empty() {
delete_agent_conversations(conn, conversations_to_remove)?;
}
}
Ok(())
@@ -99,10 +111,100 @@ pub(super) fn upsert_agent_conversation<'a>(
Ok(())
}
/// Evicts whole orchestration trees so the remaining set fits within `limit`.
/// Trees are sorted freshest-first by `max(member.last_modified_at)` (ties
/// broken by `root_id` ASC); the freshest tree is always retained, every
/// older tree is kept only if cumulative kept rows + tree size ≤ `limit`,
/// and once any tree exceeds the budget every older tree is evicted as well.
/// Parse failures and orphan parent references are treated as their own
/// root rather than linked into another tree. Returns a stable
/// `conversation_id`-sorted vector.
pub(super) fn select_conversations_to_evict(
rows: &[AgentConversationRecord],
limit: usize,
) -> Vec<String> {
if rows.len() <= limit {
return Vec::new();
}
// Map each row to its declared parent, but only when that parent is
// itself in `rows`; orphan references collapse to a root.
let row_set: HashSet<&str> = rows.iter().map(|r| r.conversation_id.as_str()).collect();
let parent_by_id: HashMap<&str, Option<String>> = rows
.iter()
.map(|r| {
let parent = serde_json::from_str::<AgentConversationData>(&r.conversation_data)
.ok()
.and_then(|d| d.parent_conversation_id)
.filter(|p| row_set.contains(p.as_str()));
(r.conversation_id.as_str(), parent)
})
.collect();
fn find_root<'a>(start: &'a str, parent_by_id: &'a HashMap<&str, Option<String>>) -> &'a str {
let mut current = start;
let mut seen: HashSet<&str> = HashSet::new();
loop {
// Defensive: cycle entries become their own root.
if !seen.insert(current) {
return current;
}
match parent_by_id.get(current) {
Some(Some(p)) => current = p.as_str(),
_ => return current,
}
}
}
let mut trees: HashMap<String, Vec<&AgentConversationRecord>> = HashMap::new();
for row in rows {
let root = find_root(row.conversation_id.as_str(), &parent_by_id).to_owned();
trees.entry(root).or_default().push(row);
}
let mut tree_list: Vec<(NaiveDateTime, String, Vec<&AgentConversationRecord>)> = trees
.into_iter()
.map(|(root, members)| {
let effective = members
.iter()
.map(|r| r.last_modified_at)
.max()
.expect("tree always has at least one member by construction");
(effective, root, members)
})
.collect();
tree_list.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
let mut kept_count: usize = 0;
let mut evicted: Vec<String> = Vec::new();
let mut tree_iter = tree_list.into_iter();
// Freshest tree is always retained, even when it alone exceeds `limit`.
if let Some((_effective, _root, members)) = tree_iter.next() {
kept_count += members.len();
}
let mut stopped = false;
for (_effective, _root, members) in tree_iter {
let tree_size = members.len();
let keep_this = !stopped && kept_count + tree_size <= limit;
if keep_this {
kept_count += tree_size;
} else {
stopped = true;
for m in &members {
evicted.push(m.conversation_id.clone());
}
}
}
evicted.sort();
evicted
}
pub(super) fn read_agent_conversations(
conn: &mut SqliteConnection,
) -> Result<Vec<AgentConversation>, diesel::result::Error> {
use schema::agent_conversations::dsl::*;
let mut conversations_by_id = HashMap::<String, AgentConversation>::from_iter(
agent_conversations
@@ -152,7 +254,6 @@ pub(crate) fn read_agent_conversation_by_id(
conversation_id_str: &str,
) -> Result<Option<AgentConversation>, diesel::result::Error> {
use schema::agent_conversations::dsl as convo_dsl;
use schema::agent_tasks::dsl as tasks_dsl;
let maybe_record: Option<AgentConversationRecord> = convo_dsl::agent_conversations
.filter(convo_dsl::conversation_id.eq(conversation_id_str.to_owned()))
@@ -189,10 +290,7 @@ pub(super) fn delete_agent_conversations(
conn: &mut SqliteConnection,
conversation_ids: Vec<String>,
) -> Result<(), diesel::result::Error> {
use diesel::ExpressionMethods;
use diesel::QueryDsl;
use schema::agent_conversations::dsl::*;
use schema::agent_tasks::dsl as tasks_dsl;
use diesel::{ExpressionMethods, QueryDsl};
conn.transaction::<_, Error, _>(|conn| {
// Delete tasks for these conversations first (due to foreign key constraint)
@@ -212,3 +310,7 @@ pub(super) fn delete_agent_conversations(
Ok(())
}
#[cfg(test)]
#[path = "agent_tests.rs"]
mod tests;
+177
View File
@@ -0,0 +1,177 @@
use chrono::NaiveDate;
use super::*;
fn data_with_parent(parent: Option<&str>) -> String {
match parent {
Some(p) => {
format!(r#"{{"server_conversation_token":null,"parent_conversation_id":"{p}"}}"#)
}
None => r#"{"server_conversation_token":null}"#.to_string(),
}
}
fn ts(secs_from_epoch: i64) -> NaiveDateTime {
// 2026-01-01 baseline keeps failure messages readable.
NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap()
+ chrono::Duration::seconds(secs_from_epoch)
}
fn make_row(
id: i32,
conversation_id: &str,
parent: Option<&str>,
secs: i64,
) -> AgentConversationRecord {
AgentConversationRecord {
id,
conversation_id: conversation_id.to_string(),
conversation_data: data_with_parent(parent),
last_modified_at: ts(secs),
}
}
/// Row count ≤ limit ⇒ no eviction, regardless of tree shape.
#[test]
fn prune_is_no_op_when_under_limit() {
let rows = vec![
make_row(1, "a", None, 100),
make_row(2, "b", None, 200),
make_row(3, "c", None, 300),
];
assert!(select_conversations_to_evict(&rows, 3).is_empty());
assert!(select_conversations_to_evict(&rows, 100).is_empty());
}
/// A tree's effective timestamp is the max of its members; older standalone
/// rows get evicted instead of splitting a fresh tree.
#[test]
fn keeps_fresh_tree_atomically_and_evicts_older_singletons() {
let mut rows = vec![
make_row(1, "root", None, 100), // parent, older
make_row(2, "c1", Some("root"), 500),
make_row(3, "c2", Some("root"), 1000),
make_row(4, "c3", Some("root"), 1500),
make_row(5, "c4", Some("root"), 2000),
];
for i in 0_i32..9 {
let id = format!("s{i}");
rows.push(make_row(100 + i, &id, None, 10 + i64::from(i)));
}
let evicted = select_conversations_to_evict(&rows, 13);
assert_eq!(evicted.len(), 1, "evicted={evicted:?}");
assert_eq!(evicted[0], "s0", "must evict the oldest singleton");
for tree_member in ["root", "c1", "c2", "c3", "c4"] {
assert!(
!evicted.contains(&tree_member.to_string()),
"tree member {tree_member} was evicted; evicted={evicted:?}"
);
}
}
/// A stale parent is kept when its child is fresh: tree ts = max(members).
#[test]
fn child_kept_drags_parent_along() {
let mut rows = vec![
make_row(1, "parent", None, 1), // very old parent
make_row(2, "child", Some("parent"), 9_999), // very fresh child
];
for i in 0_i32..8 {
let id = format!("s{i}");
rows.push(make_row(100 + i, &id, None, 100 + i64::from(i)));
}
let evicted = select_conversations_to_evict(&rows, 9);
assert_eq!(evicted.len(), 1, "evicted={evicted:?}");
assert!(!evicted.contains(&"parent".to_string()));
assert!(!evicted.contains(&"child".to_string()));
assert_eq!(evicted[0], "s0");
}
/// Reverse of the previous case: a stale child is kept when its parent is
/// fresh.
#[test]
fn parent_kept_drags_child_along() {
let mut rows = vec![
make_row(1, "parent", None, 9_999), // very fresh parent
make_row(2, "child", Some("parent"), 1), // very old child
];
for i in 0_i32..8 {
let id = format!("s{i}");
rows.push(make_row(100 + i, &id, None, 100 + i64::from(i)));
}
let evicted = select_conversations_to_evict(&rows, 9);
assert_eq!(evicted.len(), 1, "evicted={evicted:?}");
assert!(!evicted.contains(&"parent".to_string()));
assert!(
!evicted.contains(&"child".to_string()),
"stale child must not be evicted while its parent is kept; evicted={evicted:?}"
);
assert_eq!(evicted[0], "s0");
}
/// Orphans (declared parent missing from row set) are their own root.
#[test]
fn orphan_with_missing_parent_is_its_own_tree() {
let rows = vec![
make_row(1, "orphan", Some("missing_parent_id"), 9_999), // fresh
make_row(2, "a", None, 100),
make_row(3, "b", None, 200),
make_row(4, "c", None, 300),
];
let evicted = select_conversations_to_evict(&rows, 3);
assert_eq!(evicted.len(), 1, "evicted={evicted:?}");
assert_eq!(evicted[0], "a");
assert!(!evicted.contains(&"orphan".to_string()));
}
/// The freshest tree is retained even when it alone exceeds the cap, so we
/// never split an active orchestration session.
#[test]
fn single_tree_larger_than_limit_is_kept_in_full() {
let mut rows = vec![make_row(1, "big_root", None, 10_000)];
for i in 0_i32..199 {
let cid = format!("big_child_{i}");
rows.push(make_row(2 + i, &cid, Some("big_root"), 100 + i64::from(i)));
}
rows.push(make_row(9_999, "older_singleton", None, 50));
let evicted = select_conversations_to_evict(&rows, 50);
assert_eq!(evicted, vec!["older_singleton".to_string()]);
}
/// A parse-failure row is still a valid parent reference: it just becomes
/// its own root rather than getting quarantined out of the parent index.
#[test]
fn parse_failure_row_is_treated_as_root_and_can_be_referenced_by_others() {
let mut rows = vec![
AgentConversationRecord {
id: 1,
conversation_id: "garbage".to_string(),
conversation_data: "{not valid json".to_string(),
last_modified_at: ts(50),
},
make_row(2, "a", None, 100),
make_row(3, "b", None, 200),
make_row(4, "c", None, 300),
];
rows.push(make_row(5, "child_of_garbage", Some("garbage"), 9_999));
let evicted = select_conversations_to_evict(&rows, 4);
assert_eq!(evicted, vec!["a".to_string()]);
}
/// Same input twice produces the same output. Tie-broken by root_id ASC.
#[test]
fn eviction_is_deterministic() {
let rows = vec![
make_row(1, "a", None, 100),
make_row(2, "b", None, 100),
make_row(3, "c", None, 100),
make_row(4, "d", None, 100),
];
let e1 = select_conversations_to_evict(&rows, 2);
let e2 = select_conversations_to_evict(&rows, 2);
assert_eq!(e1, e2);
assert_eq!(e1, vec!["c".to_string(), "d".to_string()]);
}
+51 -9
View File
@@ -1,18 +1,20 @@
//! Manages how we write to and read from our SQLite database for our AI features.
use std::{collections::HashMap, sync::Arc};
use std::collections::HashMap;
use std::sync::Arc;
use chrono::{Local, NaiveDateTime, TimeZone};
use diesel::{prelude::*, result::Error, sqlite::SqliteConnection};
use diesel::prelude::*;
use diesel::result::Error;
use diesel::sqlite::SqliteConnection;
use itertools::Itertools;
use crate::ai::blocklist::{PersistedAIInput, SerializedBlockListItem};
use crate::terminal::model::block::{SerializedAgentViewVisibility, SerializedBlock};
use crate::{app_state::PaneUuid, persistence::schema::ai_queries};
use super::model::Block;
use super::{model, schema};
use crate::ai::blocklist::{PersistedAIInput, SerializedBlockListItem};
use crate::app_state::PaneUuid;
use crate::persistence::schema::ai_queries;
use crate::terminal::model::block::{SerializedAgentViewVisibility, SerializedBlock};
const MAX_TERMINAL_BLOCKS_TO_PERSIST_PER_SESSION: i64 = 100;
@@ -104,15 +106,53 @@ pub(super) fn read_ai_queries(
.collect_vec())
}
const AI_QUERIES_COUNT_LIMIT: i64 = 10_000;
pub(super) fn upsert_ai_query(
conn: &mut SqliteConnection,
query: Arc<PersistedAIInput>,
) -> anyhow::Result<()> {
upsert_ai_query_with_limit(conn, query, AI_QUERIES_COUNT_LIMIT)
}
/// Upserts an AI query while keeping the `ai_queries` table capped at `limit` rows by evicting
/// the oldest queries (FIFO by `id`). Split out from [`upsert_ai_query`] so tests can exercise the
/// eviction path with a small limit instead of inserting `AI_QUERIES_COUNT_LIMIT` rows.
fn upsert_ai_query_with_limit(
conn: &mut SqliteConnection,
query: Arc<PersistedAIInput>,
limit: i64,
) -> anyhow::Result<()> {
use schema::ai_queries::dsl::*;
let new_ai_query = NewAIQuery::try_from(query.as_ref())?;
Ok(conn.transaction::<_, Error, _>(|conn| {
// Only a genuinely new exchange grows the table.
let is_new_exchange = ai_queries
.filter(exchange_id.eq(&new_ai_query.exchange_id))
.count()
.first::<i64>(conn)?
== 0;
if is_new_exchange {
let query_count: i64 = ai_queries.count().first(conn)?;
// add 1 because we are about to insert a new row.
let diff = query_count - limit + 1;
if diff > 0 {
// Find the oldest row to keep and evict everything older (FIFO).
let last_kept_id: Option<i32> = ai_queries
.select(id)
.order(id.asc())
.offset(diff)
.limit(1)
.first(conn)
.optional()?;
if let Some(last_kept_id) = last_kept_id {
diesel::delete(ai_queries.filter(id.lt(last_kept_id))).execute(conn)?;
}
}
}
diesel::insert_into(ai_queries)
.values(&new_ai_query)
.on_conflict(exchange_id)
@@ -251,7 +291,6 @@ fn create_block<'a>(
}
pub(super) fn delete_blocks(conn: &mut SqliteConnection, pane_id: Vec<u8>) -> Result<(), Error> {
use schema::blocks::dsl::*;
conn.transaction::<_, Error, _>(|conn| {
diesel::delete(schema::blocks::dsl::blocks.filter(pane_leaf_uuid.eq(pane_id.clone())))
.execute(conn)?;
@@ -264,7 +303,6 @@ pub(super) fn update_block_agent_view_visibility(
target_block_id: &str,
visibility: &SerializedAgentViewVisibility,
) -> anyhow::Result<()> {
use schema::blocks::dsl::*;
let visibility_json = serde_json::to_string(visibility)?;
diesel::update(blocks.filter(block_id.eq(target_block_id)))
.set(agent_view_visibility.eq(visibility_json))
@@ -290,3 +328,7 @@ pub(super) fn delete_ai_conversation(
Ok(())
}
#[cfg(test)]
#[path = "block_list_tests.rs"]
mod tests;
+197
View File
@@ -0,0 +1,197 @@
//! Unit tests for the `ai_queries` persistence layer in [`super`].
//!
//! Covers the FIFO eviction cap added to [`super::upsert_ai_query`] and the empty-input filter
//! that drives the persistence skip in `handle_ai_history_event`.
use std::sync::Arc;
use chrono::Local;
use diesel::sqlite::SqliteConnection;
use diesel::{Connection, ExpressionMethods, QueryDsl, RunQueryDsl};
use diesel_migrations::MigrationHarness;
use super::upsert_ai_query_with_limit;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{AIAgentExchangeId, AIAgentInput, UserQueryMode};
use crate::ai::blocklist::{AIQueryHistoryOutputStatus, PersistedAIInput, PersistedAIInputType};
use crate::ai::llms::LLMId;
/// Builds an in-memory SQLite database with all migrations applied.
fn test_connection() -> SqliteConnection {
let mut conn =
SqliteConnection::establish(":memory:").expect("in-memory sqlite connection should open");
conn.run_pending_migrations(::persistence::MIGRATIONS)
.expect("migrations should run");
conn
}
/// Builds a query-bearing [`PersistedAIInput`] with a fresh, unique `exchange_id`.
fn make_query(text: &str) -> Arc<PersistedAIInput> {
Arc::new(PersistedAIInput {
exchange_id: AIAgentExchangeId::new(),
conversation_id: AIConversationId::new(),
start_ts: Local::now(),
inputs: vec![PersistedAIInputType::Query {
text: text.to_string(),
context: Default::default(),
referenced_attachments: Default::default(),
}],
output_status: AIQueryHistoryOutputStatus::Completed,
working_directory: None,
model_id: LLMId::from("test-model"),
coding_model_id: LLMId::from("test-coding-model"),
})
}
fn ai_query_count(conn: &mut SqliteConnection) -> i64 {
use crate::persistence::schema::ai_queries::dsl::ai_queries;
ai_queries
.count()
.first(conn)
.expect("count query should succeed")
}
/// Returns the persisted `exchange_id`s ordered by `id` ascending (i.e. insertion / FIFO order).
fn remaining_exchange_ids(conn: &mut SqliteConnection) -> Vec<String> {
use crate::persistence::schema::ai_queries::dsl::{ai_queries, exchange_id, id};
ai_queries
.select(exchange_id)
.order(id.asc())
.load::<String>(conn)
.expect("load query should succeed")
}
fn input_json_for_exchange(conn: &mut SqliteConnection, exchange: &str) -> String {
use crate::persistence::schema::ai_queries::dsl::{ai_queries, exchange_id, input};
ai_queries
.filter(exchange_id.eq(exchange))
.select(input)
.first::<String>(conn)
.expect("row for exchange should exist")
}
#[test]
fn upsert_ai_query_caps_table_and_evicts_oldest_first() {
let mut conn = test_connection();
let limit = 3;
// Insert five distinct exchanges into a table capped at three.
let queries: Vec<Arc<PersistedAIInput>> =
(0..5).map(|i| make_query(&format!("q{i}"))).collect();
let exchange_ids: Vec<String> = queries.iter().map(|q| q.exchange_id.to_string()).collect();
for query in &queries {
upsert_ai_query_with_limit(&mut conn, query.clone(), limit).expect("upsert should succeed");
}
// The table never exceeds the limit.
assert_eq!(ai_query_count(&mut conn), limit);
// The two oldest (q0, q1) are evicted; the three newest remain in insertion order.
assert_eq!(
remaining_exchange_ids(&mut conn),
exchange_ids[2..].to_vec()
);
}
#[test]
fn upsert_ai_query_stays_below_limit_without_evicting() {
let mut conn = test_connection();
let limit = 3;
// Filling exactly up to the limit should not evict anything.
let queries: Vec<Arc<PersistedAIInput>> =
(0..3).map(|i| make_query(&format!("q{i}"))).collect();
let exchange_ids: Vec<String> = queries.iter().map(|q| q.exchange_id.to_string()).collect();
for query in &queries {
upsert_ai_query_with_limit(&mut conn, query.clone(), limit).expect("upsert should succeed");
}
assert_eq!(ai_query_count(&mut conn), limit);
assert_eq!(remaining_exchange_ids(&mut conn), exchange_ids);
}
#[test]
fn upsert_ai_query_updates_existing_exchange_without_evicting() {
let mut conn = test_connection();
let limit = 2;
// Fill the table to its limit with two distinct exchanges.
let first = make_query("first");
let second = make_query("second");
upsert_ai_query_with_limit(&mut conn, first.clone(), limit).expect("upsert should succeed");
upsert_ai_query_with_limit(&mut conn, second.clone(), limit).expect("upsert should succeed");
assert_eq!(ai_query_count(&mut conn), limit);
// Re-upsert the oldest exchange (same `exchange_id`) repeatedly. Because this is an update of
// an existing exchange rather than a new one, it must update in place and never evict.
let updated_first = Arc::new(PersistedAIInput {
inputs: vec![PersistedAIInputType::Query {
text: "first-updated".to_string(),
context: Default::default(),
referenced_attachments: Default::default(),
}],
..(*first).clone()
});
for _ in 0..5 {
upsert_ai_query_with_limit(&mut conn, updated_first.clone(), limit)
.expect("upsert should succeed");
}
// Still exactly two rows, and both original exchanges survive (the oldest was not evicted).
assert_eq!(ai_query_count(&mut conn), limit);
assert_eq!(
remaining_exchange_ids(&mut conn),
vec![
first.exchange_id.to_string(),
second.exchange_id.to_string()
]
);
// The in-place update took effect.
let input_json = input_json_for_exchange(&mut conn, &first.exchange_id.to_string());
assert!(
input_json.contains("first-updated"),
"existing row should have been updated in place, got: {input_json}"
);
}
#[test]
fn empty_input_skip_filters_out_non_query_inputs() {
// Mirrors the filter in `handle_ai_history_event`: only query-bearing inputs are persisted.
// An exchange whose inputs are all non-query types collapses to an empty `inputs` vec, which
// is the exact condition that skips persistence.
let user_query = AIAgentInput::UserQuery {
query: "hello".to_string(),
context: Default::default(),
static_query_type: None,
referenced_attachments: Default::default(),
user_query_mode: UserQueryMode::default(),
running_command: None,
intended_agent: None,
};
let non_query = AIAgentInput::ResumeConversation {
context: Default::default(),
};
// A query input is persistable; a non-query input is not.
assert!(PersistedAIInputType::try_from(&user_query).is_ok());
assert!(PersistedAIInputType::try_from(&non_query).is_err());
// An exchange carrying only non-query inputs collapses to empty -> skipped.
let only_non_query = [non_query];
let persisted: Vec<_> = only_non_query
.iter()
.filter_map(|input| PersistedAIInputType::try_from(input).ok())
.collect();
assert!(persisted.is_empty());
// An exchange carrying a query input is persisted.
let with_query = [user_query];
let persisted: Vec<_> = with_query
.iter()
.filter_map(|input| PersistedAIInputType::try_from(input).ok())
.collect();
assert_eq!(persisted.len(), 1);
}
-72
View File
@@ -1,72 +0,0 @@
use lazy_static::lazy_static;
use session_sharing_protocol::common::{InputReplicaId, ProfileData};
use crate::{
auth::UserUid,
cloud_object::{CloudObjectGuest, ServerObjectContainer},
drive::sharing::{LinkSharingSubjectType, SharingAccessLevel, Subject, TeamKind, UserKind},
server::ids::ServerId,
};
#[test]
fn test_roundtrip_guests() {
let guests = vec![
CloudObjectGuest {
subject: Subject::User(UserKind::Account(UserUid::new("firebase_uid"))),
access_level: SharingAccessLevel::Edit,
source: None,
},
CloudObjectGuest {
subject: Subject::PendingUser {
email: Some("pending@warp.dev".to_string()),
},
access_level: SharingAccessLevel::View,
source: Some(ServerObjectContainer::Folder {
folder_uid: 123.into(),
}),
},
CloudObjectGuest {
subject: Subject::Team(TeamKind::Team {
team_uid: ServerId::from(99),
}),
access_level: SharingAccessLevel::Edit,
source: None,
},
];
let encoded = super::encode_guests(&guests).expect("encode should succeed");
let decoded = super::decode_guests(&encoded).expect("decode should succeed");
assert_eq!(guests, decoded);
}
lazy_static! {
/// By construction, [`CloudObjectGuest`] only accepts `'static`-lifetime [`Subject`]s.
///
/// In most cases, this would prevent persisting a shared session subject, but we work around
/// it here for completeness;
static ref PROFILE_DATA: ProfileData = ProfileData {
firebase_uid: "2YP93GScglXJMdEr2Id12dI7HCG3".to_string(),
display_name: "Some User".to_string(),
photo_url: Some("http://example.com/some-image".to_string()),
email: Some("user@warp.dev".to_string()),
input_replica_id: InputReplicaId::from("some-id".to_string()),
};
}
#[test]
fn test_fail_unsupported_subjects() {
let result = super::encode_guests(&[CloudObjectGuest {
subject: Subject::AnyoneWithLink(LinkSharingSubjectType::Anyone),
access_level: SharingAccessLevel::View,
source: None,
}]);
assert!(result.is_err());
let result = super::encode_guests(&[CloudObjectGuest {
subject: Subject::User(UserKind::SharedSessionParticipant(PROFILE_DATA.clone())),
access_level: SharingAccessLevel::View,
source: None,
}]);
assert!(result.is_err());
}
+2 -1
View File
@@ -1,5 +1,6 @@
use anyhow::Result;
use diesel::{sqlite::SqliteConnection, ExpressionMethods, QueryDsl, RunQueryDsl};
use diesel::sqlite::SqliteConnection;
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
use crate::terminal::event::UserBlockCompleted;
+18 -16
View File
@@ -4,7 +4,6 @@ cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
pub mod agent;
mod block_list;
mod cloud_objects;
mod sqlite;
pub mod commands;
}
@@ -17,30 +16,32 @@ pub use persistence::schema;
#[cfg(feature = "integration_tests")]
pub mod testing;
use instant::Instant;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::mpsc::SyncSender;
use std::sync::Arc;
use std::thread::JoinHandle;
use crate::ai::persisted_workspace::EnablementState;
use ai::project_context::model::ProjectRulePath;
use ai::workspace::WorkspaceMetadata as CodeWorkspaceMetadata;
use chrono::{DateTime, Local, Utc};
use galaxy_core::command::ExitCode;
use galaxy_graphql::scalars::time::ServerTimestamp;
use galaxyui::{AppContext, Entity, SingletonEntity};
use instant::Instant;
use lsp::supported_servers::LSPServerType;
#[cfg(any(feature = "local_fs", feature = "integration_tests"))]
pub use sqlite::database_file_path_for_scope;
#[cfg(any(feature = "local_fs", feature = "integration_tests"))]
pub use sqlite::establish_ro_connection;
use uuid::Uuid;
use warp_multi_agent_api as api;
use self::model::{AgentConversation, AgentConversationData, Project};
use crate::ai::blocklist::PersistedAIInput;
use crate::ai::mcp::TemplatableMCPServerInstallation;
use crate::ai::persisted_workspace::EnablementState;
use crate::app_state::AppState;
use crate::auth::auth_manager::PersistedCurrentUserInformation;
use crate::cloud_object::model::actions::ObjectAction;
use crate::cloud_object::model::generic_string_model::CloudStringObject;
use crate::cloud_object::{
CloudObject, CloudObjectMetadata, ObjectIdType, RevisionAndLastEditor, ServerCreationInfo,
};
@@ -55,25 +56,26 @@ use crate::terminal::model::session::SessionId;
use crate::workflows::CloudWorkflow;
use crate::workspaces::user_profiles::UserProfileWithUID;
use crate::workspaces::workspace::{Workspace as WorkspaceMetadata, WorkspaceUid};
use ai::workspace::WorkspaceMetadata as CodeWorkspaceMetadata;
use self::model::{AgentConversation, AgentConversationData, Project};
#[cfg(any(feature = "local_fs", feature = "integration_tests"))]
pub use sqlite::database_file_path;
#[cfg(any(feature = "local_fs", feature = "integration_tests"))]
pub use sqlite::establish_ro_connection;
pub enum PersistenceScope {
App,
RemoteServerDaemon { identity_key: String },
}
/// Initializes the persistence "subsystem".
///
/// Returns the previously-persisted data, if any, and handles for
/// writing updated data to persist, if the persistence subsystem is
/// available.
#[tracing::instrument(name = "persistence::initialize", skip_all, fields(tags.cloud_agent = true))]
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
pub fn initialize(ctx: &mut AppContext) -> (Option<PersistedData>, Option<WriterHandles>) {
pub fn initialize(
ctx: &mut AppContext,
scope: PersistenceScope,
) -> (Option<Box<PersistedData>>, Option<WriterHandles>) {
cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
sqlite::initialize(ctx)
sqlite::initialize(ctx, scope)
} else {
(None, None)
}
File diff suppressed because it is too large Load Diff
+543 -19
View File
@@ -1,27 +1,170 @@
use std::{path::PathBuf, sync::Arc};
use std::path::PathBuf;
use std::sync::Arc;
use ai::workspace::WorkspaceMetadata;
use chrono::Utc;
use cloud_object_persistence::to_cloud_object_permissions;
use diesel::connection::SimpleConnection;
use pathfinder_geometry::rect::RectF;
use pathfinder_geometry::vector::Vector2F;
use galaxy_core::features::FeatureFlag;
use galaxy_graphql::scalars::time::ServerTimestamp;
use crate::{
app_state::{
AppState, CodePaneSnapShot, CodePaneTabSnapshot, LeafContents, LeafSnapshot,
PaneNodeSnapshot, TabSnapshot, TerminalPaneSnapshot, WindowSnapshot,
},
cloud_object::{CloudObjectPermissions, Owner},
code::editor_management::CodeSource,
notebooks::{CloudNotebook, CloudNotebookModel},
persistence::{model::ObjectPermissions, BlockCompleted, ModelEvent},
server::ids::ClientId,
tab::SelectedTabColor,
terminal::model::block::SerializedBlock,
terminal::ShellLaunchData,
};
use super::{
decode_path, deduplicate_events, encode_path, read_sqlite_data, save_app_state, setup_database,
app_database_file_path, database_file_path_for_scope, decode_path, deduplicate_events,
encode_path, get_all_codebase_index_metadata, read_sqlite_data, save_app_state,
save_codebase_index_metadata, setup_database, start_writer,
};
use crate::app_state::{
AppState, CodePaneSnapShot, CodePaneTabSnapshot, LeafContents, LeafSnapshot, PaneNodeSnapshot,
TabGroupSnapshot, TabSnapshot, TerminalPaneSnapshot, WindowSnapshot,
};
use crate::cloud_object::{CloudObjectPermissions, Owner};
use crate::code::editor_management::CodeSource;
use crate::notebooks::{CloudNotebook, CloudNotebookModel};
use crate::persistence::model::ObjectPermissions;
use crate::persistence::{BlockCompleted, ModelEvent, PersistenceScope};
use crate::server::ids::ClientId;
use crate::tab::SelectedTabColor;
use crate::terminal::model::block::SerializedBlock;
use crate::terminal::ShellLaunchData;
use crate::themes::theme::AnsiColorIdentifier;
use crate::workspace::tab_group::TabGroupId;
#[test]
fn app_scope_database_path_matches_app_database_path() {
assert_eq!(
database_file_path_for_scope(&PersistenceScope::App),
app_database_file_path()
);
}
#[test]
fn remote_server_daemon_scope_database_path_uses_identity_data_dir() {
let path = database_file_path_for_scope(&PersistenceScope::RemoteServerDaemon {
identity_key: "user@example.com/ssh host".to_string(),
});
let expected_data_dir =
remote_server::setup::remote_server_daemon_data_dir("user@example.com/ssh host");
assert!(path.is_absolute());
assert_eq!(
path,
PathBuf::from(shellexpand::tilde(&expected_data_dir).into_owned()).join("warp.sqlite")
);
}
#[test]
fn remote_server_daemon_scope_database_path_handles_empty_identity_key() {
let path = database_file_path_for_scope(&PersistenceScope::RemoteServerDaemon {
identity_key: String::new(),
});
let expected_data_dir = remote_server::setup::remote_server_daemon_data_dir("");
assert_eq!(
path,
PathBuf::from(shellexpand::tilde(&expected_data_dir).into_owned()).join("warp.sqlite")
);
}
#[cfg(unix)]
#[test]
fn remote_server_daemon_database_permissions_are_owner_only() {
use std::fs::Permissions;
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let tempdir = tempfile::tempdir().expect("tempdir should be created");
let daemon_dir = tempdir.path().join("daemon");
let database_path = daemon_dir.join("warp.sqlite");
std::fs::create_dir_all(&daemon_dir).expect("daemon dir should be created");
std::fs::set_permissions(&daemon_dir, Permissions::from_mode(0o755))
.expect("daemon dir permissions should be set");
std::fs::write(&database_path, b"").expect("database file should be created");
std::fs::set_permissions(&database_path, Permissions::from_mode(0o644))
.expect("database file permissions should be set");
super::ensure_owner_only_dir(&daemon_dir).expect("daemon dir should be owner-only");
super::ensure_owner_only_file(&database_path).expect("database file should be owner-only");
assert_eq!(daemon_dir.metadata().unwrap().mode() & 0o777, 0o700);
assert_eq!(database_path.metadata().unwrap().mode() & 0o777, 0o600);
}
fn test_codebase_metadata(path: &str) -> WorkspaceMetadata {
WorkspaceMetadata {
path: PathBuf::from(path),
navigated_ts: Some(Utc::now()),
modified_ts: None,
queried_ts: None,
}
}
#[test]
fn sqlite_read_restores_app_state_and_codebase_metadata() {
let tempdir = tempfile::tempdir().expect("tempdir should be created");
let database_path = tempdir.path().join("warp.sqlite");
let mut conn = setup_database(&database_path).expect("database should initialize");
let app_state = AppState {
windows: vec![test_terminal_window_snapshot(false)],
active_window_index: Some(0),
block_lists: Default::default(),
running_mcp_servers: Default::default(),
};
save_app_state(&mut conn, &app_state).expect("app state should save");
let metadata = test_codebase_metadata("/tmp/remote-repo");
save_codebase_index_metadata(&mut conn, metadata.clone())
.expect("codebase index metadata should save");
let restored = read_sqlite_data(&mut conn, None).expect("persisted data should load");
assert_eq!(restored.app_state.windows.len(), 1);
assert_eq!(restored.codebase_indices.len(), 1);
assert_eq!(restored.codebase_indices[0].path, metadata.path);
}
#[test]
fn sqlite_writer_reuses_codebase_index_metadata_events() {
let tempdir = tempfile::tempdir().expect("tempdir should be created");
let database_path = tempdir.path().join("warp.sqlite");
let conn = setup_database(&database_path).expect("database should initialize");
let writer = start_writer(conn, database_path.clone()).expect("writer should start");
let metadata = test_codebase_metadata("/tmp/writer-repo");
writer
.sender
.send(ModelEvent::UpsertCodebaseIndexMetadata {
index_metadata: Box::new(metadata.clone()),
})
.expect("upsert event should send");
writer
.sender
.send(ModelEvent::Terminate)
.expect("terminate event should send");
writer.handle.join().expect("writer should terminate");
let mut conn = setup_database(&database_path).expect("database should reopen");
let restored = get_all_codebase_index_metadata(&mut conn).expect("metadata should load");
assert_eq!(restored.len(), 1);
assert_eq!(restored[0].path, metadata.path);
let writer = start_writer(conn, database_path.clone()).expect("writer should restart");
writer
.sender
.send(ModelEvent::DeleteCodebaseIndexMetadata {
repo_path: metadata.path,
})
.expect("delete event should send");
writer
.sender
.send(ModelEvent::Terminate)
.expect("terminate event should send");
writer.handle.join().expect("writer should terminate");
let mut conn = setup_database(&database_path).expect("database should reopen");
let restored = get_all_codebase_index_metadata(&mut conn).expect("metadata should load");
assert!(restored.is_empty());
}
#[test]
fn test_deduplicate_snapshots() {
let local_notebook = CloudNotebook::new_local(
@@ -139,6 +282,8 @@ fn test_terminal_window_snapshot(vertical_tabs_panel_open: bool) -> WindowSnapsh
selected_color: SelectedTabColor::default(),
left_panel: None,
right_panel: None,
group_id: None,
pinned: false,
}],
active_tab_index: 0,
bounds: None,
@@ -153,6 +298,7 @@ fn test_terminal_window_snapshot(vertical_tabs_panel_open: bool) -> WindowSnapsh
left_panel_width: None,
right_panel_width: None,
agent_management_filters: None,
tab_groups: vec![],
}
}
@@ -222,6 +368,8 @@ fn test_sqlite_round_trips_custom_vertical_tabs_title() {
selected_color: SelectedTabColor::default(),
left_panel: None,
right_panel: None,
group_id: None,
pinned: false,
}],
active_tab_index: 0,
bounds: None,
@@ -236,6 +384,7 @@ fn test_sqlite_round_trips_custom_vertical_tabs_title() {
left_panel_width: None,
right_panel_width: None,
agent_management_filters: None,
tab_groups: vec![],
}],
active_window_index: Some(0),
block_lists: Default::default(),
@@ -286,7 +435,9 @@ fn test_sqlite_round_trips_code_pane_with_multiple_tabs() {
],
active_tab_index: 1,
source: Some(CodeSource::FileTree {
path: PathBuf::from("/tmp/main.rs"),
location: crate::code::buffer_location::LocalOrRemotePath::Local(
PathBuf::from("/tmp/main.rs"),
),
}),
}),
}),
@@ -294,6 +445,8 @@ fn test_sqlite_round_trips_code_pane_with_multiple_tabs() {
selected_color: SelectedTabColor::default(),
left_panel: None,
right_panel: None,
group_id: None,
pinned: false,
}],
active_tab_index: 0,
bounds: None,
@@ -308,6 +461,7 @@ fn test_sqlite_round_trips_code_pane_with_multiple_tabs() {
left_panel_width: None,
right_panel_width: None,
agent_management_filters: None,
tab_groups: vec![],
}],
active_window_index: Some(0),
block_lists: Default::default(),
@@ -343,6 +497,291 @@ fn test_sqlite_round_trips_code_pane_with_multiple_tabs() {
assert!(matches!(source, Some(CodeSource::FileTree { .. })));
}
/// Verifies that a tab group and its membership round-trip through save/restore.
#[test]
fn test_sqlite_round_trips_tab_groups() {
let tempdir = tempfile::tempdir().expect("tempdir should be created");
let database_path = tempdir.path().join("warp.sqlite");
let mut conn = setup_database(&database_path).expect("database should initialize");
let group_id = TabGroupId::new();
let tab_in_group = TabSnapshot {
custom_title: None,
root: PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Terminal(TerminalPaneSnapshot {
uuid: vec![1],
cwd: Some("/tmp/grouped".to_string()),
shell_launch_data: Some(ShellLaunchData::Executable {
executable_path: PathBuf::from("/bin/zsh"),
shell_type: crate::terminal::shell::ShellType::Zsh,
}),
is_active: true,
is_read_only: false,
input_config: None,
llm_model_override: None,
active_profile_id: None,
conversation_ids_to_restore: vec![],
active_conversation_id: None,
}),
}),
default_directory_color: None,
selected_color: SelectedTabColor::default(),
left_panel: None,
right_panel: None,
group_id: Some(group_id),
pinned: false,
};
let tab_outside_group = TabSnapshot {
custom_title: None,
root: PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: false,
custom_vertical_tabs_title: None,
contents: LeafContents::Terminal(TerminalPaneSnapshot {
uuid: vec![2],
cwd: Some("/tmp/ungrouped".to_string()),
shell_launch_data: Some(ShellLaunchData::Executable {
executable_path: PathBuf::from("/bin/zsh"),
shell_type: crate::terminal::shell::ShellType::Zsh,
}),
is_active: false,
is_read_only: false,
input_config: None,
llm_model_override: None,
active_profile_id: None,
conversation_ids_to_restore: vec![],
active_conversation_id: None,
}),
}),
default_directory_color: None,
selected_color: SelectedTabColor::default(),
left_panel: None,
right_panel: None,
group_id: None,
pinned: false,
};
let app_state = AppState {
windows: vec![WindowSnapshot {
tabs: vec![tab_in_group, tab_outside_group],
active_tab_index: 0,
bounds: None,
fullscreen_state: Default::default(),
quake_mode: false,
universal_search_width: None,
warp_ai_width: None,
voltron_width: None,
warp_drive_index_width: None,
left_panel_open: false,
vertical_tabs_panel_open: false,
left_panel_width: None,
right_panel_width: None,
agent_management_filters: None,
tab_groups: vec![TabGroupSnapshot {
id: group_id,
name: Some("Backend".to_string()),
color: SelectedTabColor::Color(AnsiColorIdentifier::Blue),
collapsed: true,
pinned: false,
}],
}],
active_window_index: Some(0),
block_lists: Default::default(),
running_mcp_servers: Default::default(),
};
save_app_state(&mut conn, &app_state).expect("app state should save");
let restored = read_sqlite_data(&mut conn, None)
.expect("app state should load")
.app_state;
assert_eq!(restored.windows.len(), 1);
let restored_window = &restored.windows[0];
assert_eq!(restored_window.tab_groups.len(), 1);
let restored_group = &restored_window.tab_groups[0];
assert_eq!(restored_group.name.as_deref(), Some("Backend"));
assert_eq!(
restored_group.color,
SelectedTabColor::Color(AnsiColorIdentifier::Blue)
);
assert!(restored_group.collapsed);
// The in-memory `TabGroupId` is minted fresh on restore, so we check that
// the grouped tab points at the restored group, and the ungrouped tab
// remains ungrouped.
assert_eq!(restored_window.tabs.len(), 2);
assert_eq!(restored_window.tabs[0].group_id, Some(restored_group.id));
assert_eq!(restored_window.tabs[1].group_id, None);
}
/// Verifies that the `pinned` flag on tabs and tab groups round-trips through
/// save/restore so the user's pinned layout survives an app restart.
#[test]
fn test_sqlite_round_trips_pinned_state() {
let tempdir = tempfile::tempdir().expect("tempdir should be created");
let database_path = tempdir.path().join("warp.sqlite");
let mut conn = setup_database(&database_path).expect("database should initialize");
let pinned_group_id = TabGroupId::new();
let unpinned_group_id = TabGroupId::new();
let pinned_tab = TabSnapshot {
custom_title: None,
root: PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: true,
custom_vertical_tabs_title: None,
contents: LeafContents::Terminal(TerminalPaneSnapshot {
uuid: vec![10],
cwd: Some("/tmp/pinned".to_string()),
shell_launch_data: Some(ShellLaunchData::Executable {
executable_path: PathBuf::from("/bin/zsh"),
shell_type: crate::terminal::shell::ShellType::Zsh,
}),
is_active: true,
is_read_only: false,
input_config: None,
llm_model_override: None,
active_profile_id: None,
conversation_ids_to_restore: vec![],
active_conversation_id: None,
}),
}),
default_directory_color: None,
selected_color: SelectedTabColor::default(),
left_panel: None,
right_panel: None,
group_id: None,
pinned: true,
};
let unpinned_tab = TabSnapshot {
custom_title: None,
root: PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: false,
custom_vertical_tabs_title: None,
contents: LeafContents::Terminal(TerminalPaneSnapshot {
uuid: vec![11],
cwd: Some("/tmp/unpinned".to_string()),
shell_launch_data: Some(ShellLaunchData::Executable {
executable_path: PathBuf::from("/bin/zsh"),
shell_type: crate::terminal::shell::ShellType::Zsh,
}),
is_active: false,
is_read_only: false,
input_config: None,
llm_model_override: None,
active_profile_id: None,
conversation_ids_to_restore: vec![],
active_conversation_id: None,
}),
}),
default_directory_color: None,
selected_color: SelectedTabColor::default(),
left_panel: None,
right_panel: None,
group_id: Some(unpinned_group_id),
pinned: false,
};
let tab_in_pinned_group = TabSnapshot {
custom_title: None,
root: PaneNodeSnapshot::Leaf(LeafSnapshot {
is_focused: false,
custom_vertical_tabs_title: None,
contents: LeafContents::Terminal(TerminalPaneSnapshot {
uuid: vec![12],
cwd: Some("/tmp/pinned-group".to_string()),
shell_launch_data: Some(ShellLaunchData::Executable {
executable_path: PathBuf::from("/bin/zsh"),
shell_type: crate::terminal::shell::ShellType::Zsh,
}),
is_active: false,
is_read_only: false,
input_config: None,
llm_model_override: None,
active_profile_id: None,
conversation_ids_to_restore: vec![],
active_conversation_id: None,
}),
}),
default_directory_color: None,
selected_color: SelectedTabColor::default(),
left_panel: None,
right_panel: None,
group_id: Some(pinned_group_id),
pinned: false,
};
let app_state = AppState {
windows: vec![WindowSnapshot {
tabs: vec![pinned_tab, tab_in_pinned_group, unpinned_tab],
active_tab_index: 0,
bounds: None,
fullscreen_state: Default::default(),
quake_mode: false,
universal_search_width: None,
warp_ai_width: None,
voltron_width: None,
warp_drive_index_width: None,
left_panel_open: false,
vertical_tabs_panel_open: false,
left_panel_width: None,
right_panel_width: None,
agent_management_filters: None,
tab_groups: vec![
TabGroupSnapshot {
id: pinned_group_id,
name: Some("Pinned".to_string()),
color: SelectedTabColor::default(),
collapsed: false,
pinned: true,
},
TabGroupSnapshot {
id: unpinned_group_id,
name: Some("Loose".to_string()),
color: SelectedTabColor::default(),
collapsed: false,
pinned: false,
},
],
}],
active_window_index: Some(0),
block_lists: Default::default(),
running_mcp_servers: Default::default(),
};
save_app_state(&mut conn, &app_state).expect("app state should save");
let restored = read_sqlite_data(&mut conn, None)
.expect("app state should load")
.app_state;
assert_eq!(restored.windows.len(), 1);
let restored_window = &restored.windows[0];
// Tabs come back in insertion order; pinned flag should match what we saved.
assert_eq!(restored_window.tabs.len(), 3);
assert!(restored_window.tabs[0].pinned);
assert!(!restored_window.tabs[1].pinned);
assert!(!restored_window.tabs[2].pinned);
// Both groups round-trip with their pinned state preserved. Group ids are
// minted fresh on restore, so we look them up by name.
assert_eq!(restored_window.tab_groups.len(), 2);
let restored_pinned_group = restored_window
.tab_groups
.iter()
.find(|group| group.name.as_deref() == Some("Pinned"))
.expect("pinned group should restore");
let restored_loose_group = restored_window
.tab_groups
.iter()
.find(|group| group.name.as_deref() == Some("Loose"))
.expect("unpinned group should restore");
assert!(restored_pinned_group.pinned);
assert!(!restored_loose_group.pinned);
}
fn assert_encode_then_decode_preserves_original_path(original_path: PathBuf) {
let bytes = encode_path(original_path.clone());
let decoded_path = decode_path(bytes);
@@ -402,7 +841,7 @@ fn test_deserialize_corrupted_guests() {
};
// The overall permissions should successfully convert, minus the object guests.
let cloud_permissions = super::to_cloud_object_permissions(&db_permissions, None);
let cloud_permissions = to_cloud_object_permissions(&db_permissions, None);
assert_eq!(
cloud_permissions,
Some(CloudObjectPermissions {
@@ -415,3 +854,88 @@ fn test_deserialize_corrupted_guests() {
})
);
}
// Regression: GH#10083. The macOS green-tile button could leave a 1px-wide
// window bound in `AppContext::window_bounds`, which previously round-tripped
// through SQLite and restored as an unusable 1px sliver. Bounds below the
// platform minimum window size must be dropped on save.
#[test]
fn test_sqlite_drops_too_small_bounds_on_save() {
use diesel::prelude::*;
use crate::persistence::schema::windows;
let tempdir = tempfile::tempdir().expect("tempdir should be created");
let database_path = tempdir.path().join("warp.sqlite");
let mut conn = setup_database(&database_path).expect("database should initialize");
let mut snapshot = test_terminal_window_snapshot(false);
snapshot.bounds = Some(RectF::new(
Vector2F::new(0.0, -1410.0),
Vector2F::new(1.0, 1410.0),
));
let app_state = AppState {
windows: vec![snapshot],
active_window_index: Some(0),
block_lists: Default::default(),
running_mcp_servers: Default::default(),
};
save_app_state(&mut conn, &app_state).expect("app state should save");
// Query the row directly so the assertion isolates the save guard and is
// not masked by the read-side guard in `read_sqlite_data`.
let row: (Option<f32>, Option<f32>, Option<f32>, Option<f32>) = windows::dsl::windows
.select((
windows::columns::window_width,
windows::columns::window_height,
windows::columns::origin_x,
windows::columns::origin_y,
))
.first(&mut conn)
.expect("a windows row should have been inserted");
assert_eq!(
row,
(None, None, None, None),
"save-path guard must persist NULL bound columns for sub-minimum geometry"
);
}
// Regression: GH#10083. Users whose warp.sqlite already contains a 1px row
// (because they hit the bug on an earlier build) must still recover to default
// geometry on next launch rather than restoring the sliver.
#[test]
fn test_sqlite_drops_too_small_bounds_on_read() {
let tempdir = tempfile::tempdir().expect("tempdir should be created");
let database_path = tempdir.path().join("warp.sqlite");
let mut conn = setup_database(&database_path).expect("database should initialize");
// Save with no bounds so a row exists, then corrupt it directly to bypass
// the save-path guard and simulate a pre-existing bad row.
let app_state = AppState {
windows: vec![test_terminal_window_snapshot(false)],
active_window_index: Some(0),
block_lists: Default::default(),
running_mcp_servers: Default::default(),
};
save_app_state(&mut conn, &app_state).expect("app state should save");
conn.batch_execute(
"UPDATE windows \
SET window_width = 1.0, window_height = 1410.0, \
origin_x = 0.0, origin_y = -1410.0",
)
.expect("corrupting update should succeed");
let restored = read_sqlite_data(&mut conn, None)
.expect("app state should load")
.app_state;
assert_eq!(restored.windows.len(), 1);
assert!(
restored.windows[0].bounds.is_none(),
"tiny persisted bounds must be discarded on read so users recover from a corrupt DB"
);
}
+6 -3
View File
@@ -2,14 +2,16 @@
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
use super::{schema, sqlite::init_db};
use super::sqlite::init_db;
use super::{schema, PersistenceScope};
/// Updates the 'user' and 'host' columns for stored blocks to the given values.
///
/// This is used at runtime to update the user and host values to real values based on the running
/// machine in integration tests that rely on accuracy of these values.
pub fn set_user_and_hostname_for_blocks(user: String, hostname: String) {
let mut conn = init_db().expect("Should be able to establish sqlite connection.");
let mut conn =
init_db(&PersistenceScope::App).expect("Should be able to establish sqlite connection.");
// Update the 'user' and 'host' columns to their real values (based on the machine on which this test is running)
// for blocks that were stored with the placeholder 'local:user' and 'local:host' values.
@@ -26,7 +28,8 @@ pub fn set_user_and_hostname_for_blocks(user: String, hostname: String) {
}
pub fn set_user_and_hostname_for_commands(user: String, hostname: String) {
let mut conn = init_db().expect("Should be able to establish sqlite connection.");
let mut conn =
init_db(&PersistenceScope::App).expect("Should be able to establish sqlite connection.");
// Update the 'user' and 'host' columns to their real values (based on the machine on which
// this test is running) for commands that were stored with the placeholder 'local:user' and