refactor(#3434): the job nodes carry the agent, not the repo too

Every repo in this graph is `agents/<agent>` -- the node payloads were
carrying the same string under two names, and `create_agent` opened with
a `let repo = agent.clone()` that said so out loud.

All four node kinds now carry `agent` alone, and `forge::agent_repo` is
the single home for the naming convention. The identity it returns is the
point: a caller holding an agent name never writes a repo name itself, so
changing the convention later is one edit rather than a search.

`forge::Client`'s methods keep taking a repo, because they are a general
forge client and `add_repo_member(repo, user)` is a real signature -- the
derivation belongs at the call site that knows the two are the same here,
not baked into an API that has no reason to assume it.

`data()`'s four arms are now identical and merged into one or-pattern.
Left as an explicit list rather than a catch-all so a fifth variant fails
to compile here instead of silently rendering as an agent name.
This commit is contained in:
atlas 2026-08-18 17:34:50 +02:00 committed by mara
commit 4d526b8492
2 changed files with 45 additions and 29 deletions

View file

@ -33,6 +33,18 @@ use crate::webhook::DeliveryKind;
/// the same forge instance, not a separate one, so the same org.
pub const AGENTS_ORG: &str = "agents";
/// The repo an agent's config lives in — one repo per agent inside
/// [`AGENTS_ORG`], named after the agent.
///
/// The identity here is the point, not an accident to be inlined: this is
/// the single home for the naming convention, so a caller holding an agent
/// name never writes the repo name itself. Callers used to pass both, which
/// meant every node payload carried the same string twice and any future
/// change to the convention would have been a search rather than an edit.
pub fn agent_repo(agent: &str) -> &str {
agent
}
/// The `operators` team, whitelisted for the merge gate on every repo
/// this client protects — provisioned by `hive-c0re::forge::repos`
/// already (`ensure_operators_team`), not re-provisioned here. If that

View file

@ -56,13 +56,13 @@ mod webhook;
enum SwarmNodeKind {
/// Ensure `agent` exists as an authelia subject at the swarm level.
CreateIdentity { agent: String },
/// Create `repo` in `forge::AGENTS_ORG` with the operator merge gate
/// on its default branch. See `forge::Client::create_repo`.
CreateRepo { repo: String },
/// Add `agent` as a write collaborator on `repo`. See
/// Create the agent's repo in `forge::AGENTS_ORG` with the operator
/// merge gate on its default branch. See `forge::Client::create_repo`.
CreateRepo { agent: String },
/// Add `agent` as a write collaborator on its own repo. See
/// `forge::Client::add_repo_member`.
AddRepoMember { repo: String, agent: String },
/// Seed `repo` with `agent.nix` + `flake.nix`. See
AddRepoMember { agent: String },
/// Seed the agent's repo with `agent.nix` + `flake.nix`. See
/// `forge::Client::seed_agent_config`.
///
/// Deliberately carries no hive: seeding a config repo is the same
@ -70,7 +70,7 @@ enum SwarmNodeKind {
/// states nothing about where it runs. The hive is an address the
/// swarm routes a deploy message to — it belongs on the node that
/// sends that message, not on this one.
InitAgentConfigRepo { repo: String, agent: String },
InitAgentConfigRepo { agent: String },
}
impl hive_jobq_wire::WireNode for SwarmNodeKind {
@ -84,20 +84,18 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
}
fn data(&self, _id: hive_jobq_wire::WireId) -> serde_json::Value {
// Every node in this graph is about exactly one agent, so they all
// render the same and `label()` is what distinguishes them. Spelled
// out as an or-pattern rather than a catch-all on purpose: a fifth
// variant then fails to compile here instead of silently rendering
// as an agent name.
match self {
SwarmNodeKind::CreateIdentity { agent } => {
SwarmNodeKind::CreateIdentity { agent }
| SwarmNodeKind::CreateRepo { agent }
| SwarmNodeKind::AddRepoMember { agent }
| SwarmNodeKind::InitAgentConfigRepo { agent } => {
serde_json::json!({ "agent": agent })
}
SwarmNodeKind::CreateRepo { repo } => {
serde_json::json!({ "repo": repo })
}
// Same rendering, and that is not a coincidence to be split
// apart later: both nodes act on one repo for one agent, and
// `label()` is what tells a viewer which of the two it is.
SwarmNodeKind::AddRepoMember { repo, agent }
| SwarmNodeKind::InitAgentConfigRepo { repo, agent } => {
serde_json::json!({ "repo": repo, "agent": agent })
}
}
}
}
@ -151,13 +149,13 @@ async fn run_swarm_node(
Err(e) => Outcome::Failed(format!("{e:#}")),
},
},
SwarmNodeKind::CreateRepo { repo } => match deps.forge {
SwarmNodeKind::CreateRepo { agent } => match deps.forge {
None => Outcome::Failed(
"no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \
SWARM_CONTROLLER_FORGE_TOKEN_FILE unset)"
.to_owned(),
),
Some(client) => match client.create_repo(&repo).await {
Some(client) => match client.create_repo(forge::agent_repo(&agent)).await {
Ok(full_name) => {
tracing::info!(%full_name, "swarm jobq: create_repo done");
Outcome::Done
@ -165,24 +163,30 @@ async fn run_swarm_node(
Err(e) => Outcome::Failed(format!("{e:#}")),
},
},
SwarmNodeKind::AddRepoMember { repo, agent } => match deps.forge {
SwarmNodeKind::AddRepoMember { agent } => match deps.forge {
None => Outcome::Failed(
"no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \
SWARM_CONTROLLER_FORGE_TOKEN_FILE unset)"
.to_owned(),
),
Some(client) => match client.add_repo_member(&repo, &agent).await {
Some(client) => match client
.add_repo_member(forge::agent_repo(&agent), &agent)
.await
{
Ok(()) => Outcome::Done,
Err(e) => Outcome::Failed(format!("{e:#}")),
},
},
SwarmNodeKind::InitAgentConfigRepo { repo, agent } => match deps.forge {
SwarmNodeKind::InitAgentConfigRepo { agent } => match deps.forge {
None => Outcome::Failed(
"no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \
SWARM_CONTROLLER_FORGE_TOKEN_FILE unset)"
.to_owned(),
),
Some(client) => match client.seed_agent_config(&repo, &agent).await {
Some(client) => match client
.seed_agent_config(forge::agent_repo(&agent), &agent)
.await
{
Ok(()) => Outcome::Done,
Err(e) => Outcome::Failed(format!("{e:#}")),
},
@ -639,7 +643,6 @@ async fn create_agent(
let agent = hive_types::Ident::parse(&req.name)
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string();
let repo = agent.clone();
// Two checks, and the second is the one that makes the field worth
// having: `Ident::parse` says the string is *shaped* like a hive name,
@ -678,19 +681,20 @@ async fn create_agent(
agent: agent.clone(),
});
let create_repo = b
.node(SwarmNodeKind::CreateRepo { repo: repo.clone() })
.node(SwarmNodeKind::CreateRepo {
agent: agent.clone(),
})
.after_ok(create_identity);
// Both fan out from `create_repo` directly — independent
// operations on the same repo, no ordering requirement on
// each other (see the doc comment above).
let _add_repo_member = b
.node(SwarmNodeKind::AddRepoMember {
repo: repo.clone(),
agent: agent.clone(),
})
.after_ok(create_repo);
let _init_config = b
.node(SwarmNodeKind::InitAgentConfigRepo { repo, agent })
.node(SwarmNodeKind::InitAgentConfigRepo { agent })
.after_ok(create_repo);
vec![create_identity.guid()]
})
@ -1144,7 +1148,7 @@ mod tests {
let id = sched
.append(
SwarmNodeKind::CreateRepo {
repo: "atlas".to_owned(),
agent: "atlas".to_owned(),
},
Vec::new(),
None,