235 lines
8.1 KiB
Rust
235 lines
8.1 KiB
Rust
//! 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<Self> {
|
|
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<Utc>,
|
|
}
|
|
|
|
/// 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<Connection>,
|
|
}
|
|
|
|
impl Questions {
|
|
/// Open (creating if needed) the questions mirror at `path`.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Propagates sqlite open / schema-apply failures.
|
|
pub fn open(path: &Path) -> Result<Self> {
|
|
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<usize> {
|
|
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<Vec<QuestionMirror>> {
|
|
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"
|
|
);
|
|
}
|
|
}
|