Compare commits
4 changed files with 13 additions and 255 deletions
|
|
@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
//! before lib + bin were collapsed into one) plus the serve loop.
|
//! before lib + bin were collapsed into one) plus the serve loop.
|
||||||
|
|
||||||
mod client;
|
mod client;
|
||||||
mod db_migrate;
|
|
||||||
mod events;
|
mod events;
|
||||||
mod forge_notify;
|
mod forge_notify;
|
||||||
mod harness_state;
|
mod harness_state;
|
||||||
|
|
@ -468,21 +467,8 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
||||||
// never observes a "closed" state. Opened before the todo socket
|
// never observes a "closed" state. Opened before the todo socket
|
||||||
// below so the same `Arc` can be handed to its request dispatch
|
// below so the same `Arc` can be handed to its request dispatch
|
||||||
// (reminder ops share the todo socket/listener).
|
// (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_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)),
|
Ok(store) => Some(Arc::new(store)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(error = ?e, "open reminders db failed — reminder delivery disabled");
|
tracing::error!(error = ?e, "open reminders db failed — reminder delivery disabled");
|
||||||
|
|
@ -500,7 +486,7 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
||||||
// acceptable since a from-scratch harness boot either has a writable
|
// acceptable since a from-scratch harness boot either has a writable
|
||||||
// harness dir or doesn't).
|
// harness dir or doesn't).
|
||||||
let todo_wake = Arc::new(tokio::sync::Notify::new());
|
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) => {
|
Ok(store) => {
|
||||||
let store = Arc::new(store);
|
let store = Arc::new(store);
|
||||||
let wake = todo_wake.clone();
|
let wake = todo_wake.clone();
|
||||||
|
|
|
||||||
|
|
@ -40,40 +40,19 @@ pub fn harness_dir() -> PathBuf {
|
||||||
hive_sh4re::paths::harness_dir()
|
hive_sh4re::paths::harness_dir()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consolidated harness-local state db — currently todos + reminders, one
|
/// Harness-local todo store (loose-ends v2). A dedicated sqlite db under
|
||||||
/// table each — mutable per-agent state the harness owns, kept out of the
|
/// the harness dir — the todos are mutable per-agent state the harness
|
||||||
/// append-only `hyperhive-events.sqlite` sink. Per mara's call ("not yet
|
/// owns, kept out of the append-only `hyperhive-events.sqlite` sink.
|
||||||
/// 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.
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn state_db() -> PathBuf {
|
pub fn todos_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 {
|
|
||||||
harness_dir().join("hyperhive-todos.sqlite")
|
harness_dir().join("hyperhive-todos.sqlite")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Legacy pre-consolidation reminders db path, consulted only by
|
/// Harness-local reminder store. Same rationale as [`todos_db`] — mutable
|
||||||
/// [`crate::db_migrate`] on the first boot after the upgrade.
|
/// per-agent state the harness owns, kept out of the append-only events
|
||||||
|
/// sink.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn legacy_reminders_db() -> PathBuf {
|
pub fn reminders_db() -> PathBuf {
|
||||||
harness_dir().join("hyperhive-reminders.sqlite")
|
harness_dir().join("hyperhive-reminders.sqlite")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,9 @@ fn sweep_once() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let state_db = crate::paths::state_db();
|
let reminders_db = crate::paths::reminders_db();
|
||||||
if state_db.exists() {
|
if reminders_db.exists() {
|
||||||
match vacuum_reminders(&state_db) {
|
match vacuum_reminders(&reminders_db) {
|
||||||
Ok(0) => {}
|
Ok(0) => {}
|
||||||
Ok(n) => tracing::info!(removed = n, "reminders vacuum"),
|
Ok(n) => tracing::info!(removed = n, "reminders vacuum"),
|
||||||
Err(e) => tracing::warn!(error = ?e, "reminders vacuum failed"),
|
Err(e) => tracing::warn!(error = ?e, "reminders vacuum failed"),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue