diff --git a/Cargo.lock b/Cargo.lock index 1a6a1669..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]] @@ -4742,6 +4745,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "strum", "swarm-authelia-bridge-sock", "swarm-queue-client", "swarm-secret-client", @@ -4782,6 +4786,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "strum", "thiserror 2.0.18", "tokio", "tracing", 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/job_queue/model.rs b/hive-c0re/src/job_queue/model.rs index 6ac92760..42bf4fe5 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 @@ -201,45 +202,13 @@ 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`]). + /// ([`hive_jobq_wire::WireNode::label`]) — derived + /// (`#[strum(serialize_all = "snake_case")]`), matching the same + /// convention the `#[serde(rename_all = "snake_case")]` tag above + /// uses, rather than a 31-arm hand-written match kept in sync with it + /// by hand. 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", - } + self.into() } /// The agent this node targets, or `""` for agentless kinds diff --git a/hive-c0re/src/stores/build_logs.rs b/hive-c0re/src/stores/build_logs.rs index b43c02de..07ec2434 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, @@ -101,10 +102,7 @@ pub enum BuildStatus { impl BuildStatus { fn as_str(self) -> &'static str { - match self { - Self::Ok => "ok", - Self::Fail => "fail", - } + self.into() } } diff --git a/hive-c0re/src/stores/power.rs b/hive-c0re/src/stores/power.rs index d19fbbeb..f50ec5ea 100644 --- a/hive-c0re/src/stores/power.rs +++ b/hive-c0re/src/stores/power.rs @@ -30,7 +30,8 @@ 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, @@ -38,18 +39,14 @@ pub enum Wanted { impl Wanted { pub fn as_str(self) -> &'static str { - match self { - Wanted::Up => "up", - Wanted::Offline => "offline", - } + self.into() } + /// Derived (`strum::EnumString`, the same `snake_case` convention + /// `as_str` uses) rather than a hand-written match kept in sync with + /// it by hand. fn parse(s: &str) -> Option { - match s { - "up" => Some(Wanted::Up), - "offline" => Some(Wanted::Offline), - _ => None, - } + s.parse().ok() } /// Seed value from an observed running state (first boot after 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..04f24e51 100644 --- a/hive-forge/src/verbs/issue_edit.rs +++ b/hive-forge/src/verbs/issue_edit.rs @@ -14,7 +14,8 @@ 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, @@ -22,10 +23,7 @@ pub enum StateArg { impl StateArg { fn as_str(self) -> &'static str { - match self { - Self::Open => "open", - Self::Closed => "closed", - } + self.into() } } 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..d25912a4 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). @@ -70,18 +73,12 @@ pub enum ApprovalKind { 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. + /// produces, via the same derive (`#[strum(serialize_all = + /// "snake_case")]`) rather than a hand-rolled match a new variant + /// could silently miss. #[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", - } + self.into() } } diff --git a/hive-sh4re/src/permissions.rs b/hive-sh4re/src/permissions.rs index f34496b8..5baaa190 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, @@ -154,21 +155,12 @@ impl ToolGroup { ]; /// The `snake_case` wire name for this group (matches `serde(rename_all = - /// "snake_case")` serialisation). + /// "snake_case")` serialisation) — derived (`#[strum(serialize_all = + /// "snake_case")]`) from the same convention rather than a hand-written + /// match kept in sync with it by hand. #[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", - } + self.into() } /// Short human-readable description suitable for a tooltip or help text. @@ -213,8 +205,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,14 +240,12 @@ impl Capability { Self::QueryAgentState, ]; - /// Canonical `snake_case` name for this capability (matches serde). + /// Canonical `snake_case` name for this capability (matches serde) — + /// derived rather than a hand-written match, same as + /// [`ToolGroup::as_str`]. #[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", - } + self.into() } /// Short human-readable description suitable for a tooltip or help text. 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/webhook.rs b/swarm-controller/src/webhook.rs index fee1ffac..4495131d 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, } @@ -219,14 +222,12 @@ impl DeliveryKind { /// 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. + /// silence this issue exists to remove. Derived (`strum::EnumString`, + /// the same per-variant `#[strum(serialize = "...")]` spellings + /// `as_str` uses) rather than a hand-written match kept in sync with + /// that one by hand. fn parse(segment: &str) -> Option { - match segment { - "knowledge" => Some(Self::Knowledge), - "config-pr" => Some(Self::ConfigPr), - "vcs-activity" => Some(Self::VcsActivity), - _ => None, - } + segment.parse().ok() } /// Stable string form, used for logging. Deliberately the same spelling @@ -238,11 +239,7 @@ impl DeliveryKind { /// 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", - } + self.into() } } 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..291a6150 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, @@ -112,16 +115,13 @@ pub enum AgentState { 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. + /// Derived (`#[strum(serialize_all = "snake_case")]`) from the same + /// convention as the `serde(rename_all)` above, rather than a + /// hand-written match kept in sync with it by hand; a test still 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", - } + self.into() } }