Closes the #3110 split — lib.rs is now just the crate doc comment and the pub mod list. journal.rs's new doc comment fixes a pre-existing bug: the old JournalPriority doc text in lib.rs was actually half Capability's doc (a leftover from an earlier reorder that moved the code but not the comment above it).
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::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<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::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<Coordinator>,
|
|
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.
|