Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-c0re/src/forge/reconcile.rs
atlas 21e3f626f4 hives read the forge through their own swarm-minted read account
swarm-controller now gives every hive in its directory a forge account,
`hive-<hive>`, in a new `hives` team of `agent-configs` (read on
`repo.code` + `repo.pulls`, every repo including later ones), and mints
one `read:repository` token named `swarm-hive` into the store at
`swarm/hives/<hive>/forge/token`. A five-minute pass queues a
`MintHiveForgeToken` node for a hive whose token is missing or no longer
matches the forge, whose account is missing, or whose team membership or
team settings drifted. A hive whose store or forge read failed is never
queued, so an outage cannot rotate every hive's token. The token decision
reuses `agent_token`'s classifier and replace step, now parameterised by
name and scopes.

An agent may be called `hive-<x>`. The hive account is created with
`hive-<hive>@hives.hyperhive.local` and the job refuses an account by that
name with any other email before it locks it down, adds it to the team or
mints for it; agent creation already refuses the hive's account by the
same email check.

The controller's bao policy gains the `swarm/hives/+/forge/token` leaf
(create/read/update), pinned by the bao-grants module-eval assertion. The
hive's own policy already reads `swarm/hives/<hive>/*`.

hive-c0re reads that token through `forge::reader_token()`, falling back
to the core token file when the store holds none, and uses it for:
fetching a merged config rev (swarm deploy, `hivectl forge
reconcile-config`), reseeding a purged `applied`, the meta-update
cascade's fast-forward, seeding `proposed` on first deploy, and finding
the PR that merged a refused rev. Those reads no longer require a local
`hive-forge` container. The `/knowledge` clone moves from the forge
host's boot sweep into the knowledge pull, so every hive clones it.

Posting the deploy-failure comment on that PR stays on the core token.

Refs #4851
2026-10-07 20:52:32 +02:00

159 lines
6 KiB
Rust

//! Reconcile an agent's local applied config checkout against its forge
//! `agent-configs/<agent>` `main`. Backs `hivectl forge reconcile-config`
//! via the host-socket `ReconcileConfig{Status,Apply}` requests. hive-c0re
//! owns the applied checkout + the forge credential, so the reconcile op
//! lives here rather than in the CLI.
//!
//! `Status` is read-only (fetches forge `main` into a scratch ref, reports
//! the divergence). `Apply(Forge)` resets the local applied checkout to
//! forge `main` — the change takes effect on the next deploy (the deploy's
//! `--override-input` re-locks against the reset local tree); it does NOT
//! auto-deploy. `Apply(Local)` is not supported: forge `main` is
//! branch-protected, so it only advances through a config-PR merge.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use hive_host_sock::{HostResponse, ReconcileDirection};
use super::{CONFIG_ORG, forge_git_url, reader_token};
/// Scratch ref the forge `main` is fetched into — outside the normal
/// branch/tag namespace so it never collides with real refs.
const FORGE_MAIN_REF: &str = "refs/hyperhive/forge-config-main";
/// Fetch `agent-configs/<agent>` `main` into the applied repo's scratch
/// ref (read-only, no working-tree change) and return the applied dir.
/// Also how a swarm deploy of a merged commit gets that commit's objects.
pub async fn fetch_forge_main(agent: &str) -> Result<PathBuf> {
let Some(reader) = reader_token().await else {
anyhow::bail!(
"no forge read token: the swarm store holds none for this hive and there is no core \
token file"
);
};
let dir = crate::paths::applied_dir(agent);
if !dir.join(".git").exists() {
anyhow::bail!("agent `{agent}` has no applied config checkout");
}
let url = forge_git_url(&format!("{CONFIG_ORG}/{agent}"));
crate::lifecycle::git_authed(
&dir,
&["fetch", "--force", &url, &format!("main:{FORGE_MAIN_REF}")],
&reader.auth_header(),
)
.await
.context("fetch forge config main (does forge main exist yet?)")?;
Ok(dir)
}
/// Run a git command in `dir` and capture stdout, erroring on non-zero.
async fn git_out(dir: &Path, args: &[&str]) -> Result<String> {
let out = crate::lifecycle::git_command()
.current_dir(dir)
.args(args)
.output()
.await
.with_context(|| format!("invoke git {args:?}"))?;
if !out.status.success() {
anyhow::bail!(
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Append `lines` (from a git text block) to `messages`, each indented.
fn push_indented(messages: &mut Vec<String>, block: &str) {
messages.extend(block.lines().map(|l| format!(" {l}")));
}
/// Report the divergence between the local applied checkout and forge
/// `main` (host request `ReconcileConfigStatus`). Read-only.
pub async fn reconcile_config_status(agent: &str, verbose: bool) -> Result<HostResponse> {
let dir = fetch_forge_main(agent).await?;
let counts = git_out(
&dir,
&[
"rev-list",
"--left-right",
"--count",
&format!("HEAD...{FORGE_MAIN_REF}"),
],
)
.await?;
let mut parts = counts.split_whitespace();
let local_ahead = parts.next().unwrap_or("0");
let forge_ahead = parts.next().unwrap_or("0");
let mut messages = vec![
format!("agent {agent}: local applied main <-> forge agent-configs/{agent} main"),
format!(" local ahead by {local_ahead}, forge ahead by {forge_ahead} commit(s)"),
];
if local_ahead == "0" && forge_ahead == "0" {
messages.push(" in sync — nothing to reconcile".to_owned());
return Ok(HostResponse::messages(messages));
}
if forge_ahead != "0" {
messages.push(" forge-only commits (applied by --from forge):".to_owned());
push_indented(
&mut messages,
&git_out(
&dir,
&["log", "--oneline", &format!("HEAD..{FORGE_MAIN_REF}")],
)
.await?,
);
}
if local_ahead != "0" {
messages.push(" local-only commits (discarded by --from forge):".to_owned());
push_indented(
&mut messages,
&git_out(
&dir,
&["log", "--oneline", &format!("{FORGE_MAIN_REF}..HEAD")],
)
.await?,
);
}
messages.push(" diff --stat (local HEAD -> forge main):".to_owned());
push_indented(
&mut messages,
&git_out(&dir, &["diff", "--stat", "HEAD", FORGE_MAIN_REF]).await?,
);
if verbose {
messages.push(" full diff:".to_owned());
push_indented(
&mut messages,
&git_out(&dir, &["diff", "HEAD", FORGE_MAIN_REF]).await?,
);
}
Ok(HostResponse::messages(messages))
}
/// Apply a reconcile in `direction` (host request `ReconcileConfigApply`).
/// `Forge` resets the local applied checkout to forge `main`; `Local` is
/// unsupported and returns a clear error.
pub async fn reconcile_config_apply(
agent: &str,
direction: ReconcileDirection,
) -> Result<HostResponse> {
match direction {
ReconcileDirection::Local => Ok(HostResponse::error(
"reconciling forge from local is not supported yet: forge main is \
branch-protected. Resolve via a config PR, or use \
`--from forge` to reset the local checkout to forge main.",
)),
ReconcileDirection::Forge => {
let dir = fetch_forge_main(agent).await?;
crate::lifecycle::git(&dir, &["reset", "--hard", FORGE_MAIN_REF])
.await
.context("reset applied main to forge main")?;
Ok(HostResponse::messages(vec![
format!("reconciled applied/{agent} to forge agent-configs/{agent} main"),
format!("effective on next deploy — rebuild {agent} to apply now"),
]))
}
}
}