feat(#2635): consolidate todos + reminders into one hyperhive-state.sqlite
This commit is contained in:
parent
64a14718fe
commit
c316dc852d
3 changed files with 261 additions and 7 deletions
207
hive-agent/src/db_migrate.rs
Normal file
207
hive-agent/src/db_migrate.rs
Normal file
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<S: Surface>(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)),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue