hyperhive/hive-c0re/src/questions.rs
müde a15fafb5de dashboard: surface peer questions + operator override
questions pane now shows both operator-targeted threads
(target IS NULL) and agent-to-agent threads (target = some
agent). filter chips above the list: all / @operator / @peer /
per-participant. peer rows get a mauve left rule + a 0V3RR1D3
button that POSTs the same /answer-question endpoint
(OperatorQuestions::answer already permits the operator as
answerer on any target).

wire changes: OperatorQuestions gains pending_all +
recent_answered_all; QuestionAdded + QuestionResolved events
carry target: Option<String>; emit sites drop their
target.is_none() guard. answered-history rows show the
answerer prefix so override answers are auditable at a glance.
2026-05-17 22:06:53 +02:00

137 lines
5.5 KiB
Rust

//! Shared dispatch helpers for the `Ask` / `Answer` flow. Both the
//! agent socket and the manager socket call into here so the routing
//! semantics — recipient = operator vs. peer agent, answerer
//! authorisation, asker-notification — only live in one place.
//!
//! Routing rules at a glance:
//!
//! - `Ask { to: None | Some("operator") }` → stored with `target = NULL`;
//! the dashboard's `pending()` query surfaces it; operator answers
//! via the dashboard.
//! - `Ask { to: Some(<agent>) }` → stored with `target = <agent>`;
//! a `HelperEvent::QuestionAsked` is pushed into `<agent>`'s
//! inbox so they can `Answer { id, answer }` on their own socket.
//! - `Answer { id, answer }` → permission-checked in
//! `OperatorQuestions::answer` (only the target agent or the
//! operator can answer; both paths fire the same
//! `QuestionAnswered` event to the asker).
use std::sync::Arc;
use crate::coordinator::Coordinator;
use crate::limits;
use crate::manager_server::spawn_question_watchdog;
/// Cap on how long an asker can demand an answer before the watchdog
/// auto-resolves with `[expired]`. Six hours mirrors typical agent
/// session lifetimes — beyond that an unanswered question is
/// effectively a dead thread and should be re-asked, not blocked on.
const MAX_TTL_SECONDS: u64 = 6 * 60 * 60;
/// Handle either surface's `Ask` request. Returns the queued
/// question id on success or a caller-ready error string. Caller is
/// responsible for wrapping in the matching `*Response::Err` /
/// `QuestionQueued` variant.
pub fn handle_ask(
coord: &Arc<Coordinator>,
asker: &str,
question: &str,
options: &[String],
multi: bool,
ttl_seconds: Option<u64>,
to: Option<&str>,
) -> Result<i64, String> {
limits::check_size("question", question)?;
// Normalise `Some("operator")` → None so the storage layer
// only has to think about NULL vs. non-NULL targets, not
// "is this string the operator?".
let target = match to {
None => None,
Some(t) if t == hive_sh4re::OPERATOR_RECIPIENT => None,
Some("") => {
return Err("ask: `to` cannot be empty (omit it for the operator path)".to_owned());
}
Some(t) if t == asker => {
return Err("ask: cannot ask yourself a question (would loop forever)".to_owned());
}
Some(t) => Some(t),
};
let ttl = ttl_seconds.map(|s| s.min(MAX_TTL_SECONDS));
let deadline_at = ttl.and_then(|s| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
i64::try_from(s).ok().map(|s| now + s)
});
let id = coord
.questions
.submit(asker, question, options, multi, deadline_at, target)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(%id, %asker, ?target, ?deadline_at, "question queued");
// Agent-targeted questions need to wake the recipient — drop a
// QuestionAsked event into their inbox so the answerer doesn't
// have to poll. Operator-targeted questions show up on the
// dashboard's pending pane via `pending()` instead, plus a
// `QuestionAdded` dashboard event so the browser updates live.
if let Some(target_agent) = target {
coord.notify_agent(
target_agent,
&hive_sh4re::HelperEvent::QuestionAsked {
id,
asker: asker.to_owned(),
question: question.to_owned(),
options: options.to_vec(),
multi,
},
);
}
// Always fire on the dashboard channel — both operator-targeted
// and peer threads now surface in the dashboard's questions pane.
coord.emit_question_added(id, asker, question, options, multi, deadline_at, target);
if let Some(t) = ttl {
spawn_question_watchdog(coord, id, t);
}
Ok(id)
}
/// Handle either surface's `Answer` request. Returns `Ok(())` on
/// success or a caller-ready error string. Authorisation lives in
/// `OperatorQuestions::answer` — we only have to wire the result
/// back to the asker as a `QuestionAnswered` event.
pub fn handle_answer(
coord: &Arc<Coordinator>,
answerer: &str,
id: i64,
answer: &str,
) -> Result<(), String> {
limits::check_size("answer", answer)?;
let (question, asker, target) = coord
.questions
.answer(id, answer, answerer)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(%id, %answerer, %asker, "question answered");
coord.notify_agent(
&asker,
&hive_sh4re::HelperEvent::QuestionAnswered {
id,
question,
answer: answer.to_owned(),
answerer: answerer.to_owned(),
},
);
// Dashboard surfaces both operator-targeted and peer threads;
// emit unconditionally so the derived store moves the row.
// `cancelled = false` because this path is a real answer (the
// operator-cancel button goes through `post_cancel_question`).
coord.emit_question_resolved(id, answer, answerer, false, target.as_deref());
Ok(())
}
// Real coverage needs a `Coordinator` fixture (broker + sqlite +
// in-memory questions). Skipped for now — the normalisation branches
// in `handle_ask` are short enough to read line-by-line; once we add
// a coord test harness, drop integration tests here for: self-target
// rejection, operator-string passthrough, agent-to-agent QuestionAsked
// emission, and `Answer` authorisation.