backend: - TransientKind grows Starting / Stopping / Restarting / Rebuilding / Destroying alongside the existing Spawning. each dashboard handler (start/restart/kill/rebuild/destroy) wraps the lifecycle call with set_transient + clear_transient so the dashboard knows what's in flight. transient kind is surfaced inline on ContainerView.pending (existing-container actions) — only Spawning (pre-creation) lands in the separate transients list. frontend: - container row is now two lines: identity + meta on top, action buttons below. less cluttered, leaves room for the pending state pill. pending rows dim their actions and surface a pulsing '◐ spawning… / starting… / stopping… / restarting… / rebuilding… / destroying…' indicator next to the name. - 'needs login' / 'needs update' chips moved into a unified .badge styling for consistency. - auto-refresh kicks in not only on transient spawn but on any container with a pending action.
195 lines
7.2 KiB
Rust
195 lines
7.2 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};
|
|
|
|
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);
|
|
let notes_dir = Coordinator::agent_notes_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,
|
|
¬es_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,
|
|
¬es_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<()> {
|
|
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_manager(&HelperEvent::ApprovalResolved {
|
|
id: approval.id,
|
|
agent: approval.agent.clone(),
|
|
commit_ref: approval.commit_ref.clone(),
|
|
status,
|
|
note: note.clone(),
|
|
});
|
|
// For spawn/rebuild approvals, also surface the underlying action so
|
|
// the manager knows whether the container actually came up. The
|
|
// ApprovalResolved event already carries the same `ok` signal but
|
|
// separating it lets the manager react to the lifecycle change
|
|
// without having to special-case approvals.
|
|
match approval.kind {
|
|
ApprovalKind::Spawn => coord.notify_manager(&HelperEvent::Spawned {
|
|
agent: approval.agent.clone(),
|
|
ok,
|
|
note,
|
|
}),
|
|
ApprovalKind::ApplyCommit => coord.notify_manager(&HelperEvent::Rebuilt {
|
|
agent: approval.agent.clone(),
|
|
ok,
|
|
note,
|
|
}),
|
|
}
|
|
result
|
|
}
|
|
|
|
/// 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.
|
|
/// Refuses the manager (declarative; would fight with the host's nixos config).
|
|
pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()> {
|
|
if name == MANAGER_NAME || name == MANAGER_AGENT {
|
|
bail!("refusing to destroy the manager ({name})");
|
|
}
|
|
tracing::info!(%name, purge, "destroy");
|
|
coord.set_transient(name, TransientKind::Destroying);
|
|
let result = lifecycle::destroy(name).await;
|
|
if result.is_err() {
|
|
coord.clear_transient(name);
|
|
}
|
|
result?;
|
|
coord.unregister_agent(name);
|
|
let runtime = Coordinator::agent_dir(name);
|
|
if runtime.exists() {
|
|
let _ = std::fs::remove_dir_all(&runtime);
|
|
}
|
|
if purge {
|
|
for dir in [
|
|
Coordinator::agent_state_root(name),
|
|
Coordinator::agent_applied_dir(name),
|
|
] {
|
|
if dir.exists()
|
|
&& let Err(e) = std::fs::remove_dir_all(&dir)
|
|
{
|
|
tracing::warn!(error = ?e, dir = %dir.display(), "purge: remove failed");
|
|
}
|
|
}
|
|
}
|
|
let _ = coord.approvals.fail_pending_for_agent(
|
|
name,
|
|
if purge {
|
|
"agent purged"
|
|
} else {
|
|
"agent destroyed"
|
|
},
|
|
);
|
|
coord.clear_transient(name);
|
|
coord.notify_manager(&HelperEvent::Destroyed {
|
|
agent: name.to_owned(),
|
|
});
|
|
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 {
|
|
coord.notify_manager(&HelperEvent::ApprovalResolved {
|
|
id: a.id,
|
|
agent: a.agent,
|
|
commit_ref: a.commit_ref,
|
|
status: ApprovalStatus::Denied,
|
|
note: None,
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|