//! 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() }` → stored with `target = `; //! a `HelperEvent::QuestionAsked` is pushed into ``'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::socket_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, asker: &str, question: &str, options: &[String], multi: bool, ttl_seconds: Option, to: Option<&str>, ) -> Result { 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::manager::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::manager::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(&crate::coordinator::QuestionAdded { 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, 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"); // Use answerer as the broker `from` so the asker's terminal shows // the real name (agent or "operator") instead of "system". coord.notify_agent_from( answerer, &asker, &hive_sh4re::manager::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(()) } /// Handle `CancelLooseEnd` from a per-agent socket. Dispatches by kind, each /// with its own auth check: question / reminder cancels are ownership-only /// (an agent cancels its own), and approval cancels require the `approvals` /// tool-group (the grantable capability) AND ownership — the canceller must /// be the approval's submitter — so no positional / hardcoded privilege and /// no cross-agent cancellation. (The operator's cancel-anything path is a /// separate handler.) /// On question cancel, fires the `QuestionAnswered` event back to the asker /// so the harness loop can react (mirrors the operator-cancel dashboard path). pub fn handle_cancel_loose_end( coord: &Arc, canceller: &str, kind: hive_sh4re::inbox::CancelLooseEndKind, id: i64, ) -> Result<(), String> { match kind { hive_sh4re::inbox::CancelLooseEndKind::Question => { // Agent-socket path: never privileged — an agent may only cancel // its own question (ownership). The operator's cancel-anything // path goes through a separate handler with `privileged = true`. let (question, asker, target) = coord .questions .cancel(id, canceller, false) .map_err(|e| format!("{e:#}"))?; let sentinel = format!("[cancelled by {canceller}]"); tracing::info!(%id, %canceller, %asker, "question cancelled"); // Only notify the asker if they didn't cancel it themselves. // Self-cancels are already known to the canceller — sending // a QuestionAnswered back would cause the harness to process // its own cancel as an incoming answer. if asker != canceller { coord.notify_agent_from( canceller, &asker, &hive_sh4re::manager::HelperEvent::QuestionAnswered { id, question, answer: sentinel.clone(), answerer: canceller.to_owned(), }, ); } coord.emit_question_resolved(id, &sentinel, canceller, true, target.as_deref()); Ok(()) } hive_sh4re::inbox::CancelLooseEndKind::Reminder => { // Reminders are now agent-local (in-container store) — the // agent-mcp `cancel_loose_end` tool branches on this kind and // dials the agent's own socket directly, never forwarding to // hive-c0re. This arm should be unreachable in practice; kept // only so the match stays exhaustive. Err(format!( "reminder {id}: reminders are handled locally by the agent, \ not by hive-c0re" )) } hive_sh4re::inbox::CancelLooseEndKind::Approval => { // Withdrawing an approval needs the grantable `approvals` // tool-group (held by any approval-submitting orchestrator) // AND ownership: only the agent that submitted the approval // may withdraw it. Without the ownership check, any // approvals-group agent could cancel any other's approval by // id. A NULL submitter (legacy row predating the column) is // treated as operator-initiated (no agent tracking predates // the column). check_can_cancel_approval(canceller)?; let submitter = coord .approvals .submitter_of(id) .map_err(|e| format!("{e:#}"))? .unwrap_or_else(|| "operator".to_owned()); if submitter != canceller { return Err(format!( "cancel_loose_end: approval {id} was submitted by {submitter}, \ not {canceller}; only the submitting agent can withdraw it" )); } let approval = coord .approvals .mark_cancelled(id, canceller) .map_err(|e| format!("{e:#}"))?; tracing::info!(%id, %canceller, agent = %approval.agent, "approval cancelled"); let sha_short = approval .fetched_sha .as_deref() .map(|s| s[..s.len().min(12)].to_owned()); coord.emit_approval_resolved(crate::coordinator::ApprovalResolved { id: approval.id, agent: approval.agent.as_str(), approval_kind: approval.kind.as_str(), sha_short, status: "cancelled", note: approval.note, description: approval.description, }); Ok(()) } } } /// Capability guard on the `Approval` cancel arm: the caller must hold the /// `approvals` tool-group (the grantable capability for approval-submitting /// orchestrators), checked server-side via `tool_groups::groups_for`. Pulled /// out so the auth check has its own focused unit test — exercising the full /// `handle_cancel_loose_end` flow would need a `Coordinator` fixture (broker + /// sqlite + in-memory questions) we don't have. Keys on a grantable capability, /// not a positional / hardcoded privilege. fn check_can_cancel_approval(canceller: &str) -> Result<(), String> { const APPROVALS_GROUP: &str = "approvals"; if crate::tool_groups::groups_for(canceller) .iter() .any(|g| g == APPROVALS_GROUP) { Ok(()) } else { Err( "cancel_loose_end: cancelling approval rows requires the `approvals` tool group" .to_owned(), ) } } #[cfg(test)] mod tests { use super::*; #[test] fn approval_cancel_rejects_callers_without_the_approvals_group() { // A caller that doesn't hold the `approvals` tool-group must not be // able to cancel approval rows even if it invents an id. The guard is // server-side so client cooperation is irrelevant — and it keys on a // grantable capability (the tool-group), not on any agent name. // `groups_for` of a name with no tool_groups.json entry is empty. let err = check_can_cancel_approval("nobody-with-no-groups").unwrap_err(); assert!(err.contains("approvals` tool group"), "{err}"); } } // 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.