diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index 7009ad1..85e827a 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -1155,6 +1155,26 @@ pub struct ErrorLogRow { pub metadata: Option, } +/// Delete `error_log` rows older than `keep_days` whole days. Returns the +/// row count removed. +/// +/// Called once on app startup so the table doesn't grow unbounded across +/// months of dogfooding — without this it would eventually balloon the +/// diagnostic-bundle export and slow the `list_recent_errors` query. +/// 90 days is the default: long enough to triage a "this happened a few +/// weeks ago" report, short enough that the table stays kilobyte-scale. +pub async fn prune_error_log(pool: &SqlitePool, keep_days: i64) -> Result { + let result = sqlx::query( + "DELETE FROM error_log \ + WHERE timestamp < datetime('now', ?)", + ) + .bind(format!("-{keep_days} days")) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Prune error_log failed: {e}")))?; + Ok(result.rows_affected()) +} + pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result> { let rows = sqlx::query( "SELECT id, timestamp, context, error_code, message, metadata \ @@ -2471,4 +2491,44 @@ mod tests { "must fail when DB file does not exist (no create_if_missing)", ); } + + #[tokio::test] + async fn prune_error_log_removes_old_rows_only() { + let pool = test_pool().await; + + // Three rows: one from now, one 30 days old, one 200 days old. + sqlx::query( + "INSERT INTO error_log (timestamp, context, error_code, message) VALUES \ + (datetime('now'), 'recent', NULL, 'now'), \ + (datetime('now', '-30 days'), 'older', NULL, '30d'), \ + (datetime('now', '-200 days'), 'oldest', NULL, '200d')", + ) + .execute(&pool) + .await + .unwrap(); + + // Default 90-day retention removes only the 200-day row. + let removed = prune_error_log(&pool, 90).await.unwrap(); + assert_eq!(removed, 1, "only the 200-day row should be pruned"); + + let remaining: Vec = sqlx::query_scalar( + "SELECT context FROM error_log ORDER BY id ASC", + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(remaining, vec!["recent".to_string(), "older".to_string()]); + + // A 14-day window prunes the 30-day row too. + let removed = prune_error_log(&pool, 14).await.unwrap(); + assert_eq!(removed, 1); + + let remaining: Vec = sqlx::query_scalar( + "SELECT context FROM error_log", + ) + .fetch_all(&pool) + .await + .unwrap(); + assert_eq!(remaining, vec!["recent".to_string()]); + } } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index a344d57..214b1d1 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -14,7 +14,8 @@ pub use database::{ insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms, list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired, - record_feedback, search_transcripts, set_implementation_rule_enabled, set_setting, + prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled, + set_setting, set_task_energy, uncomplete_task, update_profile, update_task, update_transcript, update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType, ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 19391cf..969ab9f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,7 +9,14 @@ use tauri::Manager; use kon_core::types::EngineName; use kon_llm::LlmEngine; -use kon_storage::{database_path, get_setting, init as init_db, set_setting}; +use kon_storage::{ + database_path, get_setting, init as init_db, prune_error_log, set_setting, +}; + +/// How long to retain `error_log` rows. Pruned once on startup. +/// 90 days is long enough to triage "this happened a few weeks ago" +/// reports and short enough that the table stays kilobyte-scale. +const ERROR_LOG_RETENTION_DAYS: i64 = 90; use kon_transcription::LocalEngine; /// Shared app state holding the transcription engines and database pool. @@ -158,6 +165,22 @@ pub fn run() { .map_err(|e| Box::new(e) as Box)?; eprintln!("[startup] DB init: {:?}", t0.elapsed()); + // Prune old `error_log` rows so the table doesn't grow unbounded + // across months of dogfooding. Best-effort — a prune failure is + // not worth blocking startup over. + let t_prune = Instant::now(); + let pruned = tauri::async_runtime::block_on(async { + prune_error_log(&db, ERROR_LOG_RETENTION_DAYS).await + }); + match pruned { + Ok(n) if n > 0 => eprintln!( + "[startup] Error log prune: {n} rows removed (>{ERROR_LOG_RETENTION_DAYS}d) in {:?}", + t_prune.elapsed() + ), + Ok(_) => {} + Err(e) => eprintln!("[startup] Error log prune failed: {e}"), + } + // Load saved preferences for webview injection let t1 = Instant::now(); let prefs_json = tauri::async_runtime::block_on(async {