From d124dd205ac063ad08bf4b2519c76aafd0497cab Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 17 Jul 2026 12:31:08 +0200 Subject: [PATCH] feat(#2554): add hivectl forge reconcile-config to reconcile local applied config against forge main --- docs/tools/hivectl-cli.md | 28 ++++++ docs/tools/hivectl.md | 10 ++ hive-c0re/src/forge/mod.rs | 2 + hive-c0re/src/forge/reconcile.rs | 157 +++++++++++++++++++++++++++++++ hive-c0re/src/server.rs | 6 ++ hive-host-sock/src/lib.rs | 33 +++++++ hivectl/src/cli.rs | 35 +++++++ hivectl/src/forge.rs | 58 ++++++++++++ hivectl/src/main.rs | 7 +- 9 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 hive-c0re/src/forge/reconcile.rs diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index ac5cbc80..93829e90 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -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 `` +* `reconcile-config` — Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/` 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/` 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] ` + +###### **Arguments:** + +* `` — Agent whose config branches to reconcile + +###### **Options:** + +* `--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. diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 7a5bd643..55117dea 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -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 ` shows the divergence between the agent's local + applied config checkout and its forge `agent-configs/` `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 diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index f18fca49..42afee1f 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -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, diff --git a/hive-c0re/src/forge/reconcile.rs b/hive-c0re/src/forge/reconcile.rs new file mode 100644 index 00000000..23ca5b96 --- /dev/null +++ b/hive-c0re/src/forge/reconcile.rs @@ -0,0 +1,157 @@ +//! Reconcile an agent's local applied config checkout against its forge +//! `agent-configs/` `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/` `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 { + 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 { + 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, 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 { + 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 { + 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"), + ])) + } + } +} diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 4b3c716e..cc6053a1 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -196,6 +196,12 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> 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)?]) } diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 0c8b6340..7bd71ddf 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -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, }, + /// Report the divergence between agent `agent`'s local applied config + /// checkout and its forge `agent-configs/` `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 `. + 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 diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index 7d87aa13..22644438 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -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/` 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, + /// 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 for hive_host_sock::ReconcileDirection { + fn from(from: ReconcileFrom) -> Self { + match from { + ReconcileFrom::Forge => Self::Forge, + ReconcileFrom::Local => Self::Local, + } + } } #[derive(Subcommand)] diff --git a/hivectl/src/forge.rs b/hivectl/src/forge.rs index e16b82a0..9f8128fe 100644 --- a/hivectl/src/forge.rs +++ b/hivectl/src/forge.rs @@ -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 [--from ] [--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, + 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> { + 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) + } + } +} diff --git a/hivectl/src/main.rs b/hivectl/src/main.rs index b23bf3b4..1bfea697 100644 --- a/hivectl/src/main.rs +++ b/hivectl/src/main.rs @@ -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 {