Watch
0
0
Fork
You've already forked hyperhive
0

swarm-wire: take agent_status's wire types

This commit is contained in:
atlas 2026-10-18 12:17:46 +02:00 • committed by mara
commit 9222a051f5
10 changed files with 180 additions and 181 deletions

View file

@ -3,7 +3,7 @@
//!
//! **Keyed by the agent alone — no hive token**, which is the one way this
//! bucket deliberately differs from its otherwise-identical sibling
//! [`crate::agent_status`] (`{hive}.{agent}`). An agent is not fixed to a
//! `swarm_wire::agent_status` (`{hive}.{agent}`). An agent is not fixed to a
//! hive and can move between them; an icon keyed by where the agent
//! currently runs would be stranded under the old key by a migration, and
//! a reader would have to know the placement to ask the question at all.
@ -29,7 +29,7 @@ use crate::Error;
/// The KV bucket agent icons are published into, keyed by agent name.
///
/// A constant and not an option, for the reason
/// [`crate::agent_status::BUCKET`] gives: writer and reader must name the
/// [`swarm_wire::agent_status::BUCKET`] gives: writer and reader must name the
/// same bucket, and an option is a way for the two to disagree.
pub const BUCKET: &str = "agent-icons";

View file

@ -1,103 +1,6 @@
//! The per-agent status KV bucket: one key per `(hive, agent)` pair.
//!
//! Deliberately **not** a field inside [`crate::status::BUCKET`]'s hive
//! document, even though a hive already publishes its own status there.
//! Three reasons, none of which show up until the second write:
//!
//! - **Cardinality and write rate.** Hive status is one value per hive.
//! Agent status is N values per hive, changing independently and far
//! more often — folded into the hive document, every agent's change
//! rewrites the whole thing, and two agents changing at once race on
//! the same key.
//! - **A KV key is the unit of update and of watch.** A consumer that
//! cares about one agent wants a per-key read with its own revision,
//! not a hive-wide document to diff.
//! - **Staleness means different things.** A hive can be healthy while
//! one agent is wedged, and the reverse; one timestamp cannot answer
//! both questions honestly.
//!
//! The transport *is* shared — this rides the same [`async_nats::Client`]
//! and the same auth callout as [`crate::status`] and [`crate::wanted`],
//! just a different bucket.
#[cfg(feature = "kv")]
use crate::Error;
/// The KV bucket per-agent status snapshots are published into, keyed by
/// [`key`].
///
/// A constant and not an option, for the reason [`crate::status::BUCKET`]
/// gives: writer (a hive) and reader (the controller) must name the same
/// bucket, and an option is a way for the two to disagree about which one
/// that is.
pub const BUCKET: &str = "agent-status";
/// The bucket key for one agent on one hive.
///
/// `.`-joined, and the separator is load-bearing rather than cosmetic: a KV
/// entry is published to `$KV.<bucket>.<key>`, and NATS wildcards match
/// whole `.`-delimited tokens. Two tokens make a hive's write grant
/// expressible as `$KV.agent-status.<hive>.*` — every agent of one hive and
/// nothing else. Joined by any character that is not `.`, the key is a
/// single token, and the only grants available are one exact subject per
/// agent or a bucket-wide wildcard that lets any hive overwrite any other
/// hive's agents.
///
/// Neither name can be empty or contain a `.` (`hive_types::Ident` is
/// `[a-z0-9-]`, non-empty), so the join stays unambiguous to split back
/// apart — and a leading or trailing `.` would be rejected outright by the
/// client's own KV key validation.
///
/// Plain code span above, deliberately not an intra-doc link — this crate
/// doesn't depend on `hive_types`, and a bracketed reference to its `Ident`
/// type fails `cargo doc` with "no item named `hive_types` in scope" under
/// this workspace's `docs-rustdoc` CI job, which treats broken doc links as
/// errors.
#[must_use]
pub fn key(hive: &str, agent: &str) -> String {
format!("{hive}.{agent}")
}
/// The inverse of [`key`]: split a bucket key back into `(hive, agent)`.
///
/// `None` for a key with zero or more than one `.` — a hive publishing
/// under [`key`] never produces one, so a malformed key means something
/// else wrote this bucket. Splitting on the *first* `.` would be equally
/// valid today (neither name can contain one), but this rejects rather
/// than guesses, so a future name-charset change can't silently start
/// misreading old keys.
#[must_use]
pub fn split_key(key: &str) -> Option<(&str, &str)> {
let mut parts = key.splitn(3, '.');
let hive = parts.next()?;
let agent = parts.next()?;
if parts.next().is_some() {
return None;
}
Some((hive, agent))
}
/// One agent's status snapshot, as published under [`key`].
///
/// Mirrors the tuple `container_view::read_agent_status_live` returns on
/// the publishing side — this is that value, not a recomputation of it.
/// Nothing here stamps a time: same rule as [`crate::status`], freshness is
/// derived by the reader from when the value landed in the bucket, so a
/// hive with a wrong clock only skews its own payload.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AgentStatus {
/// The agent's free-text status (`set_status`), or `None` if it has
/// never set one, or if the container isn't running.
pub status_text: Option<String>,
/// Unix timestamp the status was last set. `None` alongside
/// `status_text: None` — the two are never independently absent.
pub status_set_at: Option<i64>,
/// Whether the container is currently running. `false` forces the two
/// fields above to `None` even if an on-disk status file is stale from
/// before the container stopped — the same "stopped containers have
/// stale state" rule `read_agent_status_live` already enforces.
pub running: bool,
}
//! The connection side of the per-agent status KV bucket: opening it for
//! writing. The bucket's name, keys and stored type are
//! `swarm_wire::agent_status`.
/// Open the agent-status bucket, creating it if nothing has yet.
///
@ -107,74 +10,13 @@ pub struct AgentStatus {
#[cfg(feature = "kv")]
pub async fn open_or_create(
client: &async_nats::Client,
) -> Result<async_nats::jetstream::kv::Store, Error> {
) -> Result<async_nats::jetstream::kv::Store, crate::Error> {
let js = async_nats::jetstream::new(client.clone());
crate::kv::open_or_create(
&js,
BUCKET,
swarm_wire::agent_status::BUCKET,
"agent-status",
"Last status snapshot offered by each agent".to_owned(),
)
.await
}
#[cfg(test)]
mod tests {
use super::{AgentStatus, BUCKET, key, split_key};
#[test]
fn key_joins_hive_and_agent_with_a_dot() {
assert_eq!(key("prod", "iris"), "prod.iris");
}
#[test]
fn the_published_subject_carries_the_hive_as_its_own_token() {
// This is what the hive's write grant `$KV.agent-status.<hive>.*`
// matches on, so the separator is pinned here rather than left as a
// property of `key`'s formatting string.
assert_eq!(
format!("$KV.{BUCKET}.{}", key("prod", "iris")),
"$KV.agent-status.prod.iris"
);
// Control: a single-token join lands in the same bucket and would
// pass any assertion that only checked the prefix — it is the hive
// token that the grant needs, and only the `.` produces one.
assert!(!format!("$KV.{BUCKET}.prod/iris").starts_with("$KV.agent-status.prod."));
}
#[test]
fn split_key_is_the_inverse_of_key() {
assert_eq!(split_key(&key("prod", "iris")), Some(("prod", "iris")));
}
#[test]
fn split_key_rejects_a_key_with_no_dot() {
assert_eq!(split_key("iris"), None);
}
#[test]
fn split_key_rejects_a_key_with_two_dots() {
assert_eq!(split_key("prod.iris.extra"), None);
}
#[test]
fn agent_status_round_trips() {
let status = AgentStatus {
status_text: Some("reviewing a pull request".to_owned()),
status_set_at: Some(1_725_000_000),
running: true,
};
let json = serde_json::to_string(&status).expect("serialises");
let decoded: AgentStatus = serde_json::from_str(&json).expect("decodes");
assert_eq!(decoded, status);
}
#[test]
fn a_stopped_agent_serialises_with_no_status() {
let status = AgentStatus::default();
assert_eq!(
serde_json::to_string(&status).expect("serialises"),
r#"{"status_text":null,"status_set_at":null,"running":false}"#
);
}
}

View file

@ -195,9 +195,10 @@ pub mod status;
/// "absence is not a deletion order" rule rather than inheriting either.
pub mod wanted;
/// The bucket per-agent status snapshots are published into — one key per
/// `(hive, agent)`, deliberately a sibling of [`status`] rather than a field
/// inside it. See the module doc for why the two must not merge.
/// Opening the bucket per-agent status snapshots are published into: one key
/// per `(hive, agent)`, deliberately a sibling of [`status`] rather than a
/// field inside it. The name, keys and stored type, and why the two must not
/// merge, are in `swarm_wire::agent_status`.
pub mod agent_status;
/// The bucket agent icons are published into — one key per agent, with no