286 lines
12 KiB
Rust
286 lines
12 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::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<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(&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<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");
|
|
// 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::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<Coordinator>,
|
|
canceller: &str,
|
|
kind: hive_sh4re::CancelLooseEndKind,
|
|
id: i64,
|
|
) -> Result<(), String> {
|
|
match kind {
|
|
hive_sh4re::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::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::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::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.
|