diff --git a/hive-agent/src/db_migrate.rs b/hive-agent/src/db_migrate.rs deleted file mode 100644 index 503a001b..00000000 --- a/hive-agent/src/db_migrate.rs +++ /dev/null @@ -1,207 +0,0 @@ -//! One-time on-boot migration from the pre-db-consolidation per-concern -//! sqlite files (`hyperhive-todos.sqlite`, `hyperhive-reminders.sqlite`) -//! into the single consolidated `hyperhive-state.sqlite` (mara: "not yet -//! another sqlite ... add it to one of the existing ones"). Idempotent + best -//! effort: `run` is called once in `main.rs`, before anything opens the -//! consolidated db — a fresh harness boot with no legacy files (or one that -//! already migrated) is a silent no-op. -//! -//! Two legacy shapes fold in: -//! - `hyperhive-todos.sqlite` already has exactly the `todos` table the -//! consolidated db wants and nothing touches it during the rename window -//! (this runs before the todo/reminder subsystems start), so a plain -//! filesystem rename is correct and cheap — no sqlite involved. -//! - `hyperhive-reminders.sqlite`'s `reminders` table then gets copied in via -//! `ATTACH DATABASE` + `INSERT INTO ... SELECT`, preserving row ids (a -//! reminder id an agent saw in a `get_loose_ends` call just before the -//! migrating boot must still `cancel` correctly after it), and the legacy -//! file is removed once the copy lands. - -use std::path::Path; - -use anyhow::{Context, Result}; -use rusqlite::{Connection, params}; - -/// Fold the legacy `legacy_todos`/`legacy_reminders` files (if present) into -/// the single consolidated `state_db` path. No-op if `state_db` already -/// exists — legacy files are only ever consulted on the first boot after -/// the db-consolidation upgrade. -/// -/// # Errors -/// -/// Propagates filesystem rename / sqlite attach-copy failures. Callers -/// treat this as best-effort (log and continue) — `Todos`/`Reminders::open` -/// against `state_db` still work standalone either way (todos would simply -/// start empty rather than inheriting the legacy rows). Retry-next-boot -/// only holds for failures before `state_db` exists (the todos rename, or -/// the reminders copy itself): once the copy has landed, `state_db` exists, -/// so a `remove_file(legacy_reminders)` failure afterwards leaves an -/// orphaned (already-copied, now-inert) legacy file behind rather than -/// retrying — the rows are safe, but the leftover file won't self-clean. -pub fn run(state_db: &Path, legacy_todos: &Path, legacy_reminders: &Path) -> Result<()> { - if state_db.exists() { - return Ok(()); - } - if legacy_todos.exists() { - std::fs::rename(legacy_todos, state_db).with_context(|| { - format!( - "rename legacy todos db {} -> {}", - legacy_todos.display(), - state_db.display() - ) - })?; - } - if legacy_reminders.exists() { - // Ensure `state_db` exists and has the `reminders` schema — either - // the rename above already brought a todos-only db across (no - // `reminders` table yet), or there was no legacy todos file at all - // and this creates `state_db` fresh. - crate::reminders::Reminders::open(state_db) - .context("apply reminders schema to consolidated db")?; - migrate_reminders_rows(state_db, legacy_reminders) - .context("copy legacy reminders rows into consolidated db")?; - std::fs::remove_file(legacy_reminders).with_context(|| { - format!("remove legacy reminders db {}", legacy_reminders.display()) - })?; - } - Ok(()) -} - -/// Copy every row of the legacy `reminders` table into `state_db`'s table -/// of the same name, preserving `id`. Uses a scratch `ATTACH DATABASE` so -/// the copy is one sqlite statement rather than a row-by-row read/insert -/// loop. -fn migrate_reminders_rows(state_db: &Path, legacy_reminders: &Path) -> Result<()> { - let conn = Connection::open(state_db) - .with_context(|| format!("open consolidated db {}", state_db.display()))?; - conn.execute( - "ATTACH DATABASE ?1 AS legacy", - params![legacy_reminders.to_string_lossy()], - ) - .context("attach legacy reminders db")?; - let copied = conn - .execute_batch( - "INSERT INTO reminders (id, message, file_path, due_at, created_at, sent_at) \ - SELECT id, message, file_path, due_at, created_at, sent_at FROM legacy.reminders;", - ) - .context("copy legacy reminders rows"); - // DETACH regardless of the copy's outcome so the connection doesn't - // leak the attachment on an early return via `?` inside this fn. - let detach = conn.execute("DETACH DATABASE legacy", []); - copied?; - detach.context("detach legacy reminders db")?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{reminders::Reminders, todos::Todos}; - - #[test] - fn no_legacy_files_is_a_noop() { - let dir = tempfile::tempdir().unwrap(); - let state = dir.path().join("hyperhive-state.sqlite"); - let todos = dir.path().join("hyperhive-todos.sqlite"); - let reminders = dir.path().join("hyperhive-reminders.sqlite"); - run(&state, &todos, &reminders).unwrap(); - assert!(!state.exists(), "nothing to migrate, nothing created"); - } - - #[test] - fn state_db_already_present_short_circuits() { - let dir = tempfile::tempdir().unwrap(); - let state = dir.path().join("hyperhive-state.sqlite"); - let legacy_todos = dir.path().join("hyperhive-todos.sqlite"); - std::fs::write(&legacy_todos, b"should be left alone").unwrap(); - Todos::open(&state).unwrap(); - run( - &state, - &legacy_todos, - &dir.path().join("hyperhive-reminders.sqlite"), - ) - .unwrap(); - assert_eq!( - std::fs::read(&legacy_todos).unwrap(), - b"should be left alone", - "already-migrated boot must not touch a leftover legacy file" - ); - } - - #[test] - fn renames_legacy_todos_into_state_db() { - let dir = tempfile::tempdir().unwrap(); - let legacy_todos = dir.path().join("hyperhive-todos.sqlite"); - let state = dir.path().join("hyperhive-state.sqlite"); - { - let store = Todos::open(&legacy_todos).unwrap(); - store - .upsert("matrix", Some("!a:x"), "1 unread", None) - .unwrap(); - } - run( - &state, - &legacy_todos, - &dir.path().join("hyperhive-reminders.sqlite"), - ) - .unwrap(); - assert!(!legacy_todos.exists(), "renamed away"); - let migrated = Todos::open(&state).unwrap(); - let rows = migrated.list(None).unwrap(); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].summary, "1 unread"); - } - - #[test] - fn copies_legacy_reminders_preserving_ids() { - let dir = tempfile::tempdir().unwrap(); - let legacy_reminders = dir.path().join("hyperhive-reminders.sqlite"); - let state = dir.path().join("hyperhive-state.sqlite"); - let orig_id = { - let store = Reminders::open(&legacy_reminders).unwrap(); - store.store("check on x", None, 1000).unwrap() - }; - run( - &state, - &dir.path().join("hyperhive-todos.sqlite"), - &legacy_reminders, - ) - .unwrap(); - assert!(!legacy_reminders.exists(), "removed after copy"); - let migrated = Reminders::open(&state).unwrap(); - let pending = migrated.list_pending().unwrap(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0].id, orig_id, "id preserved across the copy"); - assert_eq!(pending[0].due_at, 1000); - } - - #[test] - fn folds_both_legacy_files_into_one_consolidated_db() { - let dir = tempfile::tempdir().unwrap(); - let legacy_todos = dir.path().join("hyperhive-todos.sqlite"); - let legacy_reminders = dir.path().join("hyperhive-reminders.sqlite"); - let state = dir.path().join("hyperhive-state.sqlite"); - { - Todos::open(&legacy_todos) - .unwrap() - .upsert("bash", None, "task done", None) - .unwrap(); - Reminders::open(&legacy_reminders) - .unwrap() - .store("ping", None, 500) - .unwrap(); - } - run(&state, &legacy_todos, &legacy_reminders).unwrap(); - assert!(!legacy_todos.exists()); - assert!(!legacy_reminders.exists()); - assert_eq!(Todos::open(&state).unwrap().list(None).unwrap().len(), 1); - assert_eq!( - Reminders::open(&state) - .unwrap() - .list_pending() - .unwrap() - .len(), - 1 - ); - } -} diff --git a/hive-agent/src/main.rs b/hive-agent/src/main.rs index 1206a5f0..a5249d33 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -10,7 +10,6 @@ //! before lib + bin were collapsed into one) plus the serve loop. mod client; -mod db_migrate; mod events; mod forge_notify; mod harness_state; @@ -468,21 +467,8 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { // never observes a "closed" state. Opened before the todo socket // below so the same `Arc` can be handed to its request dispatch // (reminder ops share the todo socket/listener). - // Fold the pre-consolidation per-concern db files into the single - // `hyperhive-state.sqlite`, if this is the first boot since the - // upgrade — see `db_migrate` module docs. Best-effort: a failure here - // just leaves the legacy file(s) in place to retry next boot, so the - // subsystems below still start (`Todos`/`Reminders::open` create the - // schema fresh if nothing migrated across). - if let Err(e) = db_migrate::run( - &paths::state_db(), - &paths::legacy_todos_db(), - &paths::legacy_reminders_db(), - ) { - tracing::warn!(error = ?e, "legacy db_migrate failed — continuing with fresh/partial state db"); - } let (reminder_tx, reminder_rx) = tokio::sync::mpsc::unbounded_channel(); - let reminder_store = match reminders::Reminders::open(&paths::state_db()) { + let reminder_store = match reminders::Reminders::open(&paths::reminders_db()) { Ok(store) => Some(Arc::new(store)), Err(e) => { tracing::error!(error = ?e, "open reminders db failed — reminder delivery disabled"); @@ -500,7 +486,7 @@ async fn serve_main(socket: &Path, poll_ms: u64) -> Result<()> { // acceptable since a from-scratch harness boot either has a writable // harness dir or doesn't). let todo_wake = Arc::new(tokio::sync::Notify::new()); - match todos::Todos::open(&paths::state_db()) { + match todos::Todos::open(&paths::todos_db()) { Ok(store) => { let store = Arc::new(store); let wake = todo_wake.clone(); diff --git a/hive-agent/src/paths.rs b/hive-agent/src/paths.rs index ebd4e200..9756eb38 100644 --- a/hive-agent/src/paths.rs +++ b/hive-agent/src/paths.rs @@ -40,40 +40,19 @@ pub fn harness_dir() -> PathBuf { hive_sh4re::paths::harness_dir() } -/// Consolidated harness-local state db — currently todos + reminders, one -/// table each — mutable per-agent state the harness owns, kept out of the -/// append-only `hyperhive-events.sqlite` sink. Per mara's call ("not yet -/// another sqlite! todos, reminders, questions should be like three tiny -/// tables in one 500kb sqlite"), this file is the shared home for all -/// loose-ends-v2 stores; each store's `open()` only applies its own -/// `CREATE TABLE IF NOT EXISTS`, so opening multiple stores against the -/// same path is safe (distinct table names, no schema collision). A -/// questions mirror table is the planned third tenant (a following -/// increment), not part of this schema yet. -/// Both the todos and reminders stores open this same path directly (see -/// their `open()` call sites) — distinct table names mean no schema -/// collision, so there's no need for per-store path wrapper fns here. -/// -/// Before this consolidation, todos and reminders lived in their own -/// `hyperhive-todos.sqlite` / `hyperhive-reminders.sqlite` files; a -/// one-time boot migration (`db_migrate::run`) folds those into this path -/// the first time a harness boots after the upgrade. +/// Harness-local todo store (loose-ends v2). A dedicated sqlite db under +/// the harness dir — the todos are mutable per-agent state the harness +/// owns, kept out of the append-only `hyperhive-events.sqlite` sink. #[must_use] -pub fn state_db() -> PathBuf { - harness_dir().join("hyperhive-state.sqlite") -} - -/// Legacy pre-consolidation todos db path, consulted only by -/// [`crate::db_migrate`] on the first boot after the upgrade. -#[must_use] -pub fn legacy_todos_db() -> PathBuf { +pub fn todos_db() -> PathBuf { harness_dir().join("hyperhive-todos.sqlite") } -/// Legacy pre-consolidation reminders db path, consulted only by -/// [`crate::db_migrate`] on the first boot after the upgrade. +/// Harness-local reminder store. Same rationale as [`todos_db`] — mutable +/// per-agent state the harness owns, kept out of the append-only events +/// sink. #[must_use] -pub fn legacy_reminders_db() -> PathBuf { +pub fn reminders_db() -> PathBuf { harness_dir().join("hyperhive-reminders.sqlite") } diff --git a/hive-agent/src/vacuum.rs b/hive-agent/src/vacuum.rs index c95e2ebc..06275f9a 100644 --- a/hive-agent/src/vacuum.rs +++ b/hive-agent/src/vacuum.rs @@ -65,9 +65,9 @@ fn sweep_once() { } } - let state_db = crate::paths::state_db(); - if state_db.exists() { - match vacuum_reminders(&state_db) { + let reminders_db = crate::paths::reminders_db(); + if reminders_db.exists() { + match vacuum_reminders(&reminders_db) { Ok(0) => {} Ok(n) => tracing::info!(removed = n, "reminders vacuum"), Err(e) => tracing::warn!(error = ?e, "reminders vacuum failed"),