hive-c0re/hive-sh4re: remove the ask/answer wire protocol + core routing
This commit is contained in:
parent
46183795dd
commit
2850270829
23 changed files with 177 additions and 851 deletions
|
|
@ -15,7 +15,6 @@ use crate::broker::Broker;
|
|||
use crate::container_view::{self, ContainerView};
|
||||
use crate::dashboard_events::DashboardEvent;
|
||||
use crate::job_queue::RunningTransient;
|
||||
use crate::operator_questions::OperatorQuestions;
|
||||
use crate::socket_server::{self, AgentSocket};
|
||||
|
||||
/// Capacity of the dashboard event channel. Slow browser subscribers
|
||||
|
|
@ -32,7 +31,6 @@ const LAST_STOPPED_RUNNING_KEY: &str = "last_stopped_running";
|
|||
pub struct Coordinator {
|
||||
pub broker: Arc<Broker>,
|
||||
pub approvals: Arc<Approvals>,
|
||||
pub questions: Arc<OperatorQuestions>,
|
||||
/// Scheduled-prompts queue. One sqlite connection,
|
||||
/// internal mutex; the worker drains due rows and the manager
|
||||
/// handlers insert / cancel through the same handle.
|
||||
|
|
@ -467,7 +465,6 @@ impl Coordinator {
|
|||
} = env;
|
||||
let broker = Broker::open(db_path).context("open broker")?;
|
||||
let approvals = Approvals::open(db_path).context("open approvals")?;
|
||||
let questions = OperatorQuestions::open(db_path).context("open operator_questions")?;
|
||||
let scheduled_prompts = crate::scheduled_prompts::ScheduledPrompts::open(db_path)
|
||||
.context("open scheduled_prompts")?;
|
||||
// BuildLogs wants a directory (it picks its own `build_logs.sqlite`
|
||||
|
|
@ -496,7 +493,6 @@ impl Coordinator {
|
|||
Ok(Self {
|
||||
broker: Arc::new(broker),
|
||||
approvals: Arc::new(approvals),
|
||||
questions: Arc::new(questions),
|
||||
scheduled_prompts: Arc::new(scheduled_prompts),
|
||||
build_logs,
|
||||
audit_log,
|
||||
|
|
@ -971,9 +967,8 @@ impl Coordinator {
|
|||
std::fs::create_dir_all(&agent_dir)
|
||||
.with_context(|| format!("create agent dir {}", agent_dir.display()))?;
|
||||
let socket_path = Self::socket_path(name);
|
||||
// Hand the full Coordinator to the per-agent socket — it
|
||||
// needs broker + operator_questions to handle the agent-side
|
||||
// `ask` / `answer` tools, not just the broker.
|
||||
// Hand the full Coordinator to the per-agent socket — it needs
|
||||
// more than just the broker (approvals, scheduled_prompts, ...).
|
||||
let socket = socket_server::start(name, &socket_path, self.clone())?;
|
||||
self.agents.lock().unwrap().insert(name.to_owned(), socket);
|
||||
Ok(agent_dir)
|
||||
|
|
@ -1336,17 +1331,15 @@ impl Coordinator {
|
|||
|
||||
/// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded
|
||||
/// the same way as `notify_manager` (sender = `SYSTEM_SENDER`,
|
||||
/// body = JSON-encoded event). Used to route `QuestionAnswered`
|
||||
/// events back to the agent that called `ask`, `QuestionAsked`
|
||||
/// events to the target of a peer question, etc.
|
||||
/// body = JSON-encoded event) — e.g. `ContainerCrash`, `NeedsUpdate`.
|
||||
pub fn notify_agent(&self, agent: &str, event: &hive_sh4re::manager::HelperEvent) {
|
||||
self.notify_agent_from(hive_sh4re::manager::SYSTEM_SENDER, agent, event);
|
||||
}
|
||||
|
||||
/// Same as `notify_agent` but with an explicit sender. Use this
|
||||
/// when the event originates from a known agent or the operator
|
||||
/// (e.g. `QuestionAnswered` — the answerer should be the `from`,
|
||||
/// not `system`) so the recipient's terminal shows the right name.
|
||||
/// rather than the system itself, so the recipient's terminal
|
||||
/// shows the right name instead of `system`.
|
||||
pub fn notify_agent_from(
|
||||
&self,
|
||||
from: &str,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ mod meta;
|
|||
mod migrate;
|
||||
mod paths;
|
||||
mod priv_client;
|
||||
mod questions;
|
||||
mod server;
|
||||
mod snapshot_push;
|
||||
mod socket_server;
|
||||
|
|
@ -47,9 +46,7 @@ pub(crate) use agent_config::{capabilities, limits, resource_limits, tool_groups
|
|||
pub(crate) use stats::{
|
||||
container_stats, hive_stats, host_stats, otel_metrics, sweep_health, warnings,
|
||||
};
|
||||
pub(crate) use stores::{
|
||||
approvals, audit_log, broker, build_logs, db, operator_questions, power, scheduled_prompts,
|
||||
};
|
||||
pub(crate) use stores::{approvals, audit_log, broker, build_logs, db, power, scheduled_prompts};
|
||||
pub(crate) use workers::{
|
||||
agent_sockets, auto_update, crash_watch, knowledge, mcp_sockets, scheduled_prompts_worker,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,276 +0,0 @@
|
|||
//! 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`.
|
||||
//! ⚠️ As of the ask/answer removal's dashboard-backend slice, nothing
|
||||
//! surfaces or answers an operator-targeted row any more — the
|
||||
//! dashboard's questions pane, its `/api/answer-question` /
|
||||
//! `/api/cancel-question` endpoints, and the `pending_all()`/
|
||||
//! `recent_answered_all()` reads that fed them are all gone. An
|
||||
//! operator-targeted `ask()` (if anything still calls it — the MCP
|
||||
//! tool itself was removed earlier in the same effort) would queue a
|
||||
//! row nothing can ever resolve. Left as-is rather than special-cased,
|
||||
//! since the whole `Ask`/`Answer` flow this file implements is itself
|
||||
//! slated for removal next.
|
||||
//! - `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.
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
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(),
|
||||
},
|
||||
);
|
||||
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(),
|
||||
},
|
||||
);
|
||||
}
|
||||
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.
|
||||
|
|
@ -208,37 +208,12 @@ pub(crate) async fn dispatch_shared(
|
|||
}
|
||||
hive_core_agent_sock::Request::Wake { from, body } => handle_wake(coord, agent, from, body),
|
||||
hive_core_agent_sock::Request::Recent { limit } => handle_recent(coord, agent, *limit),
|
||||
hive_core_agent_sock::Request::Ask {
|
||||
question,
|
||||
options,
|
||||
multi,
|
||||
ttl_seconds,
|
||||
to,
|
||||
} => crate::questions::handle_ask(
|
||||
coord,
|
||||
agent,
|
||||
question,
|
||||
options,
|
||||
*multi,
|
||||
*ttl_seconds,
|
||||
to.as_ref().map(hive_types::Ident::as_str),
|
||||
)
|
||||
.map_or_else(
|
||||
|message| hive_core_agent_sock::Response::Err { message },
|
||||
|id| hive_core_agent_sock::Response::QuestionQueued { id },
|
||||
),
|
||||
hive_core_agent_sock::Request::Answer { id, answer } => {
|
||||
crate::questions::handle_answer(coord, agent, *id, answer).map_or_else(
|
||||
|message| hive_core_agent_sock::Response::Err { message },
|
||||
|()| hive_core_agent_sock::Response::Ok,
|
||||
)
|
||||
}
|
||||
hive_core_agent_sock::Request::SetStatus { text } => handle_set_status(coord, text),
|
||||
hive_core_agent_sock::Request::GetAgentMeta { name } => {
|
||||
handle_get_agent_meta(coord, agent, name.as_ref()).await
|
||||
}
|
||||
hive_core_agent_sock::Request::CancelLooseEnd { kind, id } => {
|
||||
crate::questions::handle_cancel_loose_end(coord, agent, *kind, *id).map_or_else(
|
||||
handle_cancel_loose_end(coord, agent, *kind, *id).map_or_else(
|
||||
|message| hive_core_agent_sock::Response::Err { message },
|
||||
|()| hive_core_agent_sock::Response::Ok,
|
||||
)
|
||||
|
|
@ -790,6 +765,97 @@ fn handle_get_loose_ends(coord: &Arc<Coordinator>, agent: &str, target: Option<&
|
|||
}
|
||||
}
|
||||
|
||||
/// Handle `CancelLooseEnd` from a per-agent socket. Dispatches by kind, each
|
||||
/// with its own auth check: reminder cancels are handled entirely in-container
|
||||
/// (this arm should be unreachable in practice, kept only so the match stays
|
||||
/// exhaustive) 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.)
|
||||
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::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). 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the target agent name for a *named* `GetLooseEnds` query. Rules:
|
||||
///
|
||||
/// - `None` (or `Some(caller)`) → the caller's own threads (always allowed).
|
||||
|
|
@ -1077,43 +1143,18 @@ async fn handle_get_logs(agent: &str, lines: Option<u32>) -> Response {
|
|||
}
|
||||
}
|
||||
|
||||
/// On `Ask { ttl_seconds: Some(n) }`, sleep n seconds and then try to
|
||||
/// resolve the question with `[expired]`. If the operator (or any
|
||||
/// other path) already answered it, `answer()` returns Err and we
|
||||
/// no-op silently. Otherwise fire a `QuestionAnswered` helper event
|
||||
/// with `answerer = "ttl-watchdog"` so the asker can distinguish a
|
||||
/// real answer from a deadline trip without parsing the answer text.
|
||||
const TTL_SENTINEL: &str = "[expired]";
|
||||
/// Synthetic `answerer` label used when the ttl watchdog resolves a
|
||||
/// question instead of a real human / agent. Lives in a distinct
|
||||
/// namespace from agent names + the operator so the asker can pattern
|
||||
/// match `event.answerer == "ttl-watchdog"`.
|
||||
const TTL_ANSWERER: &str = "ttl-watchdog";
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
pub fn spawn_question_watchdog(coord: &Arc<Coordinator>, id: i64, ttl_secs: u64) {
|
||||
let coord = coord.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(ttl_secs)).await;
|
||||
// Watchdog has its own answerer label so the authorisation
|
||||
// check in `answer()` permits it for any target. We bypass
|
||||
// the public `answer()` path by calling it with the operator
|
||||
// identity, since the operator is always permitted; the
|
||||
// event we fire carries the real watchdog label for observers.
|
||||
if let Ok((question, asker, _target)) =
|
||||
coord
|
||||
.questions
|
||||
.answer(id, TTL_SENTINEL, hive_sh4re::manager::OPERATOR_RECIPIENT)
|
||||
{
|
||||
tracing::info!(%id, %asker, "question expired (ttl)");
|
||||
coord.notify_agent(
|
||||
&asker,
|
||||
&hive_sh4re::manager::HelperEvent::QuestionAnswered {
|
||||
id,
|
||||
question,
|
||||
answer: TTL_SENTINEL.to_owned(),
|
||||
answerer: TTL_ANSWERER.to_owned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
//! Sqlite-backed host-side stores (broker, approval / question /
|
||||
//! schedule queues, build logs, audit trail, power intent) plus the
|
||||
//! shared connection open/migration helper (`db`). Each submodule is
|
||||
//! re-exported at the crate root, so `crate::broker::…` etc. keep
|
||||
//! working unchanged.
|
||||
//! Sqlite-backed host-side stores (broker, approval / schedule queues,
|
||||
//! build logs, audit trail, power intent) plus the shared connection
|
||||
//! open/migration helper (`db`). Each submodule is re-exported at the
|
||||
//! crate root, so `crate::broker::…` etc. keep working unchanged.
|
||||
|
||||
pub mod approvals;
|
||||
pub mod audit_log;
|
||||
pub mod broker;
|
||||
pub mod build_logs;
|
||||
pub mod db;
|
||||
pub mod operator_questions;
|
||||
pub mod power;
|
||||
pub mod scheduled_prompts;
|
||||
|
|
|
|||
|
|
@ -1,216 +0,0 @@
|
|||
//! Question queue. Agents submit via `Ask`; the answer comes from
|
||||
//! either the operator (for `target IS NULL`) or a peer agent (via
|
||||
//! `Answer`, for agent-to-agent questions). ⚠️ The dashboard no longer
|
||||
//! has any UI or endpoint for the operator to actually answer a
|
||||
//! `target IS NULL` row (removed along with the rest of the dashboard's
|
||||
//! question surface) — see `questions.rs`'s module doc for the current
|
||||
//! state of that gap.
|
||||
//!
|
||||
//! Despite the file name (kept for git history sanity), this table
|
||||
//! now stores *all* asynchronous questions in the hive — both the
|
||||
//! operator-targeted ones and the peer-to-peer ones. `target IS
|
||||
//! NULL` is the operator path (back-compat with rows written before
|
||||
//! the column existed); `target = '<agent-name>'` is the
|
||||
//! agent-to-agent path.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::db::Migration;
|
||||
|
||||
const SCHEMA: &str = r"
|
||||
CREATE TABLE IF NOT EXISTS operator_questions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
asker TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options_json TEXT NOT NULL,
|
||||
asked_at INTEGER NOT NULL,
|
||||
answered_at INTEGER,
|
||||
answer TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_operator_questions_pending
|
||||
ON operator_questions (id) WHERE answered_at IS NULL;
|
||||
";
|
||||
|
||||
/// Ordered schema migrations tracked in `schema_versions` (key
|
||||
/// `"operator_questions"`). Legacy databases are detected via the `target`
|
||||
/// column — the last column added before versioning — and fast-forwarded
|
||||
/// past all known migrations.
|
||||
const MIGRATIONS: &[Migration] = &[
|
||||
// v1: `multi` — checkbox-style multi-option questions.
|
||||
Migration {
|
||||
sql: "ALTER TABLE operator_questions ADD COLUMN \
|
||||
multi INTEGER NOT NULL DEFAULT 0",
|
||||
adds_column: Some(("operator_questions", "multi")),
|
||||
},
|
||||
// v2: `deadline_at` — optional TTL after which the watchdog auto-resolves.
|
||||
Migration {
|
||||
sql: "ALTER TABLE operator_questions ADD COLUMN deadline_at INTEGER",
|
||||
adds_column: Some(("operator_questions", "deadline_at")),
|
||||
},
|
||||
// v3: `target` — recipient of the question. NULL = operator (back-compat
|
||||
// default); non-null = peer-to-peer question.
|
||||
Migration {
|
||||
sql: "ALTER TABLE operator_questions ADD COLUMN target TEXT",
|
||||
adds_column: Some(("operator_questions", "target")),
|
||||
},
|
||||
];
|
||||
|
||||
pub struct OperatorQuestions {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl OperatorQuestions {
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let conn = crate::db::open(path, "operator_questions")?;
|
||||
conn.execute_batch(SCHEMA)
|
||||
.context("apply operator_questions schema")?;
|
||||
crate::db::apply_versioned_migrations(&conn, "operator_questions", MIGRATIONS)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn submit(
|
||||
&self,
|
||||
asker: &str,
|
||||
question: &str,
|
||||
options: &[String],
|
||||
multi: bool,
|
||||
deadline_at: Option<i64>,
|
||||
target: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into());
|
||||
conn.execute(
|
||||
"INSERT INTO operator_questions
|
||||
(asker, question, options_json, multi, deadline_at, target, asked_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
asker,
|
||||
question,
|
||||
options_json,
|
||||
i64::from(multi),
|
||||
deadline_at,
|
||||
target,
|
||||
Utc::now().timestamp(),
|
||||
],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Mark a pending question answered. `answerer` is who's actually
|
||||
/// answering: `"operator"`, or an agent's own name when responding
|
||||
/// via `Answer`. Authorisation:
|
||||
///
|
||||
/// - Operator-targeted questions (`target IS NULL`) can only be
|
||||
/// answered by `"operator"`. (Agents must not be able to spoof
|
||||
/// answers to operator questions — though as of the dashboard's
|
||||
/// ask/answer surface being removed, nothing currently calls
|
||||
/// this with `answerer = "operator"` for a `target IS NULL` row
|
||||
/// at all; the check stays as a guard, not a live path.)
|
||||
/// - Agent-targeted questions can only be answered by the
|
||||
/// declared target agent, OR by `"operator"` (operator override
|
||||
/// for stuck threads — useful when an agent is offline/down
|
||||
/// and someone has to close the loop).
|
||||
///
|
||||
/// Returns `(question, asker, target)` so the caller can fire the
|
||||
/// `QuestionAnswered` event with the right answerer label and route
|
||||
/// it back to the original asker.
|
||||
pub fn answer(
|
||||
&self,
|
||||
id: i64,
|
||||
answer: &str,
|
||||
answerer: &str,
|
||||
) -> Result<(String, String, Option<String>)> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let row: Option<(String, String, Option<String>, Option<i64>)> = conn
|
||||
.query_row(
|
||||
"SELECT question, asker, target, answered_at FROM operator_questions WHERE id = ?1",
|
||||
params![id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let Some((question, asker, target, answered_at)) = row else {
|
||||
bail!("question {id} not found");
|
||||
};
|
||||
if answered_at.is_some() {
|
||||
bail!("question {id} already answered");
|
||||
}
|
||||
// Authorisation check: must match the target, or be the operator
|
||||
// (operator-targeted questions are operator-only; the operator
|
||||
// can additionally override agent-to-agent questions to close
|
||||
// stuck threads).
|
||||
let authorised = match target.as_deref() {
|
||||
None => answerer == hive_sh4re::manager::OPERATOR_RECIPIENT,
|
||||
Some(t) => answerer == t || answerer == hive_sh4re::manager::OPERATOR_RECIPIENT,
|
||||
};
|
||||
if !authorised {
|
||||
bail!(
|
||||
"question {id} not addressed to '{answerer}' (target = {:?})",
|
||||
target
|
||||
.as_deref()
|
||||
.unwrap_or(hive_sh4re::manager::OPERATOR_RECIPIENT)
|
||||
);
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE operator_questions SET answer = ?1, answered_at = ?2 WHERE id = ?3",
|
||||
params![answer, Utc::now().timestamp(), id],
|
||||
)?;
|
||||
Ok((question, asker, target))
|
||||
}
|
||||
|
||||
/// Cancel a pending question on behalf of `canceller`. Returns
|
||||
/// `(question, asker, target)` so the caller can fire the usual
|
||||
/// `QuestionAnswered` event to the asker with a `[cancelled by
|
||||
/// <canceller>]` sentinel.
|
||||
///
|
||||
/// Auth: the canceller must be one of:
|
||||
/// - the original asker (an agent withdrawing their own ask),
|
||||
/// - the operator (already covered by the existing `answer` path
|
||||
/// but allowed here too for symmetry / dashboard cancel),
|
||||
/// - a `privileged` caller (one that arrived on the manager socket —
|
||||
/// privileged hive-wide cleanup; derived from the socket, not a
|
||||
/// name match).
|
||||
///
|
||||
/// Not the target — that's covered by `answer` (responding with
|
||||
/// an actual reply, sentinel or otherwise).
|
||||
pub fn cancel(
|
||||
&self,
|
||||
id: i64,
|
||||
canceller: &str,
|
||||
privileged: bool,
|
||||
) -> Result<(String, String, Option<String>)> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let row: Option<(String, String, Option<String>, Option<i64>)> = conn
|
||||
.query_row(
|
||||
"SELECT question, asker, target, answered_at FROM operator_questions WHERE id = ?1",
|
||||
params![id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let Some((question, asker, target, answered_at)) = row else {
|
||||
bail!("question {id} not found");
|
||||
};
|
||||
if answered_at.is_some() {
|
||||
bail!("question {id} already answered/cancelled");
|
||||
}
|
||||
let authorised = privileged
|
||||
|| canceller == asker
|
||||
|| canceller == hive_sh4re::manager::OPERATOR_RECIPIENT;
|
||||
if !authorised {
|
||||
bail!("question {id}: '{canceller}' not allowed to cancel (asker = '{asker}')");
|
||||
}
|
||||
let sentinel = format!("[cancelled by {canceller}]");
|
||||
conn.execute(
|
||||
"UPDATE operator_questions SET answer = ?1, answered_at = ?2 WHERE id = ?3",
|
||||
params![sentinel, Utc::now().timestamp(), id],
|
||||
)?;
|
||||
Ok((question, asker, target))
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,8 @@
|
|||
//! Stored as the `agent_power` table in the coordinator DB
|
||||
//! (`/var/lib/hyperhive/db/broker.sqlite`, one tiny row per agent) —
|
||||
//! same one-file-many-modules pattern as `approvals` /
|
||||
//! `operator_questions` / `scheduled_prompts`, each with its own
|
||||
//! connection. Intent persists across hive-c0re restarts; in-flight
|
||||
//! `scheduled_prompts`, each with its own connection. Intent persists
|
||||
//! across hive-c0re restarts; in-flight
|
||||
//! queue work deliberately does not. Setting `wanted` is never a
|
||||
//! queued node: operator/intent actions update the row synchronously
|
||||
//! at request time, then submit the DAG whose terminal `Reconcile`
|
||||
|
|
|
|||
Loading…
Reference in a new issue