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.
137 lines
5.5 KiB
Rust
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.
|