//! 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 hive_sh4re::approvals::{ApprovalKind, ApprovalStatus}; use hive_sh4re::manager::HelperEvent; use crate::coordinator::Coordinator; use crate::lifecycle; /// Approve a pending request. Marks the approval row durably, then submits /// the work to the job queue so the dashboard POST returns immediately while /// the long-running pipeline runs off-thread. /// /// Dispatch: /// - `UpdateMetaInputs` → a `MetaUpdate` DAG (fan-out on completion) /// /// Every queued kind resolves its approval row via /// [`resolve_approval_dag`] when the DAG settles terminal. pub fn approve(coord: &Arc, id: i64) -> Result<()> { let approval = coord.approvals.mark_approved(id)?; tracing::info!( %approval.id, %approval.agent, kind = ?approval.kind, %approval.commit_ref, "approval: dispatching", ); match approval.kind { ApprovalKind::UpdateMetaInputs => { // Inputs JSON-encoded into commit_ref by the manager's // submit path — surface them on the DAG so the dashboard // can show *which* inputs are about to bump. The cascade // rebuilds fan out when the lock bump lands (so they build // against the post-bump lock, and a failed bump fans out // nothing). let inputs: Vec = serde_json::from_str(&approval.commit_ref).unwrap_or_default(); let inserted = coord.job_queue.insert_job(|b| { crate::job_queue::templates::meta_update(b, inputs, Some(id)); Vec::new() }); if let Err(e) = inserted { return Err(e.context("insert meta-update dag")); } coord.emit_rebuild_queue_snapshot(); Ok(()) } } } /// `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, } /// 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` does not descend from /// `applied/main`, 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 { 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) } /// Post why the merged commit `rev` of `agent`'s config repo was not deployed /// onto the PR whose merge produced it. Best-effort: a failure here is logged /// only. pub(crate) async fn post_rev_deploy_failure_to_pr(agent: &str, rev: &str, err: &anyhow::Error) { let repo = crate::forge::config_repo(agent); let pr = match crate::forge::merged_pr_for_commit(&repo, rev).await { Ok(pr) => pr, Err(e) => { tracing::warn!(%agent, %rev, error = %e, "find the PR that merged a failed deploy's rev failed"); return; } }; let body = format!( "## ⚠️ config deploy failed\n\n\ The merged commit `{rev}` was not deployed:\n\n\ ```\n{err:#}\n```" ); if let Err(e) = crate::forge::post_pr_comment(&repo, pr, &body).await { tracing::warn!(%agent, %pr, error = ?e, "post rev deploy-failure comment to PR failed"); } } /// Resolve an approval row from how its DAG's work ended — the body of the /// [`NodeKind::ResolveApproval`] tail node. Every approval-carrying template /// resolves here: the pipeline is ordinary queue nodes, /// so the work's terminal state is the authoritative outcome and there's no /// in-node resolution to skip around. /// /// `outcome` is the one the calling node was built to report — a template emits /// one tail per outcome, so success, failure and cancel each arrive here from /// their own node rather than from one node branching. /// /// [`NodeKind::ResolveApproval`]: crate::job_queue::NodeKind::ResolveApproval pub(crate) fn resolve_approval_dag( coord: &Arc, approval_id: i64, outcome: crate::job_queue::TerminalState, error: Option<&str>, ) { use crate::job_queue::TerminalState; let approval = match coord.approvals.get(approval_id) { Ok(Some(a)) => a, Ok(None) => { tracing::warn!(approval_id, "approval dag terminal: row no longer exists"); return; } Err(e) => { tracing::warn!(approval_id, error = ?e, "approval dag terminal: row read failed"); return; } }; let result: Result<()> = match outcome { TerminalState::Done => Ok(()), // `Skipped` never reaches here — no template emits a tail for it — but it // reads the same to an operator either way: the work did not happen. TerminalState::Cancelled | TerminalState::Skipped => { Err(anyhow::anyhow!("cancelled before completion")) } TerminalState::Failed => Err(anyhow::anyhow!("{}", error.unwrap_or("job dag failed"))), }; if let Err(e) = finish_approval(coord, &approval, result) { tracing::warn!(approval_id, error = ?e, "approval dag resolved with failure"); } } fn finish_approval( coord: &Coordinator, approval: &hive_sh4re::approvals::Approval, result: Result<()>, ) -> Result<()> { let (status, note, ok) = match &result { Ok(()) => (ApprovalStatus::Approved, None, true), Err(e) => { let note = format!("{e:#}"); let _ = coord.approvals.mark_failed(approval.id, ¬e); (ApprovalStatus::Failed, Some(note), false) } }; coord.notify_submitter( approval.id, &HelperEvent::ApprovalResolved { id: approval.id, agent: approval.agent.to_string(), commit_ref: approval.commit_ref.clone(), status, note: note.clone(), }, ); // Phase 5b: also fire on the dashboard event channel so the // browser moves the row out of pending into history without a // snapshot refetch. `approved` rows that succeed get the // approval's logged resolved_at indirectly via `Utc::now()`; // failures already wrote it via mark_failed above. let approval_kind = <&str>::from(approval.kind); let status_str = if ok { "approved" } else { "failed" }; coord.emit_approval_resolved(crate::coordinator::ApprovalResolved { id: approval.id, agent: approval.agent.as_str(), approval_kind, status: status_str, note: note.clone(), description: approval.description.clone(), }); result } /// 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(); } pub fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<()> { let approval = coord.approvals.get(id)?; coord.approvals.mark_denied(id, note)?; tracing::info!(%id, note, "approval denied"); if let Some(a) = approval { let approval_kind = <&str>::from(a.kind); let description = a.description.clone(); let agent_owned = a.agent.clone(); coord.notify_submitter( a.id, &HelperEvent::ApprovalResolved { id: a.id, agent: a.agent.to_string(), commit_ref: a.commit_ref, status: ApprovalStatus::Denied, note: note.map(String::from), }, ); coord.emit_approval_resolved(crate::coordinator::ApprovalResolved { id, agent: agent_owned.as_str(), approval_kind, status: "denied", note: note.map(String::from), description, }); } Ok(()) } #[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 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); } }