feat(#2554): add hivectl forge reconcile-config to reconcile local applied config against forge main

This commit is contained in:
damocles 2026-07-17 12:31:08 +02:00 committed by mara
commit d124dd205a
9 changed files with 335 additions and 1 deletions

View file

@ -7,6 +7,7 @@ This document contains the help content for the `hivectl` command-line program.
* [`hivectl`↴](#hivectl)
* [`hivectl forge`↴](#hivectl-forge)
* [`hivectl forge create-user`↴](#hivectl-forge-create-user)
* [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config)
* [`hivectl matrix`↴](#hivectl-matrix)
* [`hivectl matrix create-user`↴](#hivectl-matrix-create-user)
* [`hivectl matrix sync-admin`↴](#hivectl-matrix-sync-admin)
@ -99,6 +100,7 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for
###### **Subcommands:**
* `create-user` — Create or refresh the Forgejo account + token for `<name>`
* `reconcile-config` — Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/<agent>` main
@ -121,6 +123,32 @@ For an existing agent, persists the token to its state dir; for a human/other ac
## `hivectl forge reconcile-config`
Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/<agent>` main.
Always prints the diff first. `--from forge` resets the local checkout to forge main (effective on the next deploy); `--from local` is not supported yet. With no `--from`, prompts for the direction.
**Usage:** `hivectl forge reconcile-config [OPTIONS] <AGENT>`
###### **Arguments:**
* `<AGENT>` — Agent whose config branches to reconcile
###### **Options:**
* `--from <FROM>` — Which side to reconcile from. Omit to be prompted after the diff
Possible values:
- `forge`:
Reset the local applied checkout to forge main
- `local`:
Advance forge main from local — not supported yet
* `--verbose` — Include the full diff (not just `--stat`) in the report
## `hivectl matrix`
matrix-tuwunel user provisioning.

View file

@ -28,6 +28,10 @@ hivectl forge create-user iris # provision (or refresh) forge account f
hivectl forge create-user mara # create forge account for a human user; prints token to stdout
hivectl forge create-user mara --password hunter2 # set a web-login password
hivectl forge create-user mara --password-stdin # read password from stdin (safer for scripting)
hivectl forge reconcile-config iris # show local-applied <-> forge config divergence, then prompt
hivectl forge reconcile-config iris --from forge # reset local applied checkout to forge main (effective next deploy)
hivectl forge reconcile-config iris --verbose # include the full diff, not just --stat
```
- For **agents** (name has a state dir under `/var/lib/hyperhive/agents/`):
@ -38,6 +42,12 @@ hivectl forge create-user mara --password-stdin # read password from stdin (s
re-mints the token and prints it again — safe for password resets.
- Without `--password` / `--password-stdin` a random throwaway password
is used (fine for agents — they auth by token).
- `reconcile-config <agent>` shows the divergence between the agent's local
applied config checkout and its forge `agent-configs/<agent>` `main`, then
reconciles. `--from forge` resets the local checkout to forge `main` (takes
effect on the next deploy — it does not auto-rebuild). `--from local` is not
supported yet (forge `main` is core-only branch-protected; resolve via a
config PR). With no `--from` it prompts for the direction after the diff.
## Matrix

View file

@ -7,6 +7,7 @@
mod ci_runner;
pub mod config_pr_poll;
mod pr_merge;
mod reconcile;
mod repos;
mod users;
@ -14,6 +15,7 @@ pub use pr_merge::{
ForgeMergeError, config_repo, fetch_pr_head_into_applied, merge_config_pr_ff, post_pr_comment,
pr_head_sha, pr_is_open,
};
pub use reconcile::{reconcile_config_apply, reconcile_config_status};
pub use repos::{
create_agent_repo, ensure_config_repo, ensure_knowledge_repo, ensure_meta_remote, ensure_repo,
ensure_shared_docs_repo, meta_read_access, push_config, push_meta, shared_docs_access,

View file

@ -0,0 +1,157 @@
//! 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 core-only
//! branch-protected (advanced solely by the config-PR ff-merge API).
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use hive_host_sock::{HostResponse, ReconcileDirection};
use super::{CONFIG_ORG, core_token, forge_git_url, is_present};
/// 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.
async fn fetch_forge_main(agent: &str) -> Result<PathBuf> {
if !is_present().await {
anyhow::bail!("forge is not running");
}
let Some(token) = core_token() else {
anyhow::bail!("forge core token not available");
};
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(&token, &format!("{CONFIG_ORG}/{agent}"));
crate::lifecycle::git(
&dir,
&["fetch", "--force", &url, &format!("main:{FORGE_MAIN_REF}")],
)
.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 core-only \
branch-protected (no-push, ff-merge-API-only). 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"),
]))
}
}
}

View file

@ -196,6 +196,12 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
HostRequest::ForgeCreateUser { name, password } => {
handle_forge_create_user(name, password.as_deref()).await?
}
HostRequest::ReconcileConfigStatus { agent, verbose } => {
crate::forge::reconcile_config_status(agent, *verbose).await?
}
HostRequest::ReconcileConfigApply { agent, direction } => {
crate::forge::reconcile_config_apply(agent, *direction).await?
}
HostRequest::GatewayCreateUser { username, password } => {
HostResponse::messages(vec![crate::gateway_nginx::create_user(username, password)?])
}

View file

@ -51,6 +51,17 @@ pub fn container_name(name: &str) -> String {
format!("{AGENT_PREFIX}{name}")
}
/// Which way to reconcile an agent's config branches
/// ([`HostRequest::ReconcileConfigApply`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ReconcileDirection {
/// Reset the local applied checkout to forge `main`.
Forge,
/// Advance forge `main` from local — not supported yet.
Local,
}
/// Requests on the host admin socket.
///
/// Wire format: one JSON object per line.
@ -200,6 +211,28 @@ pub enum HostRequest {
#[serde(default)]
password: Option<String>,
},
/// Report the divergence between agent `agent`'s local applied config
/// checkout and its forge `agent-configs/<agent>` `main`. The daemon
/// fetches forge `main` read-only and returns a human-readable report
/// (ahead/behind counts, commit-range summary, `git diff --stat`, and
/// the full diff when `verbose`) in [`HostResponse::messages`]. Read-only
/// — never mutates either side. Backs `hivectl forge reconcile-config`
/// (the diff it always shows first).
ReconcileConfigStatus {
agent: String,
#[serde(default)]
verbose: bool,
},
/// Reconcile agent `agent`'s config branches in `direction`.
/// `Forge` resets the local applied checkout to forge `main` (takes
/// effect on the next deploy); `Local` is not supported yet and returns
/// an [`HostResponse::error`] (advancing the protected forge `main` from
/// local needs lifting branch protection — resolve via a config PR).
/// Backs `hivectl forge reconcile-config --from <forge|local>`.
ReconcileConfigApply {
agent: String,
direction: ReconcileDirection,
},
/// Add or update a gateway HTTP-Basic user in the daemon's htpasswd file
/// (`paths::GATEWAY_HTPASSWD`). Daemon-side equivalent of `hivectl gateway
/// create-user`: the daemon bcrypt-hashes `password` (cost 12, remapped to

View file

@ -281,6 +281,41 @@ pub enum ForgeCmd {
#[arg(long, conflicts_with = "password")]
password_stdin: bool,
},
/// Show + reconcile the divergence between an agent's local applied
/// config checkout and its forge `agent-configs/<agent>` main.
///
/// Always prints the diff first. `--from forge` resets the local
/// checkout to forge main (effective on the next deploy); `--from local`
/// is not supported yet. With no `--from`, prompts for the direction.
ReconcileConfig {
/// Agent whose config branches to reconcile.
agent: String,
/// Which side to reconcile from. Omit to be prompted after the diff.
#[arg(long, value_enum)]
from: Option<ReconcileFrom>,
/// Include the full diff (not just `--stat`) in the report.
#[arg(long)]
verbose: bool,
},
}
/// Which side to reconcile config branches from (`hivectl forge
/// reconcile-config --from`). Maps to [`hive_host_sock::ReconcileDirection`].
#[derive(Clone, Copy, clap::ValueEnum)]
pub enum ReconcileFrom {
/// Reset the local applied checkout to forge main.
Forge,
/// Advance forge main from local — not supported yet.
Local,
}
impl From<ReconcileFrom> for hive_host_sock::ReconcileDirection {
fn from(from: ReconcileFrom) -> Self {
match from {
ReconcileFrom::Forge => Self::Forge,
ReconcileFrom::Local => Self::Local,
}
}
}
#[derive(Subcommand)]

View file

@ -2,10 +2,13 @@
//! daemon (which owns the forge admin token + account-token persistence);
//! hivectl just resolves the password client-side and relays the request.
use std::io::{self, Write};
use std::path::Path;
use anyhow::Result;
use hive_host_sock::{HostRequest, ReconcileDirection};
use crate::cli::ReconcileFrom;
use crate::util::{daemon_request, resolve_password};
pub(crate) async fn forge_create_user(
@ -29,3 +32,58 @@ pub(crate) async fn forge_create_user(
)
.await
}
/// `hivectl forge reconcile-config <agent> [--from <forge|local>] [--verbose]`.
/// Always shows the divergence first (daemon computes it read-only), then
/// applies the chosen direction — from `--from` or an interactive prompt.
pub(crate) async fn forge_reconcile_config(
socket: &Path,
agent: &str,
from: Option<ReconcileFrom>,
verbose: bool,
) -> Result<()> {
daemon_request(
socket,
HostRequest::ReconcileConfigStatus {
agent: agent.to_owned(),
verbose,
},
"forge",
)
.await?;
let direction = match from {
Some(f) => Some(f.into()),
None => prompt_direction()?,
};
let Some(direction) = direction else {
println!("forge: aborted — no changes made");
return Ok(());
};
daemon_request(
socket,
HostRequest::ReconcileConfigApply {
agent: agent.to_owned(),
direction,
},
"forge",
)
.await
}
/// Prompt the operator for the reconcile direction after showing the diff.
/// Returns `None` to abort (empty / `q` / unrecognized input).
fn prompt_direction() -> Result<Option<ReconcileDirection>> {
print!("reconcile from [forge/local] (or q to abort): ");
io::stdout().flush()?;
let mut line = String::new();
io::stdin().read_line(&mut line)?;
match line.trim().to_ascii_lowercase().as_str() {
"forge" | "f" => Ok(Some(ReconcileDirection::Forge)),
"local" | "l" => Ok(Some(ReconcileDirection::Local)),
"" | "q" | "quit" => Ok(None),
other => {
println!("forge: unrecognized direction {other:?} — aborting");
Ok(None)
}
}
}

View file

@ -42,7 +42,7 @@ use choom::choom;
mod github;
use github::github_set_token;
mod forge;
use forge::forge_create_user;
use forge::{forge_create_user, forge_reconcile_config};
mod agents;
use agents::run_agents;
mod power;
@ -69,6 +69,11 @@ async fn main() -> Result<()> {
password,
password_stdin,
} => forge_create_user(&socket, &name, password.as_deref(), password_stdin).await,
ForgeCmd::ReconcileConfig {
agent,
from,
verbose,
} => forge_reconcile_config(&socket, &agent, from, verbose).await,
},
Cmd::Matrix { cmd } => run_matrix_cmd(&socket, cmd).await,
Cmd::Github { cmd } => match cmd {