diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 4cbaffc2..02eb9454 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -32,6 +32,7 @@ use crate::lifecycle::{self, MANAGER_NAME}; mod build_logs; mod journal; +mod lifecycle_ops; mod permissions; mod questions; mod reminders; @@ -69,12 +70,12 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/api/state", get(api_state)) .route("/approve/{id}", post(post_approve)) .route("/deny/{id}", post(post_deny)) - .route("/destroy/{name}", post(post_destroy)) - .route("/kill/{name}", post(post_kill)) - .route("/restart/{name}", post(post_restart)) - .route("/start/{name}", post(post_start)) - .route("/rebuild/{name}", post(post_rebuild)) - .route("/update-all", post(post_update_all)) + .route("/destroy/{name}", post(lifecycle_ops::post_destroy)) + .route("/kill/{name}", post(lifecycle_ops::post_kill)) + .route("/restart/{name}", post(lifecycle_ops::post_restart)) + .route("/start/{name}", post(lifecycle_ops::post_start)) + .route("/rebuild/{name}", post(lifecycle_ops::post_rebuild)) + .route("/update-all", post(lifecycle_ops::post_update_all)) .route( "/answer-question/{id}", post(questions::post_answer_question), @@ -1863,144 +1864,6 @@ async fn post_request_spawn( } } -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:#}")), - } -} - -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 -} - -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() -} - -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 -} - -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() -} - fn transient_label(k: crate::coordinator::TransientKind) -> &'static str { use crate::coordinator::TransientKind::{ Destroying, Rebuilding, Restarting, Spawning, Starting, Stopping, @@ -2023,31 +1886,6 @@ fn strip_container_prefix(name: &str) -> String { .to_owned() } -#[derive(Deserialize, Default)] -struct DestroyForm { - #[serde(default)] - purge: Option, -} - -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:#}")), - } -} - fn error_response(message: &str) -> Response { // Plain text — the JS app surfaces this in an alert(), so HTML // wrapping would just clutter the message. diff --git a/hive-c0re/src/dashboard/lifecycle_ops.rs b/hive-c0re/src/dashboard/lifecycle_ops.rs new file mode 100644 index 00000000..7bc7c0aa --- /dev/null +++ b/hive-c0re/src/dashboard/lifecycle_ops.rs @@ -0,0 +1,191 @@ +//! 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:#}")), + } +}