feat(history): make query history retention configurable

This commit is contained in:
cowking authored and GitHub committed 2026-09-22 01:17:20 +08:00
1 parent 6d74bcbadb
commit 576943c543
25 files changed
+667 -10

No files matched your search

@@ -87,6 +87,13 @@ pub struct HistoryConnectionOption {
pub const MAX_HISTORY: usize = 1000;
pub fn validate_history_retention_limit(limit: u32) -> Result<(), String> {
match limit {
0 | 200 | 1000 | 5000 | 10000 => Ok(()),
_ => Err("History retention limit must be 200, 1000, 5000, 10000, or 0 (unlimited)".to_string()),
}
}
fn default_activity_kind() -> String {
"query".to_string()
}
+206 -9
View File
@@ -45,6 +45,7 @@ const APP_STATE_TRANSFER_TASK_LIBRARY_KEY: &str = "transfer_task_library";
const MCP_GLOBAL_POLICY_KEY: &str = "mcp_global_policy";
const MCP_HTTP_SERVER_SETTINGS_KEY: &str = "mcp_http_server_settings";
const MAX_RETRIES_KEY: &str = "max_retries";
const HISTORY_RETENTION_LIMIT_KEY: &str = "history_retention_limit";
const SQL_FILE_UPLOAD_MAX_MB_KEY: &str = "sql_file_upload_max_mb";
const APP_STATE_AI_GLOBAL_INSTRUCTIONS_KEY: &str = "ai_global_custom_instructions";
const APP_STATE_AI_CHAT_SELECTION_KEY: &str = "ai_chat_selection_v1";
@@ -1516,11 +1517,36 @@ fn map_history_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<HistoryEntry> {
})
}
fn history_retention_limit_from_settings(settings: &serde_json::Map<String, serde_json::Value>) -> u32 {
settings
.get(HISTORY_RETENTION_LIMIT_KEY)
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok())
.filter(|value| crate::history::validate_history_retention_limit(*value).is_ok())
.unwrap_or(MAX_HISTORY as u32)
}
fn load_history_retention_limit_from_conn(conn: &Connection) -> Result<u32, String> {
let current: Option<String> = conn
.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0))
.optional()
.map_err(|e| e.to_string())?;
let settings = match current {
Some(json) => serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&json)
.map_err(|e| format!("invalid app settings JSON: {e}"))?,
None => serde_json::Map::new(),
};
Ok(history_retention_limit_from_settings(&settings))
}
impl Storage {
pub async fn save_history_entry(&self, entry: &HistoryEntry) -> Result<(), String> {
let entry = entry.clone();
self.with_conn(move |conn| {
conn.execute(
let tx =
conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate).map_err(|e| e.to_string())?;
let limit = load_history_retention_limit_from_conn(&tx)?;
tx.execute(
"INSERT OR REPLACE INTO history \
(id, connection_name, database, sql_text, executed_at, execution_time_ms, success, error, \
activity_kind, connection_id, operation, target, affected_rows, rollback_sql, details_json) \
@@ -1545,13 +1571,15 @@ impl Storage {
)
.map_err(|e| e.to_string())?;
conn.execute(
"DELETE FROM history WHERE id NOT IN \
(SELECT id FROM history ORDER BY executed_at DESC LIMIT ?1)",
[MAX_HISTORY as i64],
)
.map_err(|e| e.to_string())?;
Ok(())
if limit != 0 {
tx.execute(
"DELETE FROM history WHERE id NOT IN \
(SELECT id FROM history ORDER BY executed_at DESC, id DESC LIMIT ?1)",
[i64::from(limit)],
)
.map_err(|e| e.to_string())?;
}
tx.commit().map_err(|e| e.to_string())
})
.await
}
@@ -2071,7 +2099,8 @@ impl Storage {
.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0))
.optional()
.map_err(|e| e.to_string())?;
let dedicated_keys = [MCP_GLOBAL_POLICY_KEY, MAX_RETRIES_KEY, SQL_FILE_UPLOAD_MAX_MB_KEY];
let dedicated_keys =
[MCP_GLOBAL_POLICY_KEY, MAX_RETRIES_KEY, SQL_FILE_UPLOAD_MAX_MB_KEY, HISTORY_RETENTION_LIMIT_KEY];
for key in dedicated_keys {
settings.remove(key);
}
@@ -2654,6 +2683,33 @@ impl Storage {
.unwrap_or(crate::agent_loop::DEFAULT_MAX_AGENT_TURNS))
}
pub async fn load_history_retention_limit(&self) -> Result<u32, String> {
self.with_conn(|conn| load_history_retention_limit_from_conn(conn)).await
}
pub async fn save_history_retention_limit(&self, limit: u32) -> Result<(), String> {
crate::history::validate_history_retention_limit(limit)?;
self.with_conn(move |conn| {
let current: Option<String> = conn
.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0))
.optional()
.map_err(|e| e.to_string())?;
let mut settings = match current {
Some(json) => serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&json)
.map_err(|e| format!("invalid app settings JSON: {e}"))?,
None => serde_json::Map::new(),
};
settings.insert(HISTORY_RETENTION_LIMIT_KEY.to_string(), serde_json::Value::from(limit));
conn.execute(
"INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)",
[serde_json::Value::Object(settings).to_string()],
)
.map(|_| ())
.map_err(|e| e.to_string())
})
.await
}
pub async fn save_max_retries(&self, max_retries: u32) -> Result<(), String> {
let max_retries = crate::ai::clamp_max_retries(max_retries);
self.with_conn(move |conn| {
@@ -5873,6 +5929,147 @@ mod tests {
let _ = std::fs::remove_file(path);
}
// Seed a backlog efficiently, then exercise the production write path that
// applies retention. Settings must affect every caller of that path.
async fn seed_history_backlog(storage: &Storage, count: usize) {
storage.with_conn(move |conn| {
let tx = conn.transaction().map_err(|error| error.to_string())?;
for index in 0..count {
tx.execute(
"INSERT INTO history (id, connection_name, database, sql_text, executed_at, execution_time_ms, success) VALUES (?1, 'Main', 'app', 'select 1', '2026-07-18T12:00:00Z', 1, 1)",
[format!("{index:05}")],
).map_err(|error| error.to_string())?;
}
tx.commit().map_err(|error| error.to_string())
}).await.unwrap();
}
#[tokio::test]
async fn history_retention_uses_persisted_limit_on_the_next_write() {
for (limit, expected) in [(200, 200), (5000, 1002), (10000, 1002), (0, 1002)] {
let dir = tempfile::tempdir().unwrap();
let storage = Storage::open(&dir.path().join("dbx.db")).await.unwrap();
seed_history_backlog(&storage, 1001).await;
storage
.with_conn(move |conn| {
conn.execute(
"INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)",
[serde_json::json!({"history_retention_limit": limit}).to_string()],
)
.map(|_| ())
.map_err(|error| error.to_string())
})
.await
.unwrap();
// Changing the setting alone must not evict existing history.
assert_eq!(storage.search_history_entries(HistorySearchRequest::default()).await.unwrap().total, 1001);
storage
.save_history_entry(&history_entry(
"newest",
"conn",
"Main",
"app",
"select 2",
"2026-07-19T00:00:00Z",
true,
))
.await
.unwrap();
let result = storage.search_history_entries(HistorySearchRequest::default()).await.unwrap();
assert_eq!(result.total, expected, "retention limit {limit}");
assert_eq!(result.entries[0].id, "newest");
if limit == 200 {
let remaining = storage.load_history_entries(500, 0, None).await.unwrap();
assert!(remaining.iter().all(|entry| entry.id == "newest" || entry.id.as_str() >= "00802"));
}
}
}
#[tokio::test]
async fn history_retention_defaults_validates_and_survives_stale_settings() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dbx.db");
let storage = Storage::open(&path).await.unwrap();
assert_eq!(storage.load_history_retention_limit().await.unwrap(), 1000);
let stale = storage.load_app_settings_json().await.unwrap();
for limit in [200, 1000, 5000, 10000, 0] {
storage.save_history_retention_limit(limit).await.unwrap();
assert_eq!(storage.load_history_retention_limit().await.unwrap(), limit);
}
for invalid in [1, 199, 201, 10001, u32::MAX] {
assert!(storage.save_history_retention_limit(invalid).await.is_err());
assert_eq!(storage.load_history_retention_limit().await.unwrap(), 0);
}
storage.save_app_settings_json(&stale).await.unwrap();
assert_eq!(storage.load_history_retention_limit().await.unwrap(), 0);
drop(storage);
let reopened = Storage::open(&path).await.unwrap();
assert_eq!(reopened.load_history_retention_limit().await.unwrap(), 0);
}
#[tokio::test]
async fn history_retention_invalid_persisted_values_use_default() {
let dir = tempfile::tempdir().unwrap();
let storage = Storage::open(&dir.path().join("dbx.db")).await.unwrap();
for value in [
serde_json::json!(-1),
serde_json::json!(1),
serde_json::json!("200"),
serde_json::json!(u64::MAX),
serde_json::Value::Null,
] {
storage
.with_conn(move |conn| {
conn.execute(
"INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)",
[serde_json::json!({"history_retention_limit": value}).to_string()],
)
.map(|_| ())
.map_err(|error| error.to_string())
})
.await
.unwrap();
assert_eq!(storage.load_history_retention_limit().await.unwrap(), 1000);
}
seed_history_backlog(&storage, 1001).await;
storage
.save_history_entry(&history_entry(
"newest",
"conn",
"Main",
"app",
"select 2",
"2026-07-19T00:00:00Z",
true,
))
.await
.unwrap();
assert_eq!(storage.search_history_entries(HistorySearchRequest::default()).await.unwrap().total, 1000);
}
#[tokio::test]
async fn history_retention_setting_changes_do_not_prune_until_next_write() {
let dir = tempfile::tempdir().unwrap();
let storage = Storage::open(&dir.path().join("dbx.db")).await.unwrap();
storage.save_history_retention_limit(0).await.unwrap();
seed_history_backlog(&storage, 1001).await;
storage.save_history_retention_limit(200).await.unwrap();
assert_eq!(storage.search_history_entries(HistorySearchRequest::default()).await.unwrap().total, 1001);
storage
.save_history_entry(&history_entry(
"newest",
"conn",
"Main",
"app",
"select 2",
"2026-07-19T00:00:00Z",
true,
))
.await
.unwrap();
assert_eq!(storage.search_history_entries(HistorySearchRequest::default()).await.unwrap().total, 200);
}
#[tokio::test]
async fn history_search_filters_connection_database_and_legacy_entries() {
let path = temp_db_path("history-search-scope");
+5
View File
@@ -1140,6 +1140,11 @@ async fn main() {
"/app-settings/max-agent-turns",
get(routes::app_settings::load_max_agent_turns).put(routes::app_settings::save_max_agent_turns),
)
.route(
"/app-settings/history-retention-limit",
get(routes::app_settings::load_history_retention_limit)
.put(routes::app_settings::save_history_retention_limit),
)
.route(
"/app-settings/max-retries",
get(routes::app_settings::load_max_retries).put(routes::app_settings::save_max_retries),
+18
View File
@@ -120,6 +120,24 @@ pub async fn save_max_agent_turns(
Ok(Json(()))
}
#[derive(Deserialize)]
pub struct SaveHistoryRetentionLimitRequest {
pub limit: u32,
}
pub async fn load_history_retention_limit(State(state): State<Arc<WebState>>) -> Result<Json<u32>, AppError> {
state.app.storage.load_history_retention_limit().await.map(Json).map_err(AppError::from)
}
pub async fn save_history_retention_limit(
State(state): State<Arc<WebState>>,
Json(body): Json<SaveHistoryRetentionLimitRequest>,
) -> Result<Json<()>, AppError> {
dbx_core::history::validate_history_retention_limit(body.limit).map_err(AppError::bad_request)?;
state.app.storage.save_history_retention_limit(body.limit).await.map_err(AppError::from)?;
Ok(Json(()))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SaveMaxRetriesRequest {