Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller grants internal/docs read to agents; hives stop doing it

Agent creation gains a GrantDocsRead node (after CreateForgeUser) that adds
the agent as a read collaborator on internal/docs, and forge::docs_grant
runs a pass over the store's hive-agent-* roster at start and every five
minutes that queues the same job for any agent that is not one. The pass only
adds; an unreadable roster or forge read changes nothing.

hive-c0re drops shared_docs_access, its call in sync_agent, and the
SHARED_ORG / SHARED_DOCS_REPO constants only it used.
This commit is contained in:
atlas 2026-10-07 21:02:45 +02:00
commit 1ca758daa7
7 changed files with 643 additions and 59 deletions

View file

@ -96,6 +96,11 @@ enum SwarmNodeKind {
/// Add `agent` as a write collaborator on its own repo. See
/// `forge::Client::add_repo_member`.
AddRepoMember { agent: String },
/// Add `agent` as a read collaborator on `internal/docs`. See
/// `forge::docs_grant`.
///
/// Carries no hive: the grant is on a swarm-wide repo.
GrantDocsRead { agent: String },
/// Seed the agent's repo with `agent.nix` + `flake.nix`. See
/// `forge::Client::seed_agent_config`.
///
@ -171,6 +176,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
SwarmNodeKind::CreateRepo { .. } => "create_repo".to_owned(),
SwarmNodeKind::CreateForgeUser { .. } => "create_forge_user".to_owned(),
SwarmNodeKind::AddRepoMember { .. } => "add_repo_member".to_owned(),
SwarmNodeKind::GrantDocsRead { .. } => "grant_docs_read".to_owned(),
SwarmNodeKind::InitAgentConfigRepo { .. } => "init_agent_config_repo".to_owned(),
SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(),
SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(),
@ -197,6 +203,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
| SwarmNodeKind::CreateRepo { agent }
| SwarmNodeKind::CreateForgeUser { agent }
| SwarmNodeKind::AddRepoMember { agent }
| SwarmNodeKind::GrantDocsRead { agent }
| SwarmNodeKind::InitAgentConfigRepo { agent }
| SwarmNodeKind::MintAgentIdentity { agent }
| SwarmNodeKind::MintAgentForgeToken { agent }
@ -340,6 +347,7 @@ async fn run_swarm_node(
Err(e) => Outcome::Failed(format!("{e:#}")),
},
},
SwarmNodeKind::GrantDocsRead { agent } => grant_docs_read(deps.forge, &agent).await,
SwarmNodeKind::InitAgentConfigRepo { agent } => match deps.forge {
None => Outcome::Failed(
"no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \
@ -414,6 +422,26 @@ async fn mint_forge_token(
}
}
/// The `GrantDocsRead` arm, lifted out for the same reason.
async fn grant_docs_read(
forge: Option<Arc<forge::Client>>,
agent: &str,
) -> hive_jobq::scheduler::Outcome {
use hive_jobq::scheduler::Outcome;
let Some(client) = forge else {
return Outcome::Failed(
"no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \
SWARM_CONTROLLER_FORGE_TOKEN_FILE unset), so no docs grant can be made"
.to_owned(),
);
};
match client.grant_docs_read(agent).await {
Ok(()) => Outcome::Done,
Err(e) => Outcome::Failed(format!("{e:#}")),
}
}
/// The `RenewAgentQueueCredential` arm, lifted out for the same reason.
async fn renew_queue_credential(agent: &str) -> hive_jobq::scheduler::Outcome {
use hive_jobq::scheduler::Outcome;
@ -1980,6 +2008,14 @@ fn declare_agent_job(
})
.after_ok(create_repo)
.after_ok(create_forge_user);
// Read on `internal/docs`, a repo that exists whatever this agent is, so
// it needs only the user. Not a parent of the deploy: the agent reads docs
// once running, and a failed grant is retried by `forge::docs_grant::spawn`.
let _grant_docs = b
.node(SwarmNodeKind::GrantDocsRead {
agent: agent.to_owned(),
})
.after_ok(create_forge_user);
let init_config = b
.node(SwarmNodeKind::InitAgentConfigRepo {
agent: agent.to_owned(),
@ -2266,6 +2302,37 @@ fn queue_forge_token_mints(
Ok(ids)
}
/// Insert one job per agent: `CreateForgeUser`, then `GrantDocsRead` `after_ok`
/// it — the same edge [`declare_agent_job`] uses. Returns the grant nodes' ids.
/// `forge::docs_grant::spawn`'s periodic pass comes through here, so a
/// backfilled grant is the same node a new agent gets; the user node is a
/// no-op for an agent that has an account.
fn queue_docs_grants(
sched: &Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>,
agents: Vec<String>,
) -> Result<Vec<hive_jobq::NodeId>> {
let mut sched = sched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut ids = Vec::with_capacity(agents.len());
for agent in agents {
let queued = sched
.insert_job(None, |b| {
let create_forge_user = b.node(SwarmNodeKind::CreateForgeUser {
agent: agent.clone(),
});
vec![
b.node(SwarmNodeKind::GrantDocsRead { agent })
.after_ok(create_forge_user)
.guid(),
]
})
.map_err(|e| anyhow::anyhow!("{e}"))?;
ids.extend(queued);
}
Ok(ids)
}
/// Insert one `MintAgentMatrixAccount` job per agent and return the nodes'
/// ids. `matrix_account::agent_token::spawn`'s periodic pass comes through
/// here, so a backfilled mint is the same node a new agent gets.
@ -2373,7 +2440,8 @@ fn spawn_matrix_account_backfill(
}
/// Start the forge's periodic passes — the swarm-wide objects, the runner
/// registration token, hives' read tokens, and agents' tokens (which also
/// registration token, hives' read tokens, agents' `internal/docs` grants, and
/// agents' tokens (which also
/// sweeps legacy tokens; see `forge::legacy_tokens`) —
/// when a forge is configured. Lifted out of `main` for
/// `clippy::too_many_lines`.
@ -2395,6 +2463,12 @@ fn spawn_forge_workers(
}
});
let sched = Arc::clone(jobq);
forge::docs_grant::spawn(Arc::clone(&client), move |agents| {
if let Err(e) = queue_docs_grants(&sched, agents) {
tracing::warn!(error = %format!("{e:#}"), "docs grants: queueing failed");
}
});
let sched = Arc::clone(jobq);
forge::agent_token::spawn(client, move |agents| {
if let Err(e) = queue_forge_token_mints(&sched, agents) {
tracing::warn!(error = %format!("{e:#}"), "agent forge tokens: queueing failed");
@ -4529,6 +4603,103 @@ mod tests {
);
}
/// Agent creation grants `internal/docs` read once the forge user exists,
/// and a failed grant does not hold up the deploy (the pass retries it).
#[tokio::test]
async fn the_docs_grant_follows_the_user_and_is_not_a_deploy_parent() {
use hive_jobq_wire::WireNode as _;
let (state, sched) = state_with_roster();
let _queued = super::create_agent(
axum::extract::State(state),
axum::Json(super::CreateAgentRequest {
name: "atlas".to_owned(),
hive: "pr1ma".to_owned(),
}),
)
.await
.expect("a hive in the roster must be accepted");
let guard = sched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let graph = guard.graph();
let id_of = |label: &str| {
graph
.nodes()
.find(|n| n.payload.label() == label)
.unwrap_or_else(|| panic!("the graph holds a {label} node"))
.id
};
let grant = graph.node(id_of("grant_docs_read")).expect("just found");
assert_eq!(grant.payload.data(grant.id.get())["agent"], "atlas");
let when = grant
.deps
.iter()
.find_map(|d| match d {
hive_jobq::Dep::Node { id, when } if *id == id_of("create_forge_user") => {
Some(*when)
}
_ => None,
})
.expect("the grant waits for the forge user");
assert!(
!when.accepts(hive_jobq::TerminalState::Failed),
"adding a user that was never created as a collaborator is a forge error; \
this edge has to be `after_ok`"
);
let deploy = graph.node(id_of("trigger_deploy")).expect("just found");
assert!(
!deploy.deps.iter().any(|d| matches!(
d,
hive_jobq::Dep::Node { id, .. } if *id == grant.id
)),
"the deploy must not wait on the docs grant"
);
}
/// The periodic pass queues the same pair of nodes agent creation does.
#[test]
fn a_queued_docs_grant_creates_the_forge_user_first() {
use hive_jobq_wire::WireNode as _;
let sched = std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
));
let ids = super::queue_docs_grants(&sched, vec!["a".to_owned(), "b".to_owned()])
.expect("two jobs insert");
assert_eq!(ids.len(), 2, "one returned id per agent: its grant node");
let guard = sched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let graph = guard.graph();
let mut labels: Vec<String> = graph.nodes().map(|n| n.payload.label()).collect();
labels.sort();
assert_eq!(
labels,
[
"create_forge_user",
"create_forge_user",
"grant_docs_read",
"grant_docs_read"
]
);
for grant in graph
.nodes()
.filter(|n| n.payload.label() == "grant_docs_read")
{
assert!(ids.contains(&grant.id));
assert!(
grant
.deps
.iter()
.any(|d| matches!(d, hive_jobq::Dep::Node { when, .. } if !when.accepts(hive_jobq::TerminalState::Failed))),
"each grant waits `after_ok` on its user"
);
}
}
#[test]
fn a_forge_token_node_renders_the_agent() {
use hive_jobq_wire::WireNode as _;