swarm-wire: take wanted's wire types
This commit is contained in:
parent
c6b1364684
commit
266be0a5db
17 changed files with 298 additions and 282 deletions
|
|
@ -12,3 +12,4 @@ pub mod agent_status;
|
|||
pub mod builder;
|
||||
pub mod claude_login;
|
||||
pub mod review_classifier;
|
||||
pub mod wanted;
|
||||
|
|
|
|||
221
swarm-wire/src/wanted.rs
Normal file
221
swarm-wire/src/wanted.rs
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
//! The hive-wanted KV buckets: the agent set the controller declares for each
|
||||
//! hive, **one bucket per hive** (see [`bucket`] for why the
|
||||
//! split is a grant boundary rather than a data-modelling choice), keyed inside
|
||||
//! it by `hiveName`.
|
||||
//!
|
||||
//! Sibling of `swarm_queue_client::status` and deliberately **not** a mirror of it: that
|
||||
//! one is **observed** — each hive republishes what it is, so its store losing
|
||||
//! everything "degrades to honesty" (`swarm-nats.nix`). This one is
|
||||
//! **declared**, and nothing regenerates it; lose it and a reconcile loop has
|
||||
//! nothing to converge to, which is the failure such a loop exists to remove.
|
||||
//!
|
||||
//! # Absence is not a deletion order
|
||||
//!
|
||||
//! A hive that finds no key for itself — or no bucket at all — has learned
|
||||
//! nothing about the agents it is running, not that it should have none.
|
||||
//! Swarm-side lifecycle does not yet cover agents that predate it, so callers
|
||||
//! converge the agents a value NAMES and leave the rest alone.
|
||||
//!
|
||||
//! # Only the controller creates it
|
||||
//!
|
||||
//! Unlike `swarm_queue_client::status::open_or_create`, where either end may
|
||||
//! legitimately arrive first, the writer here is single and known. A hive
|
||||
//! opens read-only and treats absence as the case above, so "not published
|
||||
//! yet" stays quiet rather than looking like an error a hive could fix.
|
||||
|
||||
/// The prefix every hive's wanted-state bucket name starts with.
|
||||
///
|
||||
/// Derived from, and not configurable, for the reason
|
||||
/// `swarm_queue_client::status::BUCKET` gives: writer and reader must name the
|
||||
/// same bucket, and an option is a way for two deployments to disagree about
|
||||
/// which one that is.
|
||||
/// for two deployments to disagree about which one that is.
|
||||
pub const BUCKET_PREFIX: &str = "hive-wanted-";
|
||||
|
||||
/// The KV bucket one hive's declaration lives in — **one bucket per hive**, not
|
||||
/// one bucket keyed by hive.
|
||||
///
|
||||
/// The split is a grant-scoping decision, not a data-modelling one. A KV read
|
||||
/// scopes per key, because `DIRECT.GET` carries the key in the subject; a
|
||||
/// *watch* does not, because a consumer's filter travels in the request payload
|
||||
/// and `$JS.API.CONSUMER.CREATE.<stream>` therefore grants the whole stream. So
|
||||
/// with every hive in one bucket, letting a hive watch its own declaration
|
||||
/// means letting it read every other hive's. One stream per hive makes the
|
||||
/// grant a hive can hold exactly as wide as what it is allowed to see.
|
||||
///
|
||||
/// A bucket name may contain `[a-zA-Z0-9_-]`, and a hive name is an `Ident`
|
||||
/// (`[a-z0-9-]`), so this composition is always a legal bucket name.
|
||||
#[must_use]
|
||||
pub fn bucket(hive: &str) -> String {
|
||||
format!("{BUCKET_PREFIX}{hive}")
|
||||
}
|
||||
|
||||
/// One hive's whole declaration: the agents the controller names, and what it
|
||||
/// wants of each. The value under `BUCKET`/`<hiveName>`.
|
||||
///
|
||||
/// The agents it does **not** name are not part of the declaration at all —
|
||||
/// see the module docs. A hive converges the ones listed here and leaves the
|
||||
/// rest alone.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct HiveWanted {
|
||||
/// Agent name → what the controller wants of it.
|
||||
#[serde(default)]
|
||||
pub agents: std::collections::BTreeMap<String, AgentWanted>,
|
||||
}
|
||||
|
||||
/// One agent's entry in a [`HiveWanted`].
|
||||
///
|
||||
/// A struct around one field rather than the bare state, because this is where
|
||||
/// a later declaration grows — a config revision, a schedule — and a hive
|
||||
/// built before that field existed keeps deserialising the whole document.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct AgentWanted {
|
||||
/// The state to converge this agent to.
|
||||
pub state: AgentState,
|
||||
}
|
||||
|
||||
/// What the controller wants an agent to be.
|
||||
///
|
||||
/// **Closed on purpose.** A value this build does not know fails the whole
|
||||
/// document's decode, so a hive running older code converges **nothing** rather
|
||||
/// than part of a declaration it only half understands. Adding a state means
|
||||
/// adding a variant here and shipping it to both ends — which is the intended
|
||||
/// workflow, not an obstacle to route around with a catch-all variant.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentState {
|
||||
/// Exists on the hive and is running.
|
||||
Up,
|
||||
/// Exists on the hive and is not running.
|
||||
Offline,
|
||||
/// Exists on the hive, is running, and its turn loop is parked — the
|
||||
/// swarm-declared counterpart of the hive-local `hivectl agent pause`
|
||||
/// marker. Orthogonal to the power axis `Up`/`Offline` express: a
|
||||
/// paused agent still has a running container (the web UI and MCP
|
||||
/// daemons stay reachable), it just drives no turns. See
|
||||
/// `hive-c0re::workers::wanted::decide_pause` for the convergence side
|
||||
/// of that split.
|
||||
Paused,
|
||||
/// Torn down entirely — the hive runs the destroy template once, then
|
||||
/// treats absence as agreement rather than re-running it. The key
|
||||
/// stays in the declared set with this state forever, on purpose: it's
|
||||
/// what lets a redeclare of an already-destroyed agent be rejected as
|
||||
/// a terminal-state transition instead of silently reviving it.
|
||||
/// Irreversible: there is no state that brings a destroyed agent back
|
||||
/// short of a fresh deploy.
|
||||
Destroyed,
|
||||
}
|
||||
|
||||
impl From<AgentState> for &'static str {
|
||||
/// The state's wire spelling, the same string serde writes.
|
||||
fn from(state: AgentState) -> Self {
|
||||
match state {
|
||||
AgentState::Up => "up",
|
||||
AgentState::Offline => "offline",
|
||||
AgentState::Paused => "paused",
|
||||
AgentState::Destroyed => "destroyed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AgentState, BUCKET_PREFIX, HiveWanted, bucket};
|
||||
|
||||
#[test]
|
||||
fn each_hive_gets_its_own_bucket_name() {
|
||||
assert_eq!(bucket("alpha"), "hive-wanted-alpha");
|
||||
assert_ne!(bucket("alpha"), bucket("beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bucket_name_is_legal_for_every_legal_hive_name() {
|
||||
// The client rejects a bucket name outside `[a-zA-Z0-9_-]`, and a hive
|
||||
// name is an `Ident` — lowercase, digits, hyphen. Pinned here because
|
||||
// the grant scoping in `swarm-nats-auth` names this string, so a bucket
|
||||
// the client refuses to open would surface as a permissions problem
|
||||
// rather than as the naming problem it is.
|
||||
let legal = |s: &str| {
|
||||
s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
};
|
||||
assert!(legal(&bucket("alpha")));
|
||||
assert!(legal(&bucket("a-hive-with-hyphens")));
|
||||
assert!(legal(&bucket("h9")));
|
||||
// Control: the predicate can fail, so the assertions above are not
|
||||
// vacuously true of any string.
|
||||
assert!(!legal("hive.wanted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prefix_is_what_every_bucket_starts_with() {
|
||||
assert!(bucket("alpha").starts_with(BUCKET_PREFIX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_known_states_decode_and_round_trip() {
|
||||
let doc = r#"{"agents":{"a":{"state":"up"},"b":{"state":"offline"},"c":{"state":"destroyed"},"d":{"state":"paused"}}}"#;
|
||||
let decoded: HiveWanted = serde_json::from_str(doc).expect("decodes");
|
||||
assert_eq!(decoded.agents["a"].state, AgentState::Up);
|
||||
assert_eq!(decoded.agents["b"].state, AgentState::Offline);
|
||||
assert_eq!(decoded.agents["c"].state, AgentState::Destroyed);
|
||||
assert_eq!(decoded.agents["d"].state, AgentState::Paused);
|
||||
assert_eq!(serde_json::to_string(&decoded).expect("serialises"), doc);
|
||||
}
|
||||
|
||||
/// The enum is closed, so a state this build does not know takes the
|
||||
/// **whole document** down rather than one agent. That is the point: a hive
|
||||
/// converges nothing rather than part of a declaration it half understands.
|
||||
/// The `up` beside it is the control — a valid entry in the same document
|
||||
/// does not rescue it, and this test would pass on a broken decoder without
|
||||
/// the round-trip test above proving the happy path still works.
|
||||
#[test]
|
||||
fn an_unknown_state_fails_the_whole_declaration() {
|
||||
// `sleeping` is not a variant, so the document is refused.
|
||||
let doc = r#"{"agents":{"a":{"state":"up"},"c":{"state":"sleeping"}}}"#;
|
||||
assert!(serde_json::from_str::<HiveWanted>(doc).is_err());
|
||||
}
|
||||
|
||||
/// Closed *values*, tolerant *fields*: a field a newer controller adds must
|
||||
/// not cost an older hive the document, since adding one is not a semantic
|
||||
/// a hive has to understand to obey the rest.
|
||||
#[test]
|
||||
fn unknown_fields_are_ignored() {
|
||||
let doc = r#"{"agents":{"a":{"state":"up","config_rev":"deadbeef"}},"epoch":3}"#;
|
||||
let decoded: HiveWanted = serde_json::from_str(doc).expect("decodes");
|
||||
assert_eq!(decoded.agents["a"].state, AgentState::Up);
|
||||
}
|
||||
|
||||
/// A declaration missing the one required field is an error, so a malformed
|
||||
/// document cannot read as "no agents".
|
||||
#[test]
|
||||
fn a_missing_state_is_an_error() {
|
||||
assert!(serde_json::from_str::<HiveWanted>(r#"{"agents":{"a":{}}}"#).is_err());
|
||||
}
|
||||
|
||||
/// The `&str` conversion and the `rename_all` are two spellings of one fact,
|
||||
/// and a renderer that disagrees with the wire is worse than one that does
|
||||
/// not exist. The `match` is what makes this exhaustive: a new variant fails
|
||||
/// to compile here rather than quietly going untested.
|
||||
#[test]
|
||||
fn the_str_conversion_matches_the_serde_spelling() {
|
||||
let every = [
|
||||
AgentState::Up,
|
||||
AgentState::Offline,
|
||||
AgentState::Paused,
|
||||
AgentState::Destroyed,
|
||||
];
|
||||
for state in every {
|
||||
match state {
|
||||
AgentState::Up
|
||||
| AgentState::Offline
|
||||
| AgentState::Paused
|
||||
| AgentState::Destroyed => {}
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_string(&state).expect("serialises"),
|
||||
format!("\"{}\"", <&str>::from(state))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue