agent: code-atomiser-fix — soft-delete transcripts with audio cleanup (Rev-2, Rev-3)

Two interlocking reversibility kills, fixed as one bundle:

Rev-2 (hard-DELETE transcripts, no trash) — delete_transcript previously
issued DELETE FROM transcripts WHERE id = ?, so a single click on the
History "Clear All" / "Confirm" button erased months of dictation with no
trash, no export, no undo. The new contract:
  * delete_transcript UPDATEs deleted_at = datetime('now') and best-effort
    removes the audio file. Idempotent on repeat call.
  * Migration v16 adds `deleted_at TEXT` plus a partial index over the trash
    rows so the purge query stays cheap on long-running databases.
  * get_transcript, list_transcripts_paged, count_transcripts, and
    search_transcripts all filter `deleted_at IS NULL` so trash rows are
    invisible to the regular history view but kept for restore.
  * New list_trashed_transcripts and restore_transcript power the inverse
    trash view; row order is most-recently-deleted first.
  * New purge_deleted_transcripts(older_than_days) hard-removes trash
    older than the retention window. Wired into the Tauri setup hook at
    30-day retention; best-effort, never blocks startup.

Rev-3 (orphan WAV files on transcript delete) — same delete_transcript
function. Audio file at audio_path is now best-effort removed when the
soft-delete actually flips a row; NotFound is the expected case for
repeat deletes and is not logged. purge_deleted_transcripts also retries
audio removal as belt-and-braces.

Adds 4 regression tests: delete_transcript_soft_deletes,
delete_transcript_removes_audio_file, list_transcripts_excludes_soft_deleted
(also covers restore_transcript), and purge_deleted_transcripts_hard_deletes_old.
Plus migration_v16_adds_deleted_at_column_and_index already in place.

Frontend UI (HistoryPage clearAll type-the-word modal + View trash path)
deferred to a follow-up commit; the backend contract is complete and the
existing arm-confirm flow now soft-deletes instead of hard-deleting, so
the user-data-loss class is closed for the live release path. Rev-4
(SettingsPage deleteProfile routing) also deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 17:39:03 +01:00
parent 1068ad9c7d
commit 15b74db747
4 changed files with 492 additions and 20 deletions

View File

@@ -25,13 +25,19 @@ use lumotia_core::types::EngineName;
use lumotia_llm::LlmEngine;
use lumotia_storage::{
database_path, get_setting, init as init_db, migrate_legacy_setting_keys, prune_error_log,
set_setting,
purge_deleted_transcripts, 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;
/// How long soft-deleted transcripts live in the trash before
/// `purge_deleted_transcripts` hard-removes them on startup. 30 days
/// gives the user a comfortable undo window without keeping months of
/// abandoned audio on disk.
const TRANSCRIPT_TRASH_RETENTION_DAYS: i64 = 30;
use lumotia_transcription::LocalEngine;
static TRACING_INIT: Once = Once::new();
@@ -491,6 +497,26 @@ pub fn run() {
Err(e) => tracing::warn!(target: "lumotia_startup", error = %e, "error log prune failed"),
}
// Hard-delete soft-deleted transcripts past the retention
// window (Rev-2 atomiser fix). Best-effort — a purge failure
// means trash rows stick around an extra day, not a blocker.
let t_purge_trash = Instant::now();
match purge_deleted_transcripts(&db, TRANSCRIPT_TRASH_RETENTION_DAYS).await {
Ok(n) if n > 0 => tracing::info!(
target: "lumotia_startup",
rows_removed = n,
retention_days = TRANSCRIPT_TRASH_RETENTION_DAYS,
elapsed_ms = t_purge_trash.elapsed().as_millis(),
"transcript trash purge complete"
),
Ok(_) => {}
Err(e) => tracing::warn!(
target: "lumotia_startup",
error = %e,
"transcript trash purge failed — trash rows remain for next startup"
),
}
// Load saved preferences for webview injection.
let t1 = Instant::now();
let prefs_json = get_setting(&db, "lumotia_preferences").await.unwrap_or(None);