hive-bash-mcp: publish each task's status and output to the swarm queue
This commit is contained in:
parent
faadc5abe2
commit
c0b9c1778c
2 changed files with 841 additions and 211 deletions
|
|
@ -30,7 +30,7 @@ use swarm_queue_client::agent_inference::{self, read_own};
|
|||
use swarm_queue_client::agent_permissions::{self, BEFORE_FIRST_READ, held};
|
||||
use swarm_wire::agent_inference::AgentInference;
|
||||
|
||||
use crate::swarm_term::{Target, connect};
|
||||
use swarm_queue_client::agent_publisher::{Target, connect};
|
||||
|
||||
/// The last permission set [`Reader::refresh_permissions`] read; `None` until
|
||||
/// one succeeds.
|
||||
|
|
|
|||
|
|
@ -32,38 +32,18 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context as _, anyhow};
|
||||
use hive_sh4re::term_msg::{ClassifyCtx, Level, TermMsg, fit, iso8601_utc};
|
||||
use swarm_secret_client::{
|
||||
SecretStore,
|
||||
client::{DEFAULT_CERT_MOUNT, ENV_ADDR, ENV_CACERT, Settings},
|
||||
policy, queue,
|
||||
};
|
||||
use swarm_queue_client::agent_publisher::{Queue, Target};
|
||||
use swarm_wire::subagent_status::{self, State as RunState, Status};
|
||||
use swarm_wire::subagent_term;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Names the agent. The store role, the store path, the queue token and the
|
||||
/// subjects are all built from it.
|
||||
pub(crate) const ENV_AGENT_NAME: &str = "HIVE_AGENT_NAME";
|
||||
|
||||
/// Where the swarm queue listens, as this container reaches it.
|
||||
const ENV_NATS_URL: &str = "HIVE_AGENT_NATS_URL";
|
||||
|
||||
/// Lines waiting for the publish task. Past this, `offer` drops the line
|
||||
/// rather than block a subagent's output reader.
|
||||
const QUEUE_DEPTH: usize = 4096;
|
||||
|
||||
/// How long one read of the store may take; it runs inside a queue
|
||||
/// connection attempt.
|
||||
const STORE_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How long after a failed connect the task tries again. Rows that arrive
|
||||
/// before a first connect succeeds are dropped.
|
||||
const RETRY_AFTER: Duration = Duration::from_mins(1);
|
||||
|
||||
/// Room left under the server's payload limit for subject, headers and
|
||||
/// framing, as in `hive-agent`'s `swarm_term`.
|
||||
const HEADROOM: usize = 1024;
|
||||
|
|
@ -245,136 +225,6 @@ fn unix_now() -> i64 {
|
|||
.map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
|
||||
}
|
||||
|
||||
/// Where to publish, and what to connect with.
|
||||
pub(crate) struct Target {
|
||||
pub(crate) url: String,
|
||||
pub(crate) agent: String,
|
||||
store: StoreSource,
|
||||
}
|
||||
|
||||
/// This agent's queue secret as the store holds it.
|
||||
struct StoreSource {
|
||||
settings: Settings,
|
||||
role: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
/// `None` when the queue address or the store address is absent, which
|
||||
/// is a container without a swarm queue or without a store.
|
||||
///
|
||||
/// # Errors
|
||||
/// A store address without the agent name or the identity beside it.
|
||||
pub(crate) fn from_lookup(
|
||||
get: impl Fn(&str) -> Option<String>,
|
||||
) -> anyhow::Result<Option<Self>> {
|
||||
let Some(url) = get(ENV_NATS_URL).filter(|v| !v.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if get(ENV_ADDR).is_none_or(|v| v.is_empty()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let agent = get(ENV_AGENT_NAME)
|
||||
.filter(|v| !v.is_empty())
|
||||
.with_context(|| format!("{ENV_AGENT_NAME} is unset or empty"))?;
|
||||
let settings =
|
||||
Settings::from_lookup(|k| get(k).filter(|v| k != ENV_CACERT || ca_is_usable(v)))
|
||||
.context("reading the swarm secret store's coordinates from the environment")?;
|
||||
let store = StoreSource {
|
||||
settings,
|
||||
role: policy::agent_object_name(&agent)?,
|
||||
path: queue::agent_queue_path(&agent)?,
|
||||
};
|
||||
Ok(Some(Self { url, agent, store }))
|
||||
}
|
||||
|
||||
fn subject(&self, subagent: &str) -> String {
|
||||
subagent_term::subject(&self.agent, subagent)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the CA bundle at `path` is a file with bytes in it. Absent means
|
||||
/// the container's own trust store.
|
||||
fn ca_is_usable(path: &str) -> bool {
|
||||
std::fs::metadata(path).is_ok_and(|m| m.len() > 0)
|
||||
}
|
||||
|
||||
impl StoreSource {
|
||||
/// The stored secret, read with a fresh login. Errors name the path and
|
||||
/// never the value.
|
||||
async fn read(&self) -> anyhow::Result<String> {
|
||||
let fetch = async {
|
||||
let store = SecretStore::connect(&self.settings, &self.role, DEFAULT_CERT_MOUNT)
|
||||
.await
|
||||
.context("logging in to the swarm secret store as this agent")?;
|
||||
let credential: Option<queue::AgentCredential> = store
|
||||
.read_optional(&self.path)
|
||||
.await
|
||||
.with_context(|| format!("reading {} from the store", self.path))?;
|
||||
credential
|
||||
.map(|c| c.value.trim().to_owned())
|
||||
.filter(|v| !v.is_empty())
|
||||
.with_context(|| format!("nothing is stored at {}", self.path))
|
||||
};
|
||||
tokio::time::timeout(STORE_READ_TIMEOUT, fetch)
|
||||
.await
|
||||
.map_err(|_| anyhow!("the store did not answer within {STORE_READ_TIMEOUT:?}"))?
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect presenting the agent's own token, read from the store on every
|
||||
/// connection attempt so a re-minted secret is picked up on reconnect.
|
||||
pub(crate) async fn connect(target: &Arc<Target>) -> anyhow::Result<async_nats::Client> {
|
||||
let url = target.url.clone();
|
||||
let target = Arc::clone(target);
|
||||
let agent = target.agent.clone();
|
||||
swarm_queue_client::connect_with_token(&url, None, &agent, move || {
|
||||
let target = Arc::clone(&target);
|
||||
// Spawned so the future handed back is only a `JoinHandle`, which is
|
||||
// `Sync` as async-nats's auth callback requires; the store read is not.
|
||||
let attempt = tokio::spawn(async move {
|
||||
let value = target.store.read().await.map_err(|e| format!("{e:#}"))?;
|
||||
swarm_agent_verify::agent_token::format_agent_token(&target.agent, &value)
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
async move { attempt.await.map_err(|e| e.to_string())? }
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!(swarm_queue_client::chain(&e)))
|
||||
}
|
||||
|
||||
/// The connection, retried at most once per [`RETRY_AFTER`].
|
||||
#[derive(Default)]
|
||||
struct Queue {
|
||||
client: Option<async_nats::Client>,
|
||||
connect_after: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
/// A client to publish on, connecting first when a connect is due.
|
||||
async fn ready(&mut self, target: &Arc<Target>) -> Option<async_nats::Client> {
|
||||
let now = Instant::now();
|
||||
if self.client.is_none() && self.connect_after.is_none_or(|t| now >= t) {
|
||||
match connect(target).await {
|
||||
Ok(client) => {
|
||||
tracing::info!(url = %target.url, "subagent terminal: connected as this agent");
|
||||
self.client = Some(client);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = format!("{e:#}"),
|
||||
retry_in = ?RETRY_AFTER,
|
||||
"subagent terminal: connecting with this agent's own queue credential \
|
||||
failed; rows are dropped until a connect succeeds"
|
||||
);
|
||||
self.connect_after = Some(now + RETRY_AFTER);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.client.clone()
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
mut rx: mpsc::Receiver<Line>,
|
||||
mut status_rx: mpsc::UnboundedReceiver<StatusEvent>,
|
||||
|
|
@ -383,7 +233,7 @@ async fn run(
|
|||
) {
|
||||
let target = Arc::new(target);
|
||||
let mut ctxs: HashMap<String, ClassifyCtx> = HashMap::new();
|
||||
let mut queue = Queue::default();
|
||||
let mut queue = Queue::new("subagent terminal");
|
||||
let mut runs: HashMap<String, Status> = HashMap::new();
|
||||
let period = Duration::from_secs(subagent_status::HEARTBEAT_S.unsigned_abs());
|
||||
let mut beat = tokio::time::interval_at(tokio::time::Instant::now() + period, period);
|
||||
|
|
@ -425,7 +275,7 @@ async fn publish_line(
|
|||
let Some(client) = queue.ready(target).await else {
|
||||
return;
|
||||
};
|
||||
let subject = target.subject(&line.subagent);
|
||||
let subject = subagent_term::subject(&target.agent, &line.subagent);
|
||||
let headers = line.info.headers();
|
||||
for row in rows {
|
||||
publish(&client, &subject, &headers, row).await;
|
||||
|
|
@ -552,64 +402,18 @@ async fn publish(
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||
let pairs: Vec<(String, String)> = pairs
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
|
||||
.collect();
|
||||
move |k| pairs.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
const STORE: [(&str, &str); 3] = [
|
||||
("BAO_ADDR", "https://bao.t.local:8200"),
|
||||
("BAO_CLIENT_CERT", "/run/credentials/c"),
|
||||
("BAO_CLIENT_KEY", "/run/credentials/k"),
|
||||
];
|
||||
|
||||
fn full() -> Vec<(&'static str, &'static str)> {
|
||||
let mut pairs = STORE.to_vec();
|
||||
pairs.push((ENV_NATS_URL, "tls://nats.t.local:4222"));
|
||||
pairs.push((ENV_AGENT_NAME, "iris"));
|
||||
pairs
|
||||
}
|
||||
|
||||
/// Each subagent publishes on its own subject under this agent's family,
|
||||
/// the one the queue grants the agent.
|
||||
#[test]
|
||||
fn a_subagent_publishes_under_the_agents_own_family() {
|
||||
let target = Target::from_lookup(env(&full()))
|
||||
.expect("complete")
|
||||
.expect("configured");
|
||||
assert_eq!(target.subject("scout"), "$SWARM.term.iris.sub.scout");
|
||||
assert_eq!(target.subject("w-2"), "$SWARM.term.iris.sub.w-2");
|
||||
assert_eq!(target.store.path, "swarm/agents/iris/queue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_queue_address_or_a_store_nothing_is_published() {
|
||||
let no_url: Vec<_> = full()
|
||||
.into_iter()
|
||||
.filter(|(k, _)| *k != ENV_NATS_URL)
|
||||
.collect();
|
||||
assert!(Target::from_lookup(env(&no_url)).expect("legal").is_none());
|
||||
let no_store: Vec<_> = full()
|
||||
.into_iter()
|
||||
.filter(|(k, _)| *k != "BAO_ADDR")
|
||||
.collect();
|
||||
assert!(
|
||||
Target::from_lookup(env(&no_store))
|
||||
.expect("legal")
|
||||
.is_none()
|
||||
assert_eq!(
|
||||
subagent_term::subject("iris", "scout"),
|
||||
"$SWARM.term.iris.sub.scout"
|
||||
);
|
||||
assert_eq!(
|
||||
subagent_term::subject("iris", "w-2"),
|
||||
"$SWARM.term.iris.sub.w-2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_store_without_the_agent_name_is_an_error() {
|
||||
let no_name: Vec<_> = full()
|
||||
.into_iter()
|
||||
.filter(|(k, _)| *k != ENV_AGENT_NAME)
|
||||
.collect();
|
||||
assert!(Target::from_lookup(env(&no_name)).is_err());
|
||||
}
|
||||
|
||||
/// The sink must never wait on the publisher: a full queue drops the line
|
||||
|
|
|
|||
Loading…
Reference in a new issue