//! Container lifecycle endpoints for the dashboard. //! //! Rebuild / restart / update-all enqueue onto the rebuild queue; kill / //! start run the lifecycle op directly through `lifecycle_action` (which //! marks the container transient for the duration so the dashboard can //! spinner); destroy delegates to `actions::destroy` (optionally purging). use axum::{ extract::{Form, Path as AxumPath, State}, http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; use super::{AppState, error_response, guard_agent_name, strip_container_prefix}; use crate::{actions, lifecycle}; pub(super) async fn post_rebuild( State(state): State, AxumPath(name): AxumPath, ) -> Response { let logical = strip_container_prefix(&name); if let Some(reject) = guard_agent_name(&state, &logical).await { return reject; } state.coord.rebuild_queue.enqueue( crate::rebuild_queue::QueueKind::Rebuild, logical, crate::rebuild_queue::QueueSource::Manual, "manual via dashboard ↻ R3BU1LD button".to_owned(), None, ); state.coord.emit_rebuild_queue_snapshot(); (StatusCode::OK, "ok").into_response() } /// Common shape for the simple lifecycle action handlers (start / /// stop / restart / rebuild): strip the container prefix, mark /// transient for the duration so the dashboard can spinner, run the /// lifecycle op, clear transient, redirect on success or surface the /// error. `verb` only appears in the error message; `extra` runs on /// success after `clear_transient` for handlers that need follow-up /// (e.g. `kill` also unregisters the agent + fires `HelperEvent`). async fn lifecycle_action( state: &AppState, name: &str, kind: crate::coordinator::TransientKind, verb: &str, body: F, extra: impl FnOnce(&AppState, &str), ) -> Response where F: FnOnce(String) -> Fut, Fut: std::future::Future>, { let logical = strip_container_prefix(name); let guard = state.coord.transient_guard(&logical, kind); let result = body(logical.clone()).await; drop(guard); match result { Ok(()) => { extra(state, &logical); // Rescan so the running/needs_login/needs_update flip on // the affected row lands on every dashboard's SSE channel // without waiting for a snapshot poll. 200 + matching // `data-no-refresh` on the form skip the post-submit // /api/state refetch. state.coord.rescan_containers_and_emit().await; (StatusCode::OK, "ok").into_response() } Err(e) => error_response(&format!("{verb} {logical} failed: {e:#}")), } } pub(super) async fn post_kill( State(state): State, AxumPath(name): AxumPath, ) -> Response { let logical = strip_container_prefix(&name); if let Some(reject) = guard_agent_name(&state, &logical).await { return reject; } // Manager is stoppable from the dashboard like any other // agent. The host's dashboard server keeps running (it's // hive-c0re, not the manager container), per-agent approvals // submitted by other sub-agents still process through the // host-side approval queue without the manager up, and // operator-driven meta-input updates work from the dashboard // either way. The MCP-surface self-kill guard in // `manager_server.rs::ManagerRequest::Kill` stays in place: a // manager calling Kill on its own container is self-suicide // mid-call, not a legitimate operator action. lifecycle_action( &state, &name, crate::coordinator::TransientKind::Stopping, "kill", |n| async move { lifecycle::kill(&n).await }, |s, n| { s.coord.unregister_agent(n); s.coord.notify_manager(&hive_sh4re::HelperEvent::Killed { agent: n.to_owned(), }); }, ) .await } pub(super) async fn post_restart( State(state): State, AxumPath(name): AxumPath, ) -> Response { let logical = strip_container_prefix(&name); if let Some(reject) = guard_agent_name(&state, &logical).await { return reject; } state.coord.rebuild_queue.enqueue( crate::rebuild_queue::QueueKind::Restart, logical, crate::rebuild_queue::QueueSource::Manual, "manual via dashboard ↺ R3START button".to_owned(), None, ); state.coord.emit_rebuild_queue_snapshot(); (StatusCode::OK, "ok").into_response() } pub(super) async fn post_start( State(state): State, AxumPath(name): AxumPath, ) -> Response { let logical = strip_container_prefix(&name); if let Some(reject) = guard_agent_name(&state, &logical).await { return reject; } lifecycle_action( &state, &name, crate::coordinator::TransientKind::Starting, "start", |n| async move { lifecycle::start(&n).await }, |s, n| s.coord.kick_agent(n, "container started"), ) .await } pub(super) async fn post_update_all(State(state): State) -> Response { let containers = lifecycle::list().await.unwrap_or_default(); for container in containers { let Some(logical) = container .strip_prefix(lifecycle::AGENT_PREFIX) .map(str::to_owned) else { continue; }; state.coord.rebuild_queue.enqueue( crate::rebuild_queue::QueueKind::Rebuild, logical, crate::rebuild_queue::QueueSource::Manual, "manual via dashboard 🌀 UPDATE ALL".to_owned(), None, ); } state.coord.emit_rebuild_queue_snapshot(); (StatusCode::OK, "ok").into_response() } #[derive(Deserialize, Default)] pub(super) struct DestroyForm { #[serde(default)] purge: Option, } pub(super) async fn post_destroy( State(state): State, AxumPath(name): AxumPath, Form(form): Form, ) -> Response { if let Some(reject) = guard_agent_name(&state, &name).await { return reject; } // Checkbox semantics: any non-empty value (axum sends "on") = purge. let purge = form.purge.as_deref().is_some_and(|v| !v.is_empty()); // `actions::destroy` rescans the container list on success, so the // `ContainerRemoved` event lands before we return 200. The matching // form carries `data-no-refresh`. match actions::destroy(&state.coord, &name, purge).await { Ok(()) => (StatusCode::OK, "ok").into_response(), Err(e) => error_response(&format!("destroy {name} failed: {e:#}")), } }