hyperhive/hive-c0re/src/actions.rs

184 lines
6.6 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::{Result, bail};
use hive_sh4re::{
ApprovalKind, ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER,
};
use crate::coordinator::{Coordinator, TransientKind};
use crate::lifecycle::{self, MANAGER_NAME};
/// Approve a pending request and run the underlying action. Dispatches on the
/// approval kind:
/// - `ApplyCommit`: read agent.nix at the approval's commit from the proposed
/// repo, copy into the applied repo, commit there, rebuild the container.
/// Synchronous — returns once the rebuild completes.
/// - `Spawn`: create + start a brand-new sub-agent container. Runs in a
/// background task so the operator's approve click returns immediately;
/// the dashboard surfaces a transient `Spawning` state until the container
/// is up. On failure, the approval is marked failed.
///
/// In all cases an `ApprovalResolved` helper event lands in the manager's
/// inbox when the work resolves.
pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
let approval = coord.approvals.mark_approved(id)?;
tracing::info!(
%approval.id,
%approval.agent,
kind = ?approval.kind,
%approval.commit_ref,
"approval: running action",
);
let agent_dir = coord.ensure_runtime(&approval.agent)?;
let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent);
let applied_dir = Coordinator::agent_applied_dir(&approval.agent);
let claude_dir = Coordinator::agent_claude_dir(&approval.agent);
match approval.kind {
ApprovalKind::ApplyCommit => {
let result = async {
lifecycle::apply_commit(&applied_dir, &proposed_dir, &approval.commit_ref).await?;
lifecycle::rebuild(
&approval.agent,
&coord.hyperhive_flake,
&agent_dir,
&applied_dir,
&claude_dir,
coord.dashboard_port,
)
.await
}
.await;
finish_approval(&coord, &approval, result)
}
ApprovalKind::Spawn => {
// Run the spawn in the background so the approve POST returns
// immediately. The dashboard reads `transient` to render a spinner.
coord.set_transient(&approval.agent, TransientKind::Spawning);
let coord_bg = coord.clone();
let approval_bg = approval.clone();
tokio::spawn(async move {
let agent_bg = approval_bg.agent.clone();
let result = lifecycle::spawn(
&approval_bg.agent,
&coord_bg.hyperhive_flake,
&agent_dir,
&proposed_dir,
&applied_dir,
&claude_dir,
coord_bg.dashboard_port,
)
.await;
coord_bg.clear_transient(&agent_bg);
if let Err(e) = finish_approval(&coord_bg, &approval_bg, result) {
tracing::warn!(agent = %agent_bg, error = ?e, "spawn approval failed");
}
});
Ok(())
}
}
}
fn finish_approval(
coord: &Coordinator,
approval: &hive_sh4re::Approval,
result: Result<()>,
) -> Result<()> {
match result {
Ok(()) => {
notify_manager(
coord,
&HelperEvent::ApprovalResolved {
id: approval.id,
agent: approval.agent.clone(),
commit_ref: approval.commit_ref.clone(),
status: ApprovalStatus::Approved,
note: None,
},
);
Ok(())
}
Err(e) => {
let note = format!("{e:#}");
let _ = coord.approvals.mark_failed(approval.id, &note);
notify_manager(
coord,
&HelperEvent::ApprovalResolved {
id: approval.id,
agent: approval.agent.clone(),
commit_ref: approval.commit_ref.clone(),
status: ApprovalStatus::Failed,
note: Some(note),
},
);
Err(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. A future `--purge` path can wipe state when the operator opts in.
/// Refuses the manager (declarative; would fight with the host's nixos config).
pub async fn destroy(coord: &Coordinator, name: &str) -> Result<()> {
if name == MANAGER_NAME || name == MANAGER_AGENT {
bail!("refusing to destroy the manager ({name})");
}
tracing::info!(%name, "destroy");
lifecycle::destroy(name).await?;
coord.unregister_agent(name);
let runtime = Coordinator::agent_dir(name);
if runtime.exists() {
let _ = std::fs::remove_dir_all(&runtime);
}
let _ = coord
.approvals
.fail_pending_for_agent(name, "agent destroyed");
Ok(())
}
pub fn deny(coord: &Coordinator, id: i64) -> Result<()> {
let approval = coord.approvals.get(id)?;
coord.approvals.mark_denied(id)?;
tracing::info!(%id, "approval denied");
if let Some(a) = approval {
notify_manager(
coord,
&HelperEvent::ApprovalResolved {
id: a.id,
agent: a.agent,
commit_ref: a.commit_ref,
status: ApprovalStatus::Denied,
note: None,
},
);
}
Ok(())
}
fn notify_manager(coord: &Coordinator, event: &HelperEvent) {
let body = match serde_json::to_string(event) {
Ok(s) => s,
Err(e) => {
tracing::warn!(error = ?e, "failed to encode helper event");
return;
}
};
if let Err(e) = coord.broker.send(&Message {
from: SYSTEM_SENDER.to_owned(),
to: MANAGER_AGENT.to_owned(),
body,
}) {
tracing::warn!(error = ?e, "failed to push helper event to manager");
}
}