//! Harness-local questions mirror — the second increment of the //! loose-ends-v2 migration's questions phase (see the design comment on //! the tracking issue). c0re stays the `Ask`/`Answer` routing + delivery //! rendezvous (mara's "2a" call); this store only holds the *durable "I //! still owe/am owed a reply" view* `get_loose_ends` renders, so that view //! survives a hive migration the same way todos/reminders already do. //! //! One row per outstanding question **from this agent's point of view**, //! keyed by the c0re-assigned question id (globally unique — an agent is //! never both asker and target of the same question, self-asks are //! rejected at the c0re layer): //! //! - `role = "asked"`: this agent called `ask()`; `peer` is the target //! (`"operator"` when asked with `to: None`). Cleared when the //! `question_answered` system event for `id` arrives (see `main.rs`'s //! inbound-event hook). //! - `role = "answering"`: this agent received a `question_asked` system //! event for `id`; `peer` is the asker. Cleared when this agent calls //! `answer()` for `id` (see `hive-agent-mcp`'s tool impl). //! //! Known gap: if the asker cancels their own question, the target is not //! proactively notified today (`hive-c0re::questions::handle_cancel_loose_end` //! only notifies a *different* canceller than the asker, which never //! happens via the ownership-gated agent-socket cancel path). A target's //! `answering` row is cleared the next time they call `answer()` — c0re's //! "already answered"/"not found" rejection is treated as resolved-without- //! us and clears the mirror row (see `hive-agent-mcp`'s `answer()` tool) — //! but if they never call `answer()` at all, the row lingers with no //! proactive nudge. Flagged on the tracking issue rather than fully fixed. use std::path::Path; use std::sync::Mutex; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use rusqlite::{Connection, params}; const SCHEMA: &str = r" CREATE TABLE IF NOT EXISTS questions ( id INTEGER PRIMARY KEY, role TEXT NOT NULL, peer TEXT NOT NULL, question TEXT NOT NULL, asked_at INTEGER NOT NULL ); "; /// Which side of the question this agent is on. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Role { /// This agent asked; `peer` is who it's waiting on. Asked, /// This agent was asked; `peer` is who's waiting on it. Answering, } impl Role { const fn as_str(self) -> &'static str { match self { Role::Asked => "asked", Role::Answering => "answering", } } fn parse(s: &str) -> Option { match s { "asked" => Some(Role::Asked), "answering" => Some(Role::Answering), _ => None, } } } /// One mirrored question row. #[derive(Debug, Clone)] pub struct QuestionMirror { pub id: i64, pub role: Role, pub peer: String, pub question: String, pub asked_at: DateTime, } /// The harness-local questions mirror. Same sharing/locking shape as /// [`crate::reminders::Reminders`] — cheap behind an `Arc`, short sqlite /// writes guarded by a `Mutex`. pub struct Questions { conn: Mutex, } impl Questions { /// Open (creating if needed) the questions mirror at `path`. /// /// # Errors /// /// Propagates sqlite open / schema-apply failures. pub fn open(path: &Path) -> Result { let conn = Connection::open(path) .with_context(|| format!("open questions db {}", path.display()))?; conn.execute_batch(SCHEMA) .context("apply questions schema")?; Ok(Self { conn: Mutex::new(conn), }) } /// Record a new outstanding row. `INSERT OR REPLACE` so a caller that /// re-observes the same id (e.g. a redelivered `question_asked`) is a /// harmless no-op rather than a unique-constraint error. /// /// # Errors /// /// Propagates the sqlite insert failure. /// /// # Panics /// /// Panics if the connection mutex is poisoned. pub fn record(&self, id: i64, role: Role, peer: &str, question: &str) -> Result<()> { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT OR REPLACE INTO questions (id, role, peer, question, asked_at) \ VALUES (?1, ?2, ?3, ?4, ?5)", params![id, role.as_str(), peer, question, Utc::now().timestamp()], )?; Ok(()) } /// Drop the mirror row for `id` (either role) — the question resolved /// (answered/cancelled) from this agent's side. Returns the number of /// rows removed (0 = no local row for that id, a harmless no-op). /// /// # Errors /// /// Propagates the sqlite delete failure. /// /// # Panics /// /// Panics if the connection mutex is poisoned. pub fn clear(&self, id: i64) -> Result { let conn = self.conn.lock().unwrap(); let n = conn.execute("DELETE FROM questions WHERE id = ?1", params![id])?; Ok(n) } /// List every mirrored row, oldest-asked first — for `get_loose_ends` /// rendering. A row whose `role` column doesn't parse (corruption — /// should never happen via this module's own writes) is logged and /// skipped rather than either propagating a hard error (which would /// hide every other, valid row) or silently misattributing it as /// `Asked` (which would lie about who owes whom a reply). /// /// # Errors /// /// Propagates the sqlite prepare / query / column-read failures. /// /// # Panics /// /// Panics if the connection mutex is poisoned. pub fn list(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( "SELECT id, role, peer, question, asked_at FROM questions ORDER BY asked_at ASC", )?; let mut out = Vec::new(); let mut rows = stmt.query([])?; while let Some(row) = rows.next()? { let id: i64 = row.get(0)?; let role_str: String = row.get(1)?; let Some(role) = Role::parse(&role_str) else { tracing::warn!(%id, %role_str, "questions mirror: unknown role, skipping row"); continue; }; let asked_at_secs: i64 = row.get(4)?; out.push(QuestionMirror { id, role, peer: row.get(2)?, question: row.get(3)?, asked_at: chrono::DateTime::from_timestamp(asked_at_secs, 0) .unwrap_or_else(Utc::now), }); } Ok(out) } } #[cfg(test)] mod tests { use super::*; fn store() -> (tempfile::TempDir, Questions) { let dir = tempfile::tempdir().unwrap(); let db = Questions::open(&dir.path().join("questions.sqlite")).unwrap(); (dir, db) } #[test] fn record_and_list_both_roles() { let (_dir, s) = store(); s.record(1, Role::Asked, "atlas", "are we there yet") .unwrap(); s.record(2, Role::Answering, "mara", "is this fine") .unwrap(); let rows = s.list().unwrap(); assert_eq!(rows.len(), 2); assert_eq!(rows[0].id, 1); assert_eq!(rows[0].role, Role::Asked); assert_eq!(rows[0].peer, "atlas"); assert_eq!(rows[1].id, 2); assert_eq!(rows[1].role, Role::Answering); assert_eq!(rows[1].peer, "mara"); } #[test] fn clear_removes_the_row() { let (_dir, s) = store(); s.record(1, Role::Asked, "atlas", "q").unwrap(); assert_eq!(s.clear(1).unwrap(), 1); assert!(s.list().unwrap().is_empty()); assert_eq!(s.clear(1).unwrap(), 0, "already-cleared id is a no-op"); assert_eq!(s.clear(999).unwrap(), 0, "unknown id is a no-op"); } #[test] fn record_is_idempotent_on_redelivery() { let (_dir, s) = store(); s.record(1, Role::Answering, "mara", "q").unwrap(); s.record(1, Role::Answering, "mara", "q").unwrap(); assert_eq!( s.list().unwrap().len(), 1, "re-observing the same id doesn't duplicate" ); } }