diff --git a/Cargo.lock b/Cargo.lock index 70987333..f14c6419 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1767,6 +1767,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "strum", "swarm-queue-client", "swarm-secret-client", "tempfile", @@ -1814,6 +1815,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "strum", "time", "url", ] @@ -1965,6 +1967,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "strum", ] [[package]] @@ -4662,6 +4665,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4721,6 +4745,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "strum", "swarm-authelia-bridge-sock", "swarm-queue-client", "swarm-secret-client", @@ -4761,6 +4786,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "strum", "thiserror 2.0.18", "tokio", "tracing", @@ -4775,6 +4801,7 @@ dependencies = [ "rustify_derive", "serde", "serde_json", + "strum", "thiserror 2.0.18", "vaultrs", ] diff --git a/Cargo.toml b/Cargo.toml index 82bff01f..a4d1206f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,6 +113,10 @@ schemars = "1.0" serde = { version = "1", features = ["derive"] } serde_json = "1" similar = "2" +# `derive`-only: `Kind`'s segment strings via `#[strum(serialize = "...")]` +# instead of a hand-written match, so the derive macro is the single source +# of truth (see `swarm-secret-client::path::Kind`). +strum = { version = "0.28.0", features = ["derive"] } tokio = { version = "1", features = [ "fs", "io-util", diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index f0a1743e..922cfcdf 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -848,7 +848,7 @@ progresses (a rebuild reads `stop_for_update`, then `swap`, then life). Two consequences for anything rendering it: - The label vocabulary is **open** — it's the node's own wire tag - (`NodeKind::as_str`, the same strings the graph wire's node + (`NodeKind`'s `&str` conversion, the same strings the graph wire's node labels carry), not a fixed set. Treat it as an opaque display string; don't switch on specific values. `restarting` in particular no longer exists, because no node kind is unique to a diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 895dc27f..b747e009 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -50,6 +50,7 @@ sha2.workspace = true rusqlite.workspace = true serde.workspace = true serde_json.workspace = true +strum.workspace = true # Offering this hive's status to the swarm (`swarm_status`). The same crate # the swarm controller reads it with, and `kv` for the same reason: the # bucket's name and creation config belong to neither end of it alone. diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index cdf84e88..a56d162b 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -712,7 +712,7 @@ async fn finish_approval( // snapshot refetch. `approved` rows that succeed get the // approval's logged resolved_at indirectly via `Utc::now()`; // failures already wrote it via mark_failed above. - let approval_kind = approval.kind.as_str(); + let approval_kind = <&str>::from(approval.kind); let sha_short = approval .fetched_sha .as_deref() @@ -921,7 +921,7 @@ pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> { tracing::info!(%id, note, "approval denied"); if let Some(a) = approval { let sha = a.fetched_sha.clone(); - let approval_kind = a.kind.as_str(); + let approval_kind = <&str>::from(a.kind); let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned()); let description = a.description.clone(); let agent_owned = a.agent.clone(); diff --git a/hive-c0re/src/agent_config/capabilities.rs b/hive-c0re/src/agent_config/capabilities.rs index 187cb3b7..a911bed2 100644 --- a/hive-c0re/src/agent_config/capabilities.rs +++ b/hive-c0re/src/agent_config/capabilities.rs @@ -55,7 +55,7 @@ pub fn caps_for(name: &str) -> Vec { pub fn has_cap(name: &str, cap: hive_sh4re::permissions::Capability) -> bool { caps_for(name) .iter() - .any(|s| s.eq_ignore_ascii_case(cap.as_str())) + .any(|s| s.eq_ignore_ascii_case(<&str>::from(cap))) } /// Persist the full capability map. Sorted JSON output keeps diffs diff --git a/hive-c0re/src/agent_config/tool_groups.rs b/hive-c0re/src/agent_config/tool_groups.rs index 4f73f7ba..8c93c64b 100644 --- a/hive-c0re/src/agent_config/tool_groups.rs +++ b/hive-c0re/src/agent_config/tool_groups.rs @@ -72,7 +72,7 @@ fn write(map: &BTreeMap>) -> std::io::Result<()> { pub fn validate_groups(groups: &[String]) -> anyhow::Result<()> { let valid: std::collections::BTreeSet<&str> = hive_sh4re::permissions::ToolGroup::ALL .iter() - .map(|g| g.as_str()) + .map(|g| <&str>::from(*g)) .collect(); let unknown: Vec<&str> = groups .iter() @@ -87,7 +87,7 @@ pub fn validate_groups(groups: &[String]) -> anyhow::Result<()> { unknown.join(", "), hive_sh4re::permissions::ToolGroup::ALL .iter() - .map(|g| g.as_str()) + .map(|g| <&str>::from(*g)) .collect::>() .join(", ") ) diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 49706d6b..633533e3 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -638,10 +638,10 @@ impl Coordinator { /// commits the JSON file, so the P3RM1SS10NS tab updates live. pub fn emit_capabilities_snapshot(self: &Arc) { use hive_sh4re::permissions::Capability; - let caps = Capability::ALL.iter().map(|c| c.as_str()).collect(); + let caps = Capability::ALL.iter().map(|c| <&str>::from(*c)).collect(); let descriptions = Capability::ALL .iter() - .map(|c| (c.as_str(), c.description())) + .map(|c| (<&str>::from(*c), c.description())) .collect(); let assignments = crate::capabilities::read(); // Best-effort roster (sync path); on a contended cache miss we @@ -665,10 +665,10 @@ impl Coordinator { /// commits the JSON file, so the P3RM1SS10NS tab updates live. pub fn emit_tool_groups_snapshot(self: &Arc) { use hive_sh4re::permissions::ToolGroup; - let groups = ToolGroup::ALL.iter().map(|g| g.as_str()).collect(); + let groups = ToolGroup::ALL.iter().map(|g| <&str>::from(*g)).collect(); let descriptions = ToolGroup::ALL .iter() - .map(|g| (g.as_str(), g.description())) + .map(|g| (<&str>::from(*g), g.description())) .collect(); let assignments = crate::tool_groups::read(); let roster = self.live_container_names_blocking().unwrap_or_default(); diff --git a/hive-c0re/src/dashboard/approvals.rs b/hive-c0re/src/dashboard/approvals.rs index 6a29f033..368d4f6d 100644 --- a/hive-c0re/src/dashboard/approvals.rs +++ b/hive-c0re/src/dashboard/approvals.rs @@ -105,7 +105,7 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec::from(a.kind), sha_short, status: "failed", note: Some(note.to_owned()), diff --git a/hive-c0re/src/dashboard/permissions.rs b/hive-c0re/src/dashboard/permissions.rs index 0ac5dc25..121324c8 100644 --- a/hive-c0re/src/dashboard/permissions.rs +++ b/hive-c0re/src/dashboard/permissions.rs @@ -50,11 +50,11 @@ pub(super) async fn get_tool_groups( ) -> axum::Json { let groups = hive_sh4re::permissions::ToolGroup::ALL .iter() - .map(|g| g.as_str()) + .map(|g| <&str>::from(*g)) .collect(); let descriptions = hive_sh4re::permissions::ToolGroup::ALL .iter() - .map(|g| (g.as_str(), g.description())) + .map(|g| (<&str>::from(*g), g.description())) .collect(); let assignments = crate::tool_groups::read(); let roster = state @@ -82,7 +82,7 @@ pub(super) async fn get_tool_groups( pub(crate) fn tool_group_default_names() -> Vec<&'static str> { hive_sh4re::permissions::ToolGroup::AGENT_DEFAULT .iter() - .map(|g| g.as_str()) + .map(|g| <&str>::from(*g)) .collect() } @@ -205,10 +205,10 @@ pub(super) async fn get_capabilities( State(state): State, ) -> axum::Json { use hive_sh4re::permissions::Capability; - let caps = Capability::ALL.iter().map(|c| c.as_str()).collect(); + let caps = Capability::ALL.iter().map(|c| <&str>::from(*c)).collect(); let descriptions = Capability::ALL .iter() - .map(|c| (c.as_str(), c.description())) + .map(|c| (<&str>::from(*c), c.description())) .collect(); let assignments = crate::capabilities::read(); let roster = state @@ -258,7 +258,7 @@ pub(super) async fn post_capabilities( } let known: Vec<&str> = hive_sh4re::permissions::Capability::ALL .iter() - .map(|c| c.as_str()) + .map(|c| <&str>::from(*c)) .collect(); for cap in &body.caps { if !known.contains(&cap.as_str()) { @@ -336,7 +336,7 @@ pub(super) async fn post_permissions( ) -> Result { let known_caps: Vec<&str> = hive_sh4re::permissions::Capability::ALL .iter() - .map(|c| c.as_str()) + .map(|c| <&str>::from(*c)) .collect(); // Phase 1 — validate everything before touching any file or the // queue, so a bad entry fails the whole POST with zero side effects. diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index 32556ae3..6ff92329 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -423,7 +423,7 @@ fn history_view(a: Approval) -> ApprovalHistoryView { // Pending shouldn't appear in recent_resolved, but be defensive. hive_sh4re::approvals::ApprovalStatus::Pending => "pending", }; - let kind = a.kind.as_str(); + let kind = <&str>::from(a.kind); ApprovalHistoryView { id: a.id, agent: a.agent.to_string(), diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 68045ce8..740b3c15 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -385,7 +385,7 @@ fn run_set_wanted(coord: &Arc, agent: &str, up: bool) -> Result<()> coord .power .set(agent, wanted) - .with_context(|| format!("set wanted={} for agent {agent}", wanted.as_str()))?; + .with_context(|| format!("set wanted={} for agent {agent}", <&str>::from(wanted)))?; Ok(()) } @@ -579,7 +579,7 @@ async fn run_reconcile(coord: &Arc, name: &str) -> Result { - tracing::debug!(%name, wanted = wanted.as_str(), running, "reconcile: noop"); + tracing::debug!(%name, wanted = <&str>::from(wanted), running, "reconcile: noop"); None } }) diff --git a/hive-c0re/src/job_queue/mod.rs b/hive-c0re/src/job_queue/mod.rs index 961863c5..91a44c57 100644 --- a/hive-c0re/src/job_queue/mod.rs +++ b/hive-c0re/src/job_queue/mod.rs @@ -281,7 +281,7 @@ impl JobQueue { /// so both are running and both name `a`. Anything keying this set by agent /// alone will silently drop one — see [`scheduler`]. /// - /// `label` is the node's own wire tag ([`NodeKind::as_str`]), the same + /// `label` is the node's own wire tag (`NodeKind`'s `&str` conversion), the same /// vocabulary the graph wire ships as a node's label, so a pill and a /// graph node name an operation identically. `takes_container_down` is the /// crash watcher's @@ -313,7 +313,7 @@ impl JobQueue { } Some(RunningTransient { agent: agent.to_owned(), - label: n.payload.as_str().to_owned(), + label: <&str>::from(&n.payload).to_owned(), takes_container_down: n.payload.takes_container_down(), // `started_at` is set when a node enters `Running`, and this // only sees `Running` nodes — the fallback is unreachable in diff --git a/hive-c0re/src/job_queue/model.rs b/hive-c0re/src/job_queue/model.rs index 6ac92760..0ee175b3 100644 --- a/hive-c0re/src/job_queue/model.rs +++ b/hive-c0re/src/job_queue/model.rs @@ -31,8 +31,9 @@ use hive_jobq::TerminalState; /// compose into live in the node-inventory table and surrounding sections /// of `docs/scheduler/coordinator.md` — this enum is deliberately not a /// second copy of that; each variant below gets a one-line pointer. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, strum::IntoStaticStr)] #[serde(tag = "kind", rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] pub enum NodeKind { /// The rebuild's meta-repo preamble. `relock = false` only for /// meta-update cascade rebuilds (re-locking would revert the bump the @@ -169,7 +170,7 @@ pub enum NodeKind { /// renders it without knowing what any of it means. impl hive_jobq_wire::WireNode for NodeKind { fn label(&self) -> String { - self.as_str().to_owned() + <&str>::from(self).to_owned() } fn data(&self, id: hive_jobq_wire::WireId) -> serde_json::Value { @@ -200,48 +201,6 @@ impl hive_jobq_wire::WireNode for NodeKind { } impl NodeKind { - /// Wire string for the node's label on the graph wire - /// ([`hive_jobq_wire::WireNode::label`]). - pub fn as_str(&self) -> &'static str { - match self { - NodeKind::MetaSync { .. } => "meta_sync", - NodeKind::Prebuild { .. } => "prebuild", - NodeKind::Swap { .. } => "swap", - NodeKind::RebuildBookkeeping { .. } => "rebuild_bookkeeping", - NodeKind::Provision { .. } => "provision", - NodeKind::Create { .. } => "create", - NodeKind::DestroyContainer { .. } => "destroy_container", - NodeKind::PurgeState { .. } => "purge_state", - NodeKind::DestroyBookkeeping { .. } => "destroy_bookkeeping", - NodeKind::MetaLock { .. } => "meta_lock", - NodeKind::Reconcile { .. } => "reconcile", - NodeKind::Start { .. } => "start", - NodeKind::Stop { .. } => "stop", - NodeKind::StopForUpdate { .. } => "stop_for_update", - NodeKind::Signal { .. } => "signal", - NodeKind::Drain { .. } => "drain", - NodeKind::PauseSignal { .. } => "pause_signal", - NodeKind::PauseDrain { .. } => "pause_drain", - NodeKind::WriteDropin { .. } => "write_dropin", - NodeKind::WritePermFile { .. } => "write_perm_file", - NodeKind::Reparent { .. } => "reparent", - NodeKind::DeployWindow { .. } => "deploy_window", - NodeKind::AgentWindow { .. } => "agent_window", - NodeKind::MergeVerify { .. } => "merge_verify", - NodeKind::DeployApply { .. } => "deploy_apply", - NodeKind::FinalizeDeploy { .. } => "finalize_deploy", - NodeKind::DeployTail { .. } => "deploy_tail", - NodeKind::ResolveApproval { .. } => "resolve_approval", - NodeKind::EmitRebuilt { .. } => "emit_rebuilt", - NodeKind::SetWanted { .. } => "set_wanted", - NodeKind::ForgeSweep => "forge_sweep", - NodeKind::MatrixSweep => "matrix_sweep", - NodeKind::WebhookRegister => "webhook_register", - NodeKind::KnowledgePull => "knowledge_pull", - NodeKind::WantedPull => "wanted_pull", - } - } - /// The agent this node targets, or `""` for agentless kinds /// ([`NodeKind::MetaLock`] on the `hyperhive` pseudo-agent, /// [`NodeKind::Reparent`] which can span multiple agents, and diff --git a/hive-c0re/src/job_queue/scheduler.rs b/hive-c0re/src/job_queue/scheduler.rs index f60f2028..cc2ff027 100644 --- a/hive-c0re/src/job_queue/scheduler.rs +++ b/hive-c0re/src/job_queue/scheduler.rs @@ -92,10 +92,11 @@ pub async fn run_worker(coord: Arc) { hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { let coord = node_coord; async move { + let kind_str = <&str>::from(&kind); tracing::info!( dag = coord.job_queue.root_of(id).unwrap_or_default(), node = id.get(), - kind = kind.as_str(), + kind = kind_str, agent = %kind.agent(), "job_queue: node running" ); @@ -104,7 +105,7 @@ pub async fn run_worker(coord: Arc) { Ok(()) => tracing::info!(node = id.get(), "job_queue: node done"), Err(e) => tracing::warn!( node = id.get(), - kind = kind.as_str(), + kind = kind_str, agent = %kind.agent(), error = %format!("{e:#}"), grown_nodes = !grown.is_empty(), diff --git a/hive-c0re/src/job_queue/tests.rs b/hive-c0re/src/job_queue/tests.rs index 602d0080..bbf06d45 100644 --- a/hive-c0re/src/job_queue/tests.rs +++ b/hive-c0re/src/job_queue/tests.rs @@ -146,12 +146,12 @@ fn declared_shape(q: &JobQueue) -> Vec { fn declared_shape_filtered(q: &JobQueue, keep: &dyn Fn(&NodeKind) -> bool) -> Vec { let sched = q.sched().lock().expect("job_queue mutex poisoned"); let graph = sched.graph(); - let kind_of = |id: NodeId| graph.node(id).map(|n| n.payload.as_str()); + let kind_of = |id: NodeId| graph.node(id).map(|n| <&str>::from(&n.payload)); graph .nodes() .filter(|n| keep(&n.payload)) .map(|n| Declared { - kind: n.payload.as_str(), + kind: <&str>::from(&n.payload), parent: n.parent.and_then(kind_of), after: n .deps @@ -175,7 +175,7 @@ fn node_of(q: &JobQueue, kind: &str) -> hive_jobq::NodeId { let graph = sched.graph(); let mut found: Vec<_> = graph .nodes() - .filter(|n| n.payload.as_str() == kind) + .filter(|n| <&str>::from(&n.payload) == kind) .map(|n| n.id) .collect(); assert_eq!( @@ -225,7 +225,7 @@ fn pending_kinds_filtered(q: &JobQueue, keep: &dyn Fn(&NodeKind) -> bool) -> Vec graph .nodes() .filter(|n| n.state == State::Pending && keep(&n.payload)) - .map(|n| n.payload.as_str()) + .map(|n| <&str>::from(&n.payload)) .collect() } @@ -275,7 +275,7 @@ fn declared_resources_of_kind(q: &JobQueue, kind: &str) -> Vec> { let graph = sched.graph(); let mut rows: Vec> = graph .nodes() - .filter(|n| n.payload.as_str() == kind) + .filter(|n| <&str>::from(&n.payload) == kind) .map(|n| { n.deps .iter() @@ -862,7 +862,7 @@ fn rebuild_chain_nodes_suppress_crash_watch() { kind.takes_container_down(), "{} must suppress crash-watch — a rebuild takes the container down \ on purpose", - kind.as_str() + <&str>::from(&kind) ); } // The counter-case, and the reason this can't be "any node in a rebuild": diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index e73a0e45..fea70579 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -823,7 +823,7 @@ fn handle_cancel_loose_end( coord.emit_approval_resolved(crate::coordinator::ApprovalResolved { id: approval.id, agent: approval.agent.as_str(), - approval_kind: approval.kind.as_str(), + approval_kind: <&str>::from(approval.kind), sha_short, status: "cancelled", note: approval.note, diff --git a/hive-c0re/src/stores/approvals.rs b/hive-c0re/src/stores/approvals.rs index 11b16dd9..417781db 100644 --- a/hive-c0re/src/stores/approvals.rs +++ b/hive-c0re/src/stores/approvals.rs @@ -93,7 +93,7 @@ impl Approvals { VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6, ?7)", params![ agent, - kind.as_str(), + <&str>::from(kind), commit_ref, Utc::now().timestamp(), description, diff --git a/hive-c0re/src/stores/build_logs.rs b/hive-c0re/src/stores/build_logs.rs index b43c02de..b7ca43b9 100644 --- a/hive-c0re/src/stores/build_logs.rs +++ b/hive-c0re/src/stores/build_logs.rs @@ -90,8 +90,9 @@ const MIGRATIONS: &[Migration] = &[Migration { /// Status of a finished build attempt. Stored as the literal string in /// the `status` column; `NULL` while the attempt is still in progress. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, strum::IntoStaticStr)] #[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] pub enum BuildStatus { /// Child exited with success. Ok, @@ -99,15 +100,6 @@ pub enum BuildStatus { Fail, } -impl BuildStatus { - fn as_str(self) -> &'static str { - match self { - Self::Ok => "ok", - Self::Fail => "fail", - } - } -} - /// Header-only row returned by `list_recent_for_agent`. Carries the /// metadata the dashboard's agent-card chip needs (status + age + /// id-to-open) without the multi-MB stdout/stderr payload. @@ -280,7 +272,7 @@ impl BuildLogs { let conn = self.conn.lock().unwrap(); if let Err(e) = conn.execute( "UPDATE build_logs SET finished_at = ?1, status = ?2 WHERE id = ?3", - params![now, status.as_str(), id], + params![now, <&str>::from(status), id], ) { tracing::warn!( build_log_id = id, diff --git a/hive-c0re/src/stores/power.rs b/hive-c0re/src/stores/power.rs index d19fbbeb..9d5bb708 100644 --- a/hive-c0re/src/stores/power.rs +++ b/hive-c0re/src/stores/power.rs @@ -30,28 +30,14 @@ CREATE TABLE IF NOT EXISTS agent_power ( "; /// Per-agent power intent. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr, strum::EnumString)] +#[strum(serialize_all = "snake_case")] pub enum Wanted { Up, Offline, } impl Wanted { - pub fn as_str(self) -> &'static str { - match self { - Wanted::Up => "up", - Wanted::Offline => "offline", - } - } - - fn parse(s: &str) -> Option { - match s { - "up" => Some(Wanted::Up), - "offline" => Some(Wanted::Offline), - _ => None, - } - } - /// Seed value from an observed running state (first boot after /// this store lands, or an agent spawned outside the normal path). pub fn from_running(running: bool) -> Self { @@ -128,7 +114,7 @@ impl PowerStore { .context("select agent_power")?; match row { None => Ok(None), - Some(stored) => Wanted::parse(&stored).map(Some).with_context(|| { + Some(stored) => stored.parse::().ok().map(Some).with_context(|| { format!("agent_power row for {agent} holds an unknown intent {stored:?}") }), } @@ -141,7 +127,7 @@ impl PowerStore { conn.execute( "INSERT INTO agent_power (agent, wanted, updated_at) VALUES (?1, ?2, ?3) ON CONFLICT(agent) DO UPDATE SET wanted = ?2, updated_at = ?3", - params![agent, wanted.as_str(), Utc::now().timestamp()], + params![agent, <&str>::from(wanted), Utc::now().timestamp()], ) .context("upsert agent_power")?; Ok(()) @@ -157,7 +143,7 @@ impl PowerStore { } let seeded = Wanted::from_running(running); self.set(agent, seeded)?; - tracing::info!(%agent, wanted = seeded.as_str(), "agent_power: seeded from observed state"); + tracing::info!(%agent, wanted = <&str>::from(seeded), "agent_power: seeded from observed state"); Ok(seeded) } diff --git a/hive-c0re/src/workers/auto_update.rs b/hive-c0re/src/workers/auto_update.rs index 282c71eb..03ee16b0 100644 --- a/hive-c0re/src/workers/auto_update.rs +++ b/hive-c0re/src/workers/auto_update.rs @@ -173,7 +173,7 @@ fn seed_manager_tool_groups() { } let all_groups: Vec = hive_sh4re::permissions::ToolGroup::MANAGER_DEFAULT .iter() - .map(|g| g.as_str().to_owned()) + .map(|g| <&str>::from(*g).to_owned()) .collect(); match tool_groups::set_groups(MANAGER_NAME, &all_groups) { Ok(()) => tracing::info!("seeded ruth's tool groups to MANAGER_DEFAULT (all groups)"), diff --git a/hive-forge/Cargo.toml b/hive-forge/Cargo.toml index 34f75535..3214c23d 100644 --- a/hive-forge/Cargo.toml +++ b/hive-forge/Cargo.toml @@ -38,6 +38,7 @@ reqwest = { workspace = true, features = [ "multipart", ] } serde = { workspace = true } +strum = { workspace = true } serde_json = { workspace = true } # Only for restoring the default SIGPIPE disposition at startup — see # `restore_sigpipe` in src/main.rs. std has no safe API for it. diff --git a/hive-forge/src/verbs/issue_edit.rs b/hive-forge/src/verbs/issue_edit.rs index a2f6a8bc..9120dda4 100644 --- a/hive-forge/src/verbs/issue_edit.rs +++ b/hive-forge/src/verbs/issue_edit.rs @@ -14,21 +14,13 @@ use crate::body; use crate::client::{Client, index}; use crate::verbs::print_json; -#[derive(Copy, Clone, ValueEnum)] +#[derive(Copy, Clone, ValueEnum, strum::IntoStaticStr)] +#[strum(serialize_all = "snake_case")] pub enum StateArg { Open, Closed, } -impl StateArg { - fn as_str(self) -> &'static str { - match self { - Self::Open => "open", - Self::Closed => "closed", - } - } -} - #[derive(ClapArgs)] pub struct Args { /// Issue (or PR — shares the same `/issues/` endpoint) number. @@ -76,7 +68,7 @@ pub fn run(client: &Client, args: Args) -> Result<()> { due_date: None, milestone: args.milestone.map(index).transpose()?, r#ref: None, - state: args.state.map(|s| s.as_str().to_owned()), + state: args.state.map(|s| <&str>::from(s).to_owned()), title: args.title, unset_due_date: None, updated_at: None, diff --git a/hive-sh4re/Cargo.toml b/hive-sh4re/Cargo.toml index 2db20c6d..f7a8ae73 100644 --- a/hive-sh4re/Cargo.toml +++ b/hive-sh4re/Cargo.toml @@ -13,6 +13,7 @@ hive-priv-sock.workspace = true hive-types.workspace = true schemars.workspace = true serde.workspace = true +strum.workspace = true [dev-dependencies] serde_json.workspace = true diff --git a/hive-sh4re/src/approvals.rs b/hive-sh4re/src/approvals.rs index 605de945..94017ced 100644 --- a/hive-sh4re/src/approvals.rs +++ b/hive-sh4re/src/approvals.rs @@ -40,8 +40,11 @@ pub struct Approval { /// What action the approval, when granted, will trigger. /// Variant-specific payload encoding + flow lives in /// `docs/agent-lifecycle/approvals.md::Approval kinds (wire shapes)`. -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[derive( + Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, strum::IntoStaticStr, +)] #[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] pub enum ApprovalKind { /// Create + start a new sub-agent container with the given name /// (under the default `agent.nix` template). @@ -68,23 +71,6 @@ pub enum ApprovalKind { MergeConfigPr, } -impl ApprovalKind { - /// Wire/UI string — the same value serde's `snake_case` rename - /// produces. The single source of truth for every place that needs - /// the kind as a `&'static str` (sqlite storage, dashboard events), - /// so adding a variant can't silently miss a hand-rolled match. - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - ApprovalKind::Spawn => "spawn", - ApprovalKind::InitConfig => "init_config", - ApprovalKind::UpdateMetaInputs => "update_meta_inputs", - ApprovalKind::SchedulePrompt => "schedule_prompt", - ApprovalKind::MergeConfigPr => "merge_config_pr", - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ApprovalStatus { diff --git a/hive-sh4re/src/permissions.rs b/hive-sh4re/src/permissions.rs index f34496b8..e376196c 100644 --- a/hive-sh4re/src/permissions.rs +++ b/hive-sh4re/src/permissions.rs @@ -12,8 +12,9 @@ use serde::{Deserialize, Serialize}; /// config) and expands it to the matching tool names for `--allowedTools`. /// When the env var is absent the harness falls back to `AGENT_DEFAULT`. /// See `docs/process/conventions.md::Tool groups`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)] #[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] pub enum ToolGroup { /// `send`, `recv`, `ack_until` Messaging, @@ -153,24 +154,6 @@ impl ToolGroup { Self::WebTools, ]; - /// The `snake_case` wire name for this group (matches `serde(rename_all = - /// "snake_case")` serialisation). - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Messaging => "messaging", - Self::Meta => "meta", - Self::Inbox => "inbox", - Self::Lifecycle => "lifecycle", - Self::Approvals => "approvals", - Self::Scheduling => "scheduling", - Self::Diagnostics => "diagnostics", - Self::Forge => "forge", - Self::Execution => "execution", - Self::WebTools => "web_tools", - } - } - /// Short human-readable description suitable for a tooltip or help text. #[must_use] pub fn description(self) -> &'static str { @@ -213,8 +196,9 @@ impl ToolGroup { /// `snake_case`) via `meta::render_flake`. The harness reads this to /// conditionally register capability-gated MCP tools so claude only /// sees tools it can actually invoke. See `docs/process/conventions.md::Capabilities`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)] #[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] pub enum Capability { /// Agent can lifecycle-manage the root agent (kill/start/restart) /// on behalf of the hive when the root has crashed. Named capability @@ -247,16 +231,6 @@ impl Capability { Self::QueryAgentState, ]; - /// Canonical `snake_case` name for this capability (matches serde). - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::ManageRootAgent => "manage_root_agent", - Self::ReadHostJournal => "read_host_journal", - Self::QueryAgentState => "query_agent_state", - } - } - /// Short human-readable description suitable for a tooltip or help text. #[must_use] pub fn description(self) -> &'static str { diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml index e6980f69..3bc1ea41 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -74,6 +74,7 @@ hive-types.workspace = true reqwest = { workspace = true, features = ["blocking"] } serde.workspace = true serde_json.workspace = true +strum.workspace = true swarm-authelia-bridge-sock.workspace = true # The queue connect (token mint + auth callback + reconnect) is shared with # every other participant - a hive publishing its own status runs the same diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index c0f2e034..86c3778d 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -719,7 +719,7 @@ fn render(declaration: &swarm_queue_client::wanted::HiveWanted) -> Vec::from(wanted.state).to_owned(), }) .collect() } @@ -1000,7 +1000,7 @@ async fn get_agents_status( .get(hive)? .agents .get(&row.name) - .map(|w| w.state.as_str().to_owned()) + .map(|w| <&str>::from(w.state).to_owned()) }); } } diff --git a/swarm-controller/src/webhook.rs b/swarm-controller/src/webhook.rs index fee1ffac..3da6d8ae 100644 --- a/swarm-controller/src/webhook.rs +++ b/swarm-controller/src/webhook.rs @@ -167,11 +167,13 @@ fn hex_decode(s: &str) -> Option> { /// registration we made* instead of by a field the sender chooses. Each /// registered hook gets its own `target_url`, exactly as the per-hive hooks /// do today. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr, strum::EnumString)] pub(super) enum DeliveryKind { /// Push events on the hive-wide knowledge repo. + #[strum(serialize = "knowledge")] Knowledge, /// `pull_request` events on the agent-config repos. + #[strum(serialize = "config-pr")] ConfigPr, /// `push` events on every repo in the instance — see /// `crate::vcs_metrics`'s doc comment for why this exists as its own @@ -179,6 +181,7 @@ pub(super) enum DeliveryKind { /// hook: that one is repo-scoped to the knowledge repo alone, and this /// one is instance-wide (registered via `admin_create_hook`, not /// `repo_create_hook`) — different scope, same event name. + #[strum(serialize = "vcs-activity")] VcsActivity, } @@ -212,38 +215,9 @@ impl DeliveryKind { format!( "{}{ROUTE_PREFIX}{}", public_base.trim_end_matches('/'), - self.as_str() + <&str>::from(self) ) } - - /// Parse the `{kind}` path segment. Unknown values are rejected rather - /// than accepted-and-ignored: a typo in a registered `target_url` must - /// be *observable*, and a 200 for an unrecognised path is exactly the - /// silence this issue exists to remove. - fn parse(segment: &str) -> Option { - match segment { - "knowledge" => Some(Self::Knowledge), - "config-pr" => Some(Self::ConfigPr), - "vcs-activity" => Some(Self::VcsActivity), - _ => None, - } - } - - /// Stable string form, used for logging. Deliberately the same spelling - /// as the path segment so a journal line can be matched against a - /// registered URL. - /// - /// ⚠️ **Not the routing key for the swarm→hive message.** That message is - /// semantic (*knowledge repo changed*, *deploy agent X at rev Y*) and is - /// addressed to the hives that need it; which hook a delivery arrived on - /// is an input to deriving it, not the thing sent. - fn as_str(self) -> &'static str { - match self { - Self::Knowledge => "knowledge", - Self::ConfigPr => "config-pr", - Self::VcsActivity => "vcs-activity", - } - } } /// Why a delivery was refused. @@ -374,13 +348,16 @@ pub(super) async fn post_webhook_forge( return (refusal.status(), message).into_response(); } - let Some(kind) = DeliveryKind::parse(&kind) else { + // Unknown values are rejected rather than accepted-and-ignored: a typo in + // a registered `target_url` must be *observable*, and a 200 for an + // unrecognised path is exactly the silence this issue exists to remove. + let Some(kind) = kind.parse::().ok() else { tracing::warn!(%kind, "webhook: delivery on an unknown hook kind"); return (StatusCode::NOT_FOUND, "unknown hook kind").into_response(); }; tracing::info!( - kind = kind.as_str(), + kind = <&str>::from(kind), bytes = body.len(), "webhook: verified delivery" ); @@ -562,20 +539,20 @@ mod tests { #[test] fn only_the_registered_hook_kinds_parse() { assert_eq!( - DeliveryKind::parse("knowledge"), + "knowledge".parse::().ok(), Some(DeliveryKind::Knowledge) ); assert_eq!( - DeliveryKind::parse("config-pr"), + "config-pr".parse::().ok(), Some(DeliveryKind::ConfigPr) ); assert_eq!( - DeliveryKind::parse("config_pr"), + "config_pr".parse::().ok(), None, "underscore is not the registered spelling; accepting both would make a journal line ambiguous" ); - assert_eq!(DeliveryKind::parse(""), None); - assert_eq!(DeliveryKind::parse("../knowledge"), None); + assert_eq!("".parse::().ok(), None); + assert_eq!("../knowledge".parse::().ok(), None); } /// A malformed stored secret is replaced rather than fatal — and the @@ -650,7 +627,7 @@ mod tests { let url = kind.target_url("https://swarm.example"); let segment = url.rsplit('/').next().expect("a last segment"); assert_eq!( - DeliveryKind::parse(segment), + segment.parse::().ok(), Some(kind), "the last path segment of a registered URL must parse back \ to the kind that built it" diff --git a/swarm-queue-client/Cargo.toml b/swarm-queue-client/Cargo.toml index 3798b01d..6b973d1a 100644 --- a/swarm-queue-client/Cargo.toml +++ b/swarm-queue-client/Cargo.toml @@ -47,6 +47,7 @@ async-nats.workspace = true reqwest = { workspace = true, features = ["blocking"] } serde.workspace = true serde_json.workspace = true +strum.workspace = true # A library, so its errors are a matchable enum rather than an opaque # `anyhow::Error`. The binaries that consume this keep anyhow; `?` converts. thiserror.workspace = true diff --git a/swarm-queue-client/src/wanted.rs b/swarm-queue-client/src/wanted.rs index 0a7be958..4ebfcbe7 100644 --- a/swarm-queue-client/src/wanted.rs +++ b/swarm-queue-client/src/wanted.rs @@ -84,8 +84,11 @@ pub struct AgentWanted { /// 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)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, strum::IntoStaticStr, +)] #[serde(rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] pub enum AgentState { /// Exists on the hive and is running. Up, @@ -109,22 +112,6 @@ pub enum AgentState { Destroyed, } -impl AgentState { - /// The wire spelling, for a reader that renders rather than decodes. - /// - /// Kept beside the enum so it cannot drift from the `rename_all` above; - /// a test pins the two together. - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - AgentState::Up => "up", - AgentState::Offline => "offline", - AgentState::Paused => "paused", - AgentState::Destroyed => "destroyed", - } - } -} - /// Open the wanted-state bucket for writing, creating it if nothing has yet. /// /// **Controller-side only.** `history: 1` because a hive converges to the @@ -291,7 +278,7 @@ mod tests { } assert_eq!( serde_json::to_string(&state).expect("serialises"), - format!("\"{}\"", state.as_str()) + format!("\"{}\"", <&str>::from(state)) ); } } diff --git a/swarm-secret-client/Cargo.toml b/swarm-secret-client/Cargo.toml index bf65953d..eed8fe9e 100644 --- a/swarm-secret-client/Cargo.toml +++ b/swarm-secret-client/Cargo.toml @@ -13,6 +13,7 @@ reqwest.workspace = true rustify.workspace = true rustify_derive.workspace = true serde.workspace = true +strum.workspace = true thiserror.workspace = true vaultrs.workspace = true diff --git a/swarm-secret-client/src/path.rs b/swarm-secret-client/src/path.rs index 5efa7f8c..c4f69ed5 100644 --- a/swarm-secret-client/src/path.rs +++ b/swarm-secret-client/src/path.rs @@ -25,19 +25,28 @@ pub const ROOT: &str = "swarm"; /// misspelled kind is a 403 at provision time rather than anything a compiler /// sees. [`Kind::ALL`] exists so a test can enumerate the set instead of /// restating it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// +/// The segment string lives once, on the variant itself +/// (`#[strum(serialize = "...")]`), rather than a second time in a +/// hand-written `as_str()` match — [`strum::IntoStaticStr`] derives that +/// conversion, so the attribute is the only place a segment is spelled. +#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr)] pub enum Kind { /// One agent container's own secrets. + #[strum(serialize = "agents")] Agent, /// One hive's secrets, held on behalf of whatever runs there. An identity /// minted per hive rather than per agent lands here even though an agent /// is what uses it. + #[strum(serialize = "hives")] Hive, /// One swarm service — the things a swarm runs beside the controller. + #[strum(serialize = "services")] Service, /// The controller itself. There is one per swarm, so the name segment /// below it does not vary; the shape stays uniform anyway, because one /// grant pattern over `/` is cheaper than a special case. + #[strum(serialize = "controller")] Controller, } @@ -46,18 +55,6 @@ impl Kind { /// than restate it — a second list is a list that drifts. pub const ALL: [Kind; 4] = [Kind::Agent, Kind::Hive, Kind::Service, Kind::Controller]; - /// The path segment, which is also what the store's grant is written - /// against. - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Kind::Agent => "agents", - Kind::Hive => "hives", - Kind::Service => "services", - Kind::Controller => "controller", - } - } - /// What to call the name in an error — singular, because the message reads /// "hive name ... is not a single path segment". #[must_use] @@ -80,7 +77,7 @@ impl Kind { /// another's secrets. pub fn principal_prefix(kind: Kind, name: &str) -> Result { checked_segment(kind.label(), name)?; - Ok(format!("{ROOT}/{}/{name}", kind.as_str())) + Ok(format!("{ROOT}/{}/{name}", <&str>::from(kind))) } /// A path segment that cannot change the path's shape. @@ -178,7 +175,7 @@ mod tests { // without a segment here would be granted by accident rather than by // decision. Spelling each one out is what makes adding a kind a // deliberate edit. - let segments: Vec<&str> = Kind::ALL.iter().map(|k| k.as_str()).collect(); + let segments: Vec<&str> = Kind::ALL.iter().map(|k| (*k).into()).collect(); assert_eq!(segments, ["agents", "hives", "services", "controller"]); } @@ -189,7 +186,11 @@ mod tests { // make the error name the wrong one. for (i, a) in Kind::ALL.iter().enumerate() { for b in &Kind::ALL[i + 1..] { - assert_ne!(a.as_str(), b.as_str(), "{a:?} and {b:?} share a segment"); + assert_ne!( + <&str>::from(*a), + <&str>::from(*b), + "{a:?} and {b:?} share a segment" + ); assert_ne!(a.label(), b.label(), "{a:?} and {b:?} share a label"); } } diff --git a/swarm-secret-client/src/policy.rs b/swarm-secret-client/src/policy.rs index cd796346..897b45b5 100644 --- a/swarm-secret-client/src/policy.rs +++ b/swarm-secret-client/src/policy.rs @@ -54,7 +54,7 @@ pub fn hive_object_name(hive: &str) -> Result { pub fn render() -> String { format!( "path \"{MOUNT}/data/{ROOT}/{}/*\" {{\n capabilities = [\"read\"]\n}}\n", - Kind::Agent.as_str() + <&str>::from(Kind::Agent) ) }