From c316dc852de56b0b6629c543e62cac7242c9820b Mon Sep 17 00:00:00 2001 From: damocles Date: Thu, 23 Jul 2026 12:56:45 +0200 Subject: [PATCH] feat(#2635): consolidate todos + reminders into one hyperhive-state.sqlite --- hive-agent/src/db_migrate.rs | 207 +++++++++++++++++++++++++++++++++++ hive-agent/src/main.rs | 14 +++ hive-agent/src/paths.rs | 47 ++++++-- 3 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 hive-agent/src/db_migrate.rs diff --git a/hive-agent/src/db_migrate.rs b/hive-agent/src/db_migrate.rs new file mode 100644 index 00000000..503a001b --- /dev/null +++ b/hive-agent/src/db_migrate.rs @@ -0,0 +1,207 @@ +//! 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 a5249d33..954b7b80 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -10,6 +10,7 @@ //! before lib + bin were collapsed into one) plus the serve loop. mod client; +mod db_migrate; mod events; mod forge_notify; mod harness_state; @@ -467,6 +468,19 @@ 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::reminders_db()) { Ok(store) => Some(Arc::new(store)), diff --git a/hive-agent/src/paths.rs b/hive-agent/src/paths.rs index 9756eb38..98207886 100644 --- a/hive-agent/src/paths.rs +++ b/hive-agent/src/paths.rs @@ -40,19 +40,52 @@ pub fn harness_dir() -> PathBuf { hive_sh4re::paths::harness_dir() } -/// 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. +/// 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. +/// [`todos_db`] and [`reminders_db`] both resolve here — kept as separate +/// fns (rather than every call site reading `state_db` directly) so each +/// store's callers still say what they mean. +/// +/// 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. +#[must_use] +pub fn state_db() -> PathBuf { + harness_dir().join("hyperhive-state.sqlite") +} + +/// Harness-local todo store (loose-ends v2) path — see [`state_db`]. #[must_use] pub fn todos_db() -> PathBuf { + state_db() +} + +/// Harness-local reminder store path — see [`state_db`]. +#[must_use] +pub fn reminders_db() -> PathBuf { + state_db() +} + +/// 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 { harness_dir().join("hyperhive-todos.sqlite") } -/// Harness-local reminder store. Same rationale as [`todos_db`] — mutable -/// per-agent state the harness owns, kept out of the append-only events -/// sink. +/// Legacy pre-consolidation reminders db path, consulted only by +/// [`crate::db_migrate`] on the first boot after the upgrade. #[must_use] -pub fn reminders_db() -> PathBuf { +pub fn legacy_reminders_db() -> PathBuf { harness_dir().join("hyperhive-reminders.sqlite") }