//! Question queue. Agents submit via `Ask`; the answer comes from //! either the operator (for `target IS NULL`) or a peer agent (via //! `Answer`, for agent-to-agent questions). ⚠️ The dashboard no longer //! has any UI or endpoint for the operator to actually answer a //! `target IS NULL` row (removed along with the rest of the dashboard's //! question surface) — see `questions.rs`'s module doc for the current //! state of that gap. //! //! Despite the file name (kept for git history sanity), this table //! now stores *all* asynchronous questions in the hive — both the //! operator-targeted ones and the peer-to-peer ones. `target IS //! NULL` is the operator path (back-compat with rows written before //! the column existed); `target = ''` is the //! agent-to-agent path. use std::path::Path; use std::sync::Mutex; use anyhow::{Context, Result, bail}; use chrono::Utc; use rusqlite::{Connection, OptionalExtension, params}; use crate::db::Migration; const SCHEMA: &str = r" CREATE TABLE IF NOT EXISTS operator_questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, asker TEXT NOT NULL, question TEXT NOT NULL, options_json TEXT NOT NULL, asked_at INTEGER NOT NULL, answered_at INTEGER, answer TEXT ); CREATE INDEX IF NOT EXISTS idx_operator_questions_pending ON operator_questions (id) WHERE answered_at IS NULL; "; /// Ordered schema migrations tracked in `schema_versions` (key /// `"operator_questions"`). Legacy databases are detected via the `target` /// column — the last column added before versioning — and fast-forwarded /// past all known migrations. const MIGRATIONS: &[Migration] = &[ // v1: `multi` — checkbox-style multi-option questions. Migration { sql: "ALTER TABLE operator_questions ADD COLUMN \ multi INTEGER NOT NULL DEFAULT 0", adds_column: Some(("operator_questions", "multi")), }, // v2: `deadline_at` — optional TTL after which the watchdog auto-resolves. Migration { sql: "ALTER TABLE operator_questions ADD COLUMN deadline_at INTEGER", adds_column: Some(("operator_questions", "deadline_at")), }, // v3: `target` — recipient of the question. NULL = operator (back-compat // default); non-null = peer-to-peer question. Migration { sql: "ALTER TABLE operator_questions ADD COLUMN target TEXT", adds_column: Some(("operator_questions", "target")), }, ]; pub struct OperatorQuestions { conn: Mutex, } impl OperatorQuestions { pub fn open(path: &Path) -> Result { let conn = crate::db::open(path, "operator_questions")?; conn.execute_batch(SCHEMA) .context("apply operator_questions schema")?; crate::db::apply_versioned_migrations(&conn, "operator_questions", MIGRATIONS)?; Ok(Self { conn: Mutex::new(conn), }) } pub fn submit( &self, asker: &str, question: &str, options: &[String], multi: bool, deadline_at: Option, target: Option<&str>, ) -> Result { let conn = self.conn.lock().unwrap(); let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into()); conn.execute( "INSERT INTO operator_questions (asker, question, options_json, multi, deadline_at, target, asked_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![ asker, question, options_json, i64::from(multi), deadline_at, target, Utc::now().timestamp(), ], )?; Ok(conn.last_insert_rowid()) } /// Mark a pending question answered. `answerer` is who's actually /// answering: `"operator"`, or an agent's own name when responding /// via `Answer`. Authorisation: /// /// - Operator-targeted questions (`target IS NULL`) can only be /// answered by `"operator"`. (Agents must not be able to spoof /// answers to operator questions — though as of the dashboard's /// ask/answer surface being removed, nothing currently calls /// this with `answerer = "operator"` for a `target IS NULL` row /// at all; the check stays as a guard, not a live path.) /// - Agent-targeted questions can only be answered by the /// declared target agent, OR by `"operator"` (operator override /// for stuck threads — useful when an agent is offline/down /// and someone has to close the loop). /// /// Returns `(question, asker, target)` so the caller can fire the /// `QuestionAnswered` event with the right answerer label and route /// it back to the original asker. pub fn answer( &self, id: i64, answer: &str, answerer: &str, ) -> Result<(String, String, Option)> { let conn = self.conn.lock().unwrap(); let row: Option<(String, String, Option, Option)> = conn .query_row( "SELECT question, asker, target, answered_at FROM operator_questions WHERE id = ?1", params![id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) .optional()?; let Some((question, asker, target, answered_at)) = row else { bail!("question {id} not found"); }; if answered_at.is_some() { bail!("question {id} already answered"); } // Authorisation check: must match the target, or be the operator // (operator-targeted questions are operator-only; the operator // can additionally override agent-to-agent questions to close // stuck threads). let authorised = match target.as_deref() { None => answerer == hive_sh4re::manager::OPERATOR_RECIPIENT, Some(t) => answerer == t || answerer == hive_sh4re::manager::OPERATOR_RECIPIENT, }; if !authorised { bail!( "question {id} not addressed to '{answerer}' (target = {:?})", target .as_deref() .unwrap_or(hive_sh4re::manager::OPERATOR_RECIPIENT) ); } conn.execute( "UPDATE operator_questions SET answer = ?1, answered_at = ?2 WHERE id = ?3", params![answer, Utc::now().timestamp(), id], )?; Ok((question, asker, target)) } /// Cancel a pending question on behalf of `canceller`. Returns /// `(question, asker, target)` so the caller can fire the usual /// `QuestionAnswered` event to the asker with a `[cancelled by /// ]` sentinel. /// /// Auth: the canceller must be one of: /// - the original asker (an agent withdrawing their own ask), /// - the operator (already covered by the existing `answer` path /// but allowed here too for symmetry / dashboard cancel), /// - a `privileged` caller (one that arrived on the manager socket — /// privileged hive-wide cleanup; derived from the socket, not a /// name match). /// /// Not the target — that's covered by `answer` (responding with /// an actual reply, sentinel or otherwise). pub fn cancel( &self, id: i64, canceller: &str, privileged: bool, ) -> Result<(String, String, Option)> { let conn = self.conn.lock().unwrap(); let row: Option<(String, String, Option, Option)> = conn .query_row( "SELECT question, asker, target, answered_at FROM operator_questions WHERE id = ?1", params![id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) .optional()?; let Some((question, asker, target, answered_at)) = row else { bail!("question {id} not found"); }; if answered_at.is_some() { bail!("question {id} already answered/cancelled"); } let authorised = privileged || canceller == asker || canceller == hive_sh4re::manager::OPERATOR_RECIPIENT; if !authorised { bail!("question {id}: '{canceller}' not allowed to cancel (asker = '{asker}')"); } let sentinel = format!("[cancelled by {canceller}]"); conn.execute( "UPDATE operator_questions SET answer = ?1, answered_at = ?2 WHERE id = ?3", params![sentinel, Utc::now().timestamp(), id], )?; Ok((question, asker, target)) } }