ruth is created from the swarm dashboard like any agent. An existing ruth container starts at boot through the boot reconcile, like every other agent. A definition of services.hyperhive.ruthless now fails evaluation with a removal message.
256 lines
10 KiB
Rust
256 lines
10 KiB
Rust
//! 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<RevAdvance> {
|
|
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<T>(
|
|
applied_dir: &std::path::Path,
|
|
rev: &str,
|
|
fetch: impl std::future::Future<Output = Result<T>>,
|
|
) -> Result<RevAdvance> {
|
|
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}/<name>/` 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. Nothing
|
|
/// recreates it afterwards: ruth stays gone until she is created again from
|
|
/// the swarm dashboard.
|
|
///
|
|
/// 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<Coordinator>, 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);
|
|
}
|
|
}
|