convert hand-written enum as_str matches to strum derives workspace-wide

This commit is contained in:
damocles 2026-09-11 21:06:54 +02:00
commit 299add158f
14 changed files with 70 additions and 113 deletions

5
Cargo.lock generated
View file

@ -1767,6 +1767,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0", "sha2 0.11.0",
"strum",
"swarm-queue-client", "swarm-queue-client",
"swarm-secret-client", "swarm-secret-client",
"tempfile", "tempfile",
@ -1814,6 +1815,7 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"strum",
"time", "time",
"url", "url",
] ]
@ -1965,6 +1967,7 @@ dependencies = [
"schemars", "schemars",
"serde", "serde",
"serde_json", "serde_json",
"strum",
] ]
[[package]] [[package]]
@ -4742,6 +4745,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0", "sha2 0.11.0",
"strum",
"swarm-authelia-bridge-sock", "swarm-authelia-bridge-sock",
"swarm-queue-client", "swarm-queue-client",
"swarm-secret-client", "swarm-secret-client",
@ -4782,6 +4786,7 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"strum",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",

View file

@ -50,6 +50,7 @@ sha2.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
strum.workspace = true
# Offering this hive's status to the swarm (`swarm_status`). The same crate # 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 # 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. # bucket's name and creation config belong to neither end of it alone.

View file

@ -31,8 +31,9 @@ use hive_jobq::TerminalState;
/// compose into live in the node-inventory table and surrounding sections /// compose into live in the node-inventory table and surrounding sections
/// of `docs/scheduler/coordinator.md` — this enum is deliberately not a /// of `docs/scheduler/coordinator.md` — this enum is deliberately not a
/// second copy of that; each variant below gets a one-line pointer. /// 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")] #[serde(tag = "kind", rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum NodeKind { pub enum NodeKind {
/// The rebuild's meta-repo preamble. `relock = false` only for /// The rebuild's meta-repo preamble. `relock = false` only for
/// meta-update cascade rebuilds (re-locking would revert the bump the /// meta-update cascade rebuilds (re-locking would revert the bump the
@ -201,45 +202,13 @@ impl hive_jobq_wire::WireNode for NodeKind {
impl NodeKind { impl NodeKind {
/// Wire string for the node's label on the graph wire /// 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 { pub fn as_str(&self) -> &'static str {
match self { self.into()
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 /// The agent this node targets, or `""` for agentless kinds

View file

@ -90,8 +90,9 @@ const MIGRATIONS: &[Migration] = &[Migration {
/// Status of a finished build attempt. Stored as the literal string in /// Status of a finished build attempt. Stored as the literal string in
/// the `status` column; `NULL` while the attempt is still in progress. /// 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")] #[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum BuildStatus { pub enum BuildStatus {
/// Child exited with success. /// Child exited with success.
Ok, Ok,
@ -101,10 +102,7 @@ pub enum BuildStatus {
impl BuildStatus { impl BuildStatus {
fn as_str(self) -> &'static str { fn as_str(self) -> &'static str {
match self { self.into()
Self::Ok => "ok",
Self::Fail => "fail",
}
} }
} }

View file

@ -30,7 +30,8 @@ CREATE TABLE IF NOT EXISTS agent_power (
"; ";
/// Per-agent power intent. /// 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 { pub enum Wanted {
Up, Up,
Offline, Offline,
@ -38,18 +39,14 @@ pub enum Wanted {
impl Wanted { impl Wanted {
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { self.into()
Wanted::Up => "up",
Wanted::Offline => "offline",
}
} }
/// 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<Self> { fn parse(s: &str) -> Option<Self> {
match s { s.parse().ok()
"up" => Some(Wanted::Up),
"offline" => Some(Wanted::Offline),
_ => None,
}
} }
/// Seed value from an observed running state (first boot after /// Seed value from an observed running state (first boot after

View file

@ -38,6 +38,7 @@ reqwest = { workspace = true, features = [
"multipart", "multipart",
] } ] }
serde = { workspace = true } serde = { workspace = true }
strum = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
# Only for restoring the default SIGPIPE disposition at startup — see # Only for restoring the default SIGPIPE disposition at startup — see
# `restore_sigpipe` in src/main.rs. std has no safe API for it. # `restore_sigpipe` in src/main.rs. std has no safe API for it.

View file

@ -14,7 +14,8 @@ use crate::body;
use crate::client::{Client, index}; use crate::client::{Client, index};
use crate::verbs::print_json; use crate::verbs::print_json;
#[derive(Copy, Clone, ValueEnum)] #[derive(Copy, Clone, ValueEnum, strum::IntoStaticStr)]
#[strum(serialize_all = "snake_case")]
pub enum StateArg { pub enum StateArg {
Open, Open,
Closed, Closed,
@ -22,10 +23,7 @@ pub enum StateArg {
impl StateArg { impl StateArg {
fn as_str(self) -> &'static str { fn as_str(self) -> &'static str {
match self { self.into()
Self::Open => "open",
Self::Closed => "closed",
}
} }
} }

View file

@ -13,6 +13,7 @@ hive-priv-sock.workspace = true
hive-types.workspace = true hive-types.workspace = true
schemars.workspace = true schemars.workspace = true
serde.workspace = true serde.workspace = true
strum.workspace = true
[dev-dependencies] [dev-dependencies]
serde_json.workspace = true serde_json.workspace = true

View file

@ -40,8 +40,11 @@ pub struct Approval {
/// What action the approval, when granted, will trigger. /// What action the approval, when granted, will trigger.
/// Variant-specific payload encoding + flow lives in /// Variant-specific payload encoding + flow lives in
/// `docs/agent-lifecycle/approvals.md::Approval kinds (wire shapes)`. /// `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")] #[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum ApprovalKind { pub enum ApprovalKind {
/// Create + start a new sub-agent container with the given name /// Create + start a new sub-agent container with the given name
/// (under the default `agent.nix` template). /// (under the default `agent.nix` template).
@ -70,18 +73,12 @@ pub enum ApprovalKind {
impl ApprovalKind { impl ApprovalKind {
/// Wire/UI string — the same value serde's `snake_case` rename /// Wire/UI string — the same value serde's `snake_case` rename
/// produces. The single source of truth for every place that needs /// produces, via the same derive (`#[strum(serialize_all =
/// the kind as a `&'static str` (sqlite storage, dashboard events), /// "snake_case")]`) rather than a hand-rolled match a new variant
/// so adding a variant can't silently miss a hand-rolled match. /// could silently miss.
#[must_use] #[must_use]
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { self.into()
ApprovalKind::Spawn => "spawn",
ApprovalKind::InitConfig => "init_config",
ApprovalKind::UpdateMetaInputs => "update_meta_inputs",
ApprovalKind::SchedulePrompt => "schedule_prompt",
ApprovalKind::MergeConfigPr => "merge_config_pr",
}
} }
} }

View file

@ -12,8 +12,9 @@ use serde::{Deserialize, Serialize};
/// config) and expands it to the matching tool names for `--allowedTools`. /// config) and expands it to the matching tool names for `--allowedTools`.
/// When the env var is absent the harness falls back to `AGENT_DEFAULT`. /// When the env var is absent the harness falls back to `AGENT_DEFAULT`.
/// See `docs/process/conventions.md::Tool groups`. /// 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")] #[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum ToolGroup { pub enum ToolGroup {
/// `send`, `recv`, `ack_until` /// `send`, `recv`, `ack_until`
Messaging, Messaging,
@ -154,21 +155,12 @@ impl ToolGroup {
]; ];
/// The `snake_case` wire name for this group (matches `serde(rename_all = /// 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] #[must_use]
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { self.into()
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. /// 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 /// `snake_case`) via `meta::render_flake`. The harness reads this to
/// conditionally register capability-gated MCP tools so claude only /// conditionally register capability-gated MCP tools so claude only
/// sees tools it can actually invoke. See `docs/process/conventions.md::Capabilities`. /// 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")] #[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum Capability { pub enum Capability {
/// Agent can lifecycle-manage the root agent (kill/start/restart) /// Agent can lifecycle-manage the root agent (kill/start/restart)
/// on behalf of the hive when the root has crashed. Named capability /// on behalf of the hive when the root has crashed. Named capability
@ -247,14 +240,12 @@ impl Capability {
Self::QueryAgentState, 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] #[must_use]
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { self.into()
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. /// Short human-readable description suitable for a tooltip or help text.

View file

@ -74,6 +74,7 @@ hive-types.workspace = true
reqwest = { workspace = true, features = ["blocking"] } reqwest = { workspace = true, features = ["blocking"] }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
strum.workspace = true
swarm-authelia-bridge-sock.workspace = true swarm-authelia-bridge-sock.workspace = true
# The queue connect (token mint + auth callback + reconnect) is shared with # The queue connect (token mint + auth callback + reconnect) is shared with
# every other participant - a hive publishing its own status runs the same # every other participant - a hive publishing its own status runs the same

View file

@ -167,11 +167,13 @@ fn hex_decode(s: &str) -> Option<Vec<u8>> {
/// registration we made* instead of by a field the sender chooses. Each /// 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 /// registered hook gets its own `target_url`, exactly as the per-hive hooks
/// do today. /// do today.
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq, strum::IntoStaticStr, strum::EnumString)]
pub(super) enum DeliveryKind { pub(super) enum DeliveryKind {
/// Push events on the hive-wide knowledge repo. /// Push events on the hive-wide knowledge repo.
#[strum(serialize = "knowledge")]
Knowledge, Knowledge,
/// `pull_request` events on the agent-config repos. /// `pull_request` events on the agent-config repos.
#[strum(serialize = "config-pr")]
ConfigPr, ConfigPr,
/// `push` events on every repo in the instance — see /// `push` events on every repo in the instance — see
/// `crate::vcs_metrics`'s doc comment for why this exists as its own /// `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 /// hook: that one is repo-scoped to the knowledge repo alone, and this
/// one is instance-wide (registered via `admin_create_hook`, not /// one is instance-wide (registered via `admin_create_hook`, not
/// `repo_create_hook`) — different scope, same event name. /// `repo_create_hook`) — different scope, same event name.
#[strum(serialize = "vcs-activity")]
VcsActivity, VcsActivity,
} }
@ -219,14 +222,12 @@ impl DeliveryKind {
/// Parse the `{kind}` path segment. Unknown values are rejected rather /// Parse the `{kind}` path segment. Unknown values are rejected rather
/// than accepted-and-ignored: a typo in a registered `target_url` must /// than accepted-and-ignored: a typo in a registered `target_url` must
/// be *observable*, and a 200 for an unrecognised path is exactly the /// 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<Self> { fn parse(segment: &str) -> Option<Self> {
match segment { segment.parse().ok()
"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 /// 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 /// addressed to the hives that need it; which hook a delivery arrived on
/// is an input to deriving it, not the thing sent. /// is an input to deriving it, not the thing sent.
fn as_str(self) -> &'static str { fn as_str(self) -> &'static str {
match self { self.into()
Self::Knowledge => "knowledge",
Self::ConfigPr => "config-pr",
Self::VcsActivity => "vcs-activity",
}
} }
} }

View file

@ -47,6 +47,7 @@ async-nats.workspace = true
reqwest = { workspace = true, features = ["blocking"] } reqwest = { workspace = true, features = ["blocking"] }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
strum.workspace = true
# A library, so its errors are a matchable enum rather than an opaque # A library, so its errors are a matchable enum rather than an opaque
# `anyhow::Error`. The binaries that consume this keep anyhow; `?` converts. # `anyhow::Error`. The binaries that consume this keep anyhow; `?` converts.
thiserror.workspace = true thiserror.workspace = true

View file

@ -84,8 +84,11 @@ pub struct AgentWanted {
/// than part of a declaration it only half understands. Adding a state means /// 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 /// 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. /// 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")] #[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum AgentState { pub enum AgentState {
/// Exists on the hive and is running. /// Exists on the hive and is running.
Up, Up,
@ -112,16 +115,13 @@ pub enum AgentState {
impl AgentState { impl AgentState {
/// The wire spelling, for a reader that renders rather than decodes. /// The wire spelling, for a reader that renders rather than decodes.
/// ///
/// Kept beside the enum so it cannot drift from the `rename_all` above; /// Derived (`#[strum(serialize_all = "snake_case")]`) from the same
/// a test pins the two together. /// 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] #[must_use]
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { self.into()
AgentState::Up => "up",
AgentState::Offline => "offline",
AgentState::Paused => "paused",
AgentState::Destroyed => "destroyed",
}
} }
} }