diff --git a/Cargo.lock b/Cargo.lock index f14c6419..70987333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1767,7 +1767,6 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "strum", "swarm-queue-client", "swarm-secret-client", "tempfile", @@ -1815,7 +1814,6 @@ dependencies = [ "reqwest", "serde", "serde_json", - "strum", "time", "url", ] @@ -1967,7 +1965,6 @@ dependencies = [ "schemars", "serde", "serde_json", - "strum", ] [[package]] @@ -4665,27 +4662,6 @@ 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" @@ -4745,7 +4721,6 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "strum", "swarm-authelia-bridge-sock", "swarm-queue-client", "swarm-secret-client", @@ -4786,7 +4761,6 @@ dependencies = [ "reqwest", "serde", "serde_json", - "strum", "thiserror 2.0.18", "tokio", "tracing", @@ -4801,7 +4775,6 @@ dependencies = [ "rustify_derive", "serde", "serde_json", - "strum", "thiserror 2.0.18", "vaultrs", ] diff --git a/Cargo.toml b/Cargo.toml index a4d1206f..82bff01f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,10 +113,6 @@ 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 922cfcdf..f0a1743e 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`'s `&str` conversion, the same strings the graph wire's node + (`NodeKind::as_str`, 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 b747e009..895dc27f 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -50,7 +50,6 @@ 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 a56d162b..cdf84e88 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 = <&str>::from(approval.kind); + let approval_kind = approval.kind.as_str(); 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 = <&str>::from(a.kind); + let approval_kind = a.kind.as_str(); 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 a911bed2..187cb3b7 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(<&str>::from(cap))) + .any(|s| s.eq_ignore_ascii_case(cap.as_str())) } /// 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 8c93c64b..4f73f7ba 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| <&str>::from(*g)) + .map(|g| g.as_str()) .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| <&str>::from(*g)) + .map(|g| g.as_str()) .collect::>() .join(", ") ) diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 633533e3..49706d6b 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| <&str>::from(*c)).collect(); + let caps = Capability::ALL.iter().map(|c| c.as_str()).collect(); let descriptions = Capability::ALL .iter() - .map(|c| (<&str>::from(*c), c.description())) + .map(|c| (c.as_str(), 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| <&str>::from(*g)).collect(); + let groups = ToolGroup::ALL.iter().map(|g| g.as_str()).collect(); let descriptions = ToolGroup::ALL .iter() - .map(|g| (<&str>::from(*g), g.description())) + .map(|g| (g.as_str(), 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 368d4f6d..6a29f033 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), + approval_kind: a.kind.as_str(), 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 121324c8..0ac5dc25 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| <&str>::from(*g)) + .map(|g| g.as_str()) .collect(); let descriptions = hive_sh4re::permissions::ToolGroup::ALL .iter() - .map(|g| (<&str>::from(*g), g.description())) + .map(|g| (g.as_str(), 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| <&str>::from(*g)) + .map(|g| g.as_str()) .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| <&str>::from(*c)).collect(); + let caps = Capability::ALL.iter().map(|c| c.as_str()).collect(); let descriptions = Capability::ALL .iter() - .map(|c| (<&str>::from(*c), c.description())) + .map(|c| (c.as_str(), 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| <&str>::from(*c)) + .map(|c| c.as_str()) .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| <&str>::from(*c)) + .map(|c| c.as_str()) .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 6ff92329..32556ae3 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 = <&str>::from(a.kind); + let kind = a.kind.as_str(); 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 740b3c15..68045ce8 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}", <&str>::from(wanted)))?; + .with_context(|| format!("set wanted={} for agent {agent}", wanted.as_str()))?; Ok(()) } @@ -579,7 +579,7 @@ async fn run_reconcile(coord: &Arc, name: &str) -> Result { - tracing::debug!(%name, wanted = <&str>::from(wanted), running, "reconcile: noop"); + tracing::debug!(%name, wanted = wanted.as_str(), running, "reconcile: noop"); None } }) diff --git a/hive-c0re/src/job_queue/mod.rs b/hive-c0re/src/job_queue/mod.rs index 91a44c57..961863c5 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`'s `&str` conversion), the same + /// `label` is the node's own wire tag ([`NodeKind::as_str`]), 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: <&str>::from(&n.payload).to_owned(), + label: n.payload.as_str().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 0ee175b3..6ac92760 100644 --- a/hive-c0re/src/job_queue/model.rs +++ b/hive-c0re/src/job_queue/model.rs @@ -31,9 +31,8 @@ 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, strum::IntoStaticStr)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[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 @@ -170,7 +169,7 @@ pub enum NodeKind { /// renders it without knowing what any of it means. impl hive_jobq_wire::WireNode for NodeKind { fn label(&self) -> String { - <&str>::from(self).to_owned() + self.as_str().to_owned() } fn data(&self, id: hive_jobq_wire::WireId) -> serde_json::Value { @@ -201,6 +200,48 @@ 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 cc2ff027..f60f2028 100644 --- a/hive-c0re/src/job_queue/scheduler.rs +++ b/hive-c0re/src/job_queue/scheduler.rs @@ -92,11 +92,10 @@ 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_str, + kind = kind.as_str(), agent = %kind.agent(), "job_queue: node running" ); @@ -105,7 +104,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_str, + kind = kind.as_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 bbf06d45..602d0080 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| <&str>::from(&n.payload)); + let kind_of = |id: NodeId| graph.node(id).map(|n| n.payload.as_str()); graph .nodes() .filter(|n| keep(&n.payload)) .map(|n| Declared { - kind: <&str>::from(&n.payload), + kind: n.payload.as_str(), 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| <&str>::from(&n.payload) == kind) + .filter(|n| n.payload.as_str() == 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| <&str>::from(&n.payload)) + .map(|n| n.payload.as_str()) .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| <&str>::from(&n.payload) == kind) + .filter(|n| n.payload.as_str() == 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", - <&str>::from(&kind) + kind.as_str() ); } // 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 fea70579..e73a0e45 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: <&str>::from(approval.kind), + approval_kind: approval.kind.as_str(), sha_short, status: "cancelled", note: approval.note, diff --git a/hive-c0re/src/stores/approvals.rs b/hive-c0re/src/stores/approvals.rs index 417781db..11b16dd9 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, - <&str>::from(kind), + kind.as_str(), 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 b7ca43b9..b43c02de 100644 --- a/hive-c0re/src/stores/build_logs.rs +++ b/hive-c0re/src/stores/build_logs.rs @@ -90,9 +90,8 @@ 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, strum::IntoStaticStr)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] pub enum BuildStatus { /// Child exited with success. Ok, @@ -100,6 +99,15 @@ 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. @@ -272,7 +280,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, <&str>::from(status), id], + params![now, status.as_str(), id], ) { tracing::warn!( build_log_id = id, diff --git a/hive-c0re/src/stores/power.rs b/hive-c0re/src/stores/power.rs index 9d5bb708..d19fbbeb 100644 --- a/hive-c0re/src/stores/power.rs +++ b/hive-c0re/src/stores/power.rs @@ -30,14 +30,28 @@ CREATE TABLE IF NOT EXISTS agent_power ( "; /// Per-agent power intent. -#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::IntoStaticStr, strum::EnumString)] -#[strum(serialize_all = "snake_case")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] 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 { @@ -114,7 +128,7 @@ impl PowerStore { .context("select agent_power")?; match row { None => Ok(None), - Some(stored) => stored.parse::().ok().map(Some).with_context(|| { + Some(stored) => Wanted::parse(&stored).map(Some).with_context(|| { format!("agent_power row for {agent} holds an unknown intent {stored:?}") }), } @@ -127,7 +141,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, <&str>::from(wanted), Utc::now().timestamp()], + params![agent, wanted.as_str(), Utc::now().timestamp()], ) .context("upsert agent_power")?; Ok(()) @@ -143,7 +157,7 @@ impl PowerStore { } let seeded = Wanted::from_running(running); self.set(agent, seeded)?; - tracing::info!(%agent, wanted = <&str>::from(seeded), "agent_power: seeded from observed state"); + tracing::info!(%agent, wanted = seeded.as_str(), "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 03ee16b0..282c71eb 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| <&str>::from(*g).to_owned()) + .map(|g| g.as_str().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 3214c23d..34f75535 100644 --- a/hive-forge/Cargo.toml +++ b/hive-forge/Cargo.toml @@ -38,7 +38,6 @@ 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 9120dda4..a2f6a8bc 100644 --- a/hive-forge/src/verbs/issue_edit.rs +++ b/hive-forge/src/verbs/issue_edit.rs @@ -14,13 +14,21 @@ use crate::body; use crate::client::{Client, index}; use crate::verbs::print_json; -#[derive(Copy, Clone, ValueEnum, strum::IntoStaticStr)] -#[strum(serialize_all = "snake_case")] +#[derive(Copy, Clone, ValueEnum)] 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. @@ -68,7 +76,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| <&str>::from(s).to_owned()), + state: args.state.map(|s| s.as_str().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 f7a8ae73..2db20c6d 100644 --- a/hive-sh4re/Cargo.toml +++ b/hive-sh4re/Cargo.toml @@ -13,7 +13,6 @@ 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 94017ced..605de945 100644 --- a/hive-sh4re/src/approvals.rs +++ b/hive-sh4re/src/approvals.rs @@ -40,11 +40,8 @@ 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, strum::IntoStaticStr, -)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[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). @@ -71,6 +68,23 @@ 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 e376196c..f34496b8 100644 --- a/hive-sh4re/src/permissions.rs +++ b/hive-sh4re/src/permissions.rs @@ -12,9 +12,8 @@ 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, strum::IntoStaticStr)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] pub enum ToolGroup { /// `send`, `recv`, `ack_until` Messaging, @@ -154,6 +153,24 @@ 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 { @@ -196,9 +213,8 @@ 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, strum::IntoStaticStr)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[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 @@ -231,6 +247,16 @@ 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 3bc1ea41..e6980f69 100644 --- a/swarm-controller/Cargo.toml +++ b/swarm-controller/Cargo.toml @@ -74,7 +74,6 @@ 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 86c3778d..c0f2e034 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(), + state: wanted.state.as_str().to_owned(), }) .collect() } @@ -1000,7 +1000,7 @@ async fn get_agents_status( .get(hive)? .agents .get(&row.name) - .map(|w| <&str>::from(w.state).to_owned()) + .map(|w| w.state.as_str().to_owned()) }); } } diff --git a/swarm-controller/src/webhook.rs b/swarm-controller/src/webhook.rs index 3da6d8ae..fee1ffac 100644 --- a/swarm-controller/src/webhook.rs +++ b/swarm-controller/src/webhook.rs @@ -167,13 +167,11 @@ 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, strum::IntoStaticStr, strum::EnumString)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] 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 @@ -181,7 +179,6 @@ 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, } @@ -215,9 +212,38 @@ impl DeliveryKind { format!( "{}{ROUTE_PREFIX}{}", public_base.trim_end_matches('/'), - <&str>::from(self) + self.as_str() ) } + + /// 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. @@ -348,16 +374,13 @@ pub(super) async fn post_webhook_forge( return (refusal.status(), message).into_response(); } - // 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 { + let Some(kind) = DeliveryKind::parse(&kind) else { tracing::warn!(%kind, "webhook: delivery on an unknown hook kind"); return (StatusCode::NOT_FOUND, "unknown hook kind").into_response(); }; tracing::info!( - kind = <&str>::from(kind), + kind = kind.as_str(), bytes = body.len(), "webhook: verified delivery" ); @@ -539,20 +562,20 @@ mod tests { #[test] fn only_the_registered_hook_kinds_parse() { assert_eq!( - "knowledge".parse::().ok(), + DeliveryKind::parse("knowledge"), Some(DeliveryKind::Knowledge) ); assert_eq!( - "config-pr".parse::().ok(), + DeliveryKind::parse("config-pr"), Some(DeliveryKind::ConfigPr) ); assert_eq!( - "config_pr".parse::().ok(), + DeliveryKind::parse("config_pr"), None, "underscore is not the registered spelling; accepting both would make a journal line ambiguous" ); - assert_eq!("".parse::().ok(), None); - assert_eq!("../knowledge".parse::().ok(), None); + assert_eq!(DeliveryKind::parse(""), None); + assert_eq!(DeliveryKind::parse("../knowledge"), None); } /// A malformed stored secret is replaced rather than fatal — and the @@ -627,7 +650,7 @@ mod tests { let url = kind.target_url("https://swarm.example"); let segment = url.rsplit('/').next().expect("a last segment"); assert_eq!( - segment.parse::().ok(), + DeliveryKind::parse(segment), 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 6b973d1a..3798b01d 100644 --- a/swarm-queue-client/Cargo.toml +++ b/swarm-queue-client/Cargo.toml @@ -47,7 +47,6 @@ 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 4ebfcbe7..0a7be958 100644 --- a/swarm-queue-client/src/wanted.rs +++ b/swarm-queue-client/src/wanted.rs @@ -84,11 +84,8 @@ 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, strum::IntoStaticStr, -)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] -#[strum(serialize_all = "snake_case")] pub enum AgentState { /// Exists on the hive and is running. Up, @@ -112,6 +109,22 @@ 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 @@ -278,7 +291,7 @@ mod tests { } assert_eq!( serde_json::to_string(&state).expect("serialises"), - format!("\"{}\"", <&str>::from(state)) + format!("\"{}\"", state.as_str()) ); } } diff --git a/swarm-secret-client/Cargo.toml b/swarm-secret-client/Cargo.toml index eed8fe9e..bf65953d 100644 --- a/swarm-secret-client/Cargo.toml +++ b/swarm-secret-client/Cargo.toml @@ -13,7 +13,6 @@ 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 c4f69ed5..5efa7f8c 100644 --- a/swarm-secret-client/src/path.rs +++ b/swarm-secret-client/src/path.rs @@ -25,28 +25,19 @@ 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. -/// -/// 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)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] 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, } @@ -55,6 +46,18 @@ 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] @@ -77,7 +80,7 @@ impl Kind { /// another's secrets. pub fn principal_prefix(kind: Kind, name: &str) -> Result { checked_segment(kind.label(), name)?; - Ok(format!("{ROOT}/{}/{name}", <&str>::from(kind))) + Ok(format!("{ROOT}/{}/{name}", kind.as_str())) } /// A path segment that cannot change the path's shape. @@ -175,7 +178,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).into()).collect(); + let segments: Vec<&str> = Kind::ALL.iter().map(|k| k.as_str()).collect(); assert_eq!(segments, ["agents", "hives", "services", "controller"]); } @@ -186,11 +189,7 @@ 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!( - <&str>::from(*a), - <&str>::from(*b), - "{a:?} and {b:?} share a segment" - ); + assert_ne!(a.as_str(), b.as_str(), "{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 897b45b5..cd796346 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", - <&str>::from(Kind::Agent) + Kind::Agent.as_str() ) }