//! Container lifecycle endpoints for the dashboard. //! //! Rebuild / restart / start / stop (hard + graceful) / update-all all //! submit DAGs to the job queue (`job_queue::submit`), so each shows a //! visible queued→running transient on the dashboard — a direct //! sub-second start/stop only flashed the badge. Start/stop also //! persist the agent's `wanted` power intent before submitting; the //! DAG's `Reconcile` converges to it. Destroy delegates to //! `actions::destroy` (optionally purging). use axum::{ extract::{Form, Path as AxumPath, Query, State}, http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; /// Query params for `post_kill` / `post_restart`. `?graceful=1` routes to /// the graceful-stop/-restart orchestration (quiesce the harness, flush /// `/state`, then container stop/restart) instead of an immediate hard /// action. Defaults false → today's hard kill/restart. #[derive(Deserialize)] pub(super) struct GracefulParams { #[serde(default)] graceful: bool, } use super::{AppState, Ident, error_response, guard_agent_name, strip_container_prefix}; use crate::job_queue::{Source, submit}; 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; } submit::rebuild( &state.coord, &logical, Source::Manual, "manual via dashboard ↻ R3BU1LD button".to_owned(), ); (StatusCode::OK, "ok").into_response() } pub(super) async fn post_kill( State(state): State, AxumPath(name): AxumPath, Query(params): Query, ) -> Response { let logical = strip_container_prefix(&name); if let Some(reject) = guard_agent_name(&state, &logical).await { return reject; } if params.graceful { // Graceful stop: submit the quiesce DAG (signal the harness → // one stop-checkpoint turn → drain → container stop, with a // timeout fallback to a hard stop). The agent's lifecycle // lease keeps it from racing an in-flight rebuild for the same // agent, and per-node progress surfaces on the queue snapshot. submit::graceful_stop( &state.coord, &logical, Source::Manual, "manual via dashboard graceful stop".to_owned(), ) .await; return (StatusCode::OK, "ok").into_response(); } // 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 // `socket_server.rs::Request::Kill` stays in place: a // manager calling Kill on its own container is self-suicide // mid-call, not a legitimate operator action. submit::stop( &state.coord, &logical, Source::Manual, "manual via dashboard stop".to_owned(), ) .await; (StatusCode::OK, "ok").into_response() } pub(super) async fn post_restart( State(state): State, AxumPath(name): AxumPath, Query(params): Query, ) -> Response { let logical = strip_container_prefix(&name); if let Some(reject) = guard_agent_name(&state, &logical).await { return reject; } if params.graceful { submit::graceful_restart( &state.coord, &logical, Source::Manual, "manual via dashboard graceful restart".to_owned(), ) .await; return (StatusCode::OK, "ok").into_response(); } submit::restart( &state.coord, &logical, Source::Manual, "manual via dashboard ↺ R3START button".to_owned(), ) .await; (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; } submit::start( &state.coord, &logical, Source::Manual, "manual via dashboard start".to_owned(), ) .await; (StatusCode::OK, "ok").into_response() } /// `POST /api/pause/{name}` — write the pause marker for `name`. /// /// Unlike the lifecycle ops above this is not a DAG: it writes a single /// marker file, which the harness stats at the top of its serve loop. /// Works on stopped containers too (the marker is sticky and takes effect /// when the container next boots). Triggers an immediate rescan so the /// `paused` badge flips on the dashboard without waiting for the next /// periodic sweep. pub(super) async fn post_pause( 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; } let ident = match Ident::parse(&logical) { Ok(i) => i, Err(e) => return (StatusCode::BAD_REQUEST, format!("bad agent name: {e}")).into_response(), }; if let Err(e) = crate::coordinator::Coordinator::set_paused(&ident, true) { return error_response(&format!("pause {logical}: {e}")); } state.coord.rescan_containers_and_emit().await; (StatusCode::OK, "ok").into_response() } /// `POST /api/resume/{name}` — remove the pause marker for `name`. /// /// The inverse of `post_pause`. Removing a non-existent marker is a no-op /// (idempotent). Triggers an immediate rescan so the paused badge clears. pub(super) async fn post_resume( 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; } let ident = match Ident::parse(&logical) { Ok(i) => i, Err(e) => return (StatusCode::BAD_REQUEST, format!("bad agent name: {e}")).into_response(), }; if let Err(e) = crate::coordinator::Coordinator::set_paused(&ident, false) { return error_response(&format!("resume {logical}: {e}")); } state.coord.rescan_containers_and_emit().await; (StatusCode::OK, "ok").into_response() } 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; }; submit::rebuild( &state.coord, &logical, Source::Manual, "manual via dashboard 🌀 UPDATE ALL".to_owned(), ); } (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:#}")), } }