first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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
@@ -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
|
||||
/// 10–40 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;
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
+495
-1203
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user