//! Operations that are exposed through more than one surface (the host admin //! socket *and* the dashboard's POST endpoints). Each function takes a //! `&Coordinator` and the request parameters; callers stitch the response //! shape they want (HTTP redirect vs JSON). use std::sync::Arc; use anyhow::{Context as _, Result, bail}; use crate::coordinator::Coordinator; use crate::lifecycle; /// `applied/main`, and whether `target` descends from it. Fast-forwarding /// `main` to a `target` that does not is a rewind: it drops every commit on /// `main` that `target` lacks. async fn applied_main_under(applied_dir: &std::path::Path, target: &str) -> Result<(String, bool)> { let current_main = lifecycle::git_rev_parse(applied_dir, "refs/heads/main") .await .map_err(|e| anyhow::anyhow!("read applied/main: {e:#}"))?; let descends = lifecycle::git_is_ancestor(applied_dir, ¤t_main, target) .await .map_err(|e| anyhow::anyhow!("ancestry check {current_main}..{target}: {e:#}"))?; Ok((current_main, descends)) } /// What [`advance_applied_to_rev`] did to `applied/main`. #[derive(Debug, PartialEq, Eq)] pub(crate) enum RevAdvance { /// `applied/main` already was the rev, so there is nothing to deploy. AlreadyApplied, /// `applied/main` was fast-forwarded to the rev. Advanced, /// The rev is an ancestor of `applied/main`, so a later commit already /// carries it and there is nothing to deploy. Deploy requests are not /// ordered: two merges' requests can arrive in either order, and a /// meta-update cascade fast-forwards `main` to the forge's on its own. Superseded, } /// Fast-forward `agent`'s `applied/main` to `rev`, a commit the swarm asks /// this hive to deploy, so the next relocking rebuild builds it. /// /// No eval-verify on this path: a config that does not evaluate fails the /// rebuild instead, with `applied/main` already at `rev`. /// /// # Errors /// /// Returns an error if the forge fetch fails, if `rev` has diverged from /// `applied/main` (neither descendant nor ancestor), or if `main` moved during the fast-forward. `main` does not /// move in any of these. pub(crate) async fn advance_applied_to_rev(agent: &str, rev: &str) -> Result { advance_applied_main( &crate::paths::applied_dir(agent), rev, crate::forge::fetch_forge_main(agent), ) .await } /// [`advance_applied_to_rev`] with the forge fetch passed in, so it is /// testable against a local repo. /// /// `applied/main` is compared to `rev` twice: before the fetch, so an applied /// rev costs no forge round-trip, and after it, because a meta-update cascade /// fast-forwards `main` to the forge's and may do so meanwhile. async fn advance_applied_main( applied_dir: &std::path::Path, rev: &str, fetch: impl std::future::Future>, ) -> Result { let main = lifecycle::git_rev_parse(applied_dir, "refs/heads/main") .await .map_err(|e| anyhow::anyhow!("read applied/main: {e:#}"))?; if main == rev { return Ok(RevAdvance::AlreadyApplied); } fetch.await.context("fetch the forge config main")?; let (main, descends) = applied_main_under(applied_dir, rev).await?; if main == rev { return Ok(RevAdvance::AlreadyApplied); } if !descends { let contained = lifecycle::git_is_ancestor(applied_dir, rev, &main) .await .map_err(|e| anyhow::anyhow!("ancestry check {rev}..{main}: {e:#}"))?; if contained { return Ok(RevAdvance::Superseded); } bail!( "{rev} does not descend from applied/main ({main}); fast-forwarding to it would \ discard commits" ); } ff_applied_main(applied_dir, rev, &main).await?; Ok(RevAdvance::Advanced) } /// Fast-forward `applied/main` to `target` and sync the working tree. /// /// Compare-and-swap, not a bare set: `main` must still be `expected_main`, the /// sha the caller's ancestry gate ([`applied_main_under`]) ran against. A plain /// `update-ref` moves the branch to `target` whatever it currently points at, /// which turns "fast-forward" into "discard anything that landed in the /// meantime" — the gate only proves `target` safe against the `main` observed /// *then*, so this is what makes that proof still true *now*. async fn ff_applied_main( applied_dir: &std::path::Path, target: &str, expected_main: &str, ) -> Result<()> { lifecycle::git_update_ref_cas(applied_dir, "refs/heads/main", target, expected_main) .await .map_err(|e| { anyhow::anyhow!("ff main {expected_main} -> {target} (concurrent move?): {e:#}") })?; lifecycle::git_read_tree_reset(applied_dir, "refs/heads/main") .await .map_err(|e| anyhow::anyhow!("read-tree to main: {e:#}")) } /// Tear down a sub-agent container. By default this is non-destructive to /// persistent state: the proposed/applied config repos and the Claude /// credentials dir under `/var/lib/hyperhive/{agents,applied}//` are /// kept, so recreating an agent of the same name reuses prior config + creds /// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/` /// is cleared because its contents (the mcp socket) don't survive restarts /// anyway. With `purge=true` the persistent trees are also wiped — config /// history, claude creds, notes — there is no undo. /// The bootstrap/root container is destroyable like any other: it's /// imperative infra that `auto_update::ensure_root_agent` recreates on the /// next hive-c0re startup if absent, so destroying it is transient rather /// than something to refuse at the API. /// /// Submits the teardown DAG and returns — it does not wait for the container to /// go away. Same contract as every other lifecycle op (`rebuild`, `kill`, /// `restart`, `start`): the queue owns the work, the caller gets an /// acknowledgement. Progress is visible as real nodes on the dashboard. pub fn destroy(coord: &Arc, name: &str, purge: bool) { tracing::info!(%name, purge, "destroy"); if let Err(e) = coord.job_queue.insert_job(|b| { crate::job_queue::templates::destroy(b, name, purge); Vec::new() }) { tracing::error!(agent = %name, error = ?e, "destroy: insert failed"); } coord.emit_rebuild_queue_snapshot(); } #[cfg(test)] mod tests { use super::{RevAdvance, advance_applied_main}; use crate::lifecycle; /// A repo with `main` at `b`, child of `a`, and `c`, a sibling of `b` off /// `a`. Returns the dir and the shas `[a, b, c]`. async fn repo() -> (tempfile::TempDir, [String; 3]) { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path(); let commit = |msg: &'static str| async move { lifecycle::git( path, &[ "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", msg, ], ) .await .expect("commit"); lifecycle::git_rev_parse(path, "HEAD").await.expect("HEAD") }; lifecycle::git(path, &["init", "-q", "--initial-branch=main"]) .await .expect("init"); let a = commit("a").await; let b = commit("b").await; lifecycle::git(path, &["checkout", "-q", "-b", "side", &a]) .await .expect("branch"); let c = commit("c").await; lifecycle::git(path, &["checkout", "-q", "main"]) .await .expect("checkout main"); (dir, [a, b, c]) } async fn main_of(dir: &tempfile::TempDir) -> String { lifecycle::git_rev_parse(dir.path(), "refs/heads/main") .await .expect("main") } #[tokio::test] async fn the_applied_rev_is_a_no_op_without_a_fetch() { let (dir, [_, b, _]) = repo().await; let fetched = std::cell::Cell::new(false); let fetch = async { fetched.set(true); anyhow::Ok(()) }; let outcome = advance_applied_main(dir.path(), &b, fetch).await; assert_eq!(outcome.expect("no-op"), RevAdvance::AlreadyApplied); assert!(!fetched.get(), "an applied rev must not reach the forge"); assert_eq!(main_of(&dir).await, b); } #[tokio::test] async fn a_rev_not_descending_from_main_is_refused_and_main_stays() { let (dir, [_, b, c]) = repo().await; let outcome = advance_applied_main(dir.path(), &c, async { anyhow::Ok(()) }).await; let err = outcome.expect_err("a sibling of main is not a fast-forward"); assert!( format!("{err:#}").contains("does not descend from applied/main"), "{err:#}" ); assert_eq!(main_of(&dir).await, b); } #[tokio::test] async fn an_ancestor_of_main_is_superseded_not_refused() { let (dir, [a, b, _]) = repo().await; let outcome = advance_applied_main(dir.path(), &a, async { anyhow::Ok(()) }).await; assert_eq!(outcome.expect("no-op"), RevAdvance::Superseded); assert_eq!(main_of(&dir).await, b); } #[tokio::test] async fn a_descendant_rev_fast_forwards_main() { let (dir, [a, b, _]) = repo().await; lifecycle::git_update_ref(dir.path(), "refs/heads/main", &a) .await .expect("rewind main"); let outcome = advance_applied_main(dir.path(), &b, async { anyhow::Ok(()) }).await; assert_eq!(outcome.expect("fast-forward"), RevAdvance::Advanced); assert_eq!(main_of(&dir).await, b); } #[tokio::test] async fn a_failed_fetch_leaves_main_alone() { let (dir, [a, b, _]) = repo().await; lifecycle::git_update_ref(dir.path(), "refs/heads/main", &a) .await .expect("rewind main"); let outcome = advance_applied_main(dir.path(), &b, async { Err::<(), _>(anyhow::anyhow!("forge down")) }) .await; assert!(outcome.is_err()); assert_eq!(main_of(&dir).await, a); } }