//! The per-agent icon KV bucket: one key per agent, holding the icon the //! swarm shows for it. //! //! **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 //! 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. //! Operator ruling: *"the bucket is per agent, not per hive. agents can //! move hives."* //! //! That is also what makes the icon answerable for an agent whose //! container is stopped: the value's lifetime is the agent's, not its //! placement's. //! //! **Each agent writes its own key** with its own queue credential. The auth //! callout grants that credential `$KV.agent-icons.` and no other key. //! The hive's shared agent client gets nothing in this bucket: every agent on //! a hive presents that same client, so its grant cannot be narrowed to one //! agent's key. //! //! The writer publishes to [`crate::agent_icon::subject`] directly instead of //! opening the bucket, so that one subject is its whole grant. Creating the //! bucket is left to the reader, with `open_or_create`. A write that arrives //! before the bucket exists is refused with "no stream", and the writer //! retries. #[cfg(feature = "kv")] 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 /// same bucket, and an option is a way for the two to disagree. pub const BUCKET: &str = "agent-icons"; /// The media type of every value in this bucket. /// /// The value is the icon's bytes **verbatim, not a JSON envelope**: an SVG /// carries no metadata this bucket would have to describe, and the one /// consumer serves the bytes straight back out. So the type is fixed here /// rather than stored per entry — a publisher that has something other /// than an SVG does not have an agent icon. pub const MEDIA_TYPE: &str = "image/svg+xml"; /// The subject `agent`'s entry is written on: `$KV..`, where the /// key is the agent name and nothing else. /// /// A KV put is a `JetStream` publish to this subject, so a writer that /// publishes directly has to use the same subject the bucket's `get` reads. #[must_use] pub fn subject(agent: &str) -> String { format!("$KV.{BUCKET}.{agent}") } /// Open the agent-icon bucket, creating it if nothing has yet. /// /// `history: 1`, same rationale as [`crate::agent_status::open_or_create`]: /// a consumer wants each agent's current icon, not every icon it has ever /// had. #[cfg(feature = "kv")] pub async fn open_or_create( client: &async_nats::Client, ) -> Result { let js = async_nats::jetstream::new(client.clone()); match js.get_key_value(BUCKET).await { Ok(store) => Ok(store), Err(e) => { tracing::info!( bucket = BUCKET, reason = %e, "agent-icon bucket not available, creating it" ); js.create_key_value(async_nats::jetstream::kv::Config { bucket: BUCKET.to_owned(), description: "Current icon published by each agent".to_owned(), history: 1, ..Default::default() }) .await .map_err(|source| Error::CreateBucket { bucket: BUCKET.to_owned(), source, }) } } } #[cfg(test)] mod tests { use super::subject; /// One token after the bucket, the agent name alone. That is what the /// per-agent grant `$KV.agent-icons.{agent}` expands to, and what a /// hive-scoped grant (`$KV...*`, two tokens) cannot match. #[test] fn the_published_subject_carries_the_agent_and_no_placement() { assert_eq!(subject("iris"), "$KV.agent-icons.iris"); } }