refactor(#1456): extract dashboard lifecycle endpoints into dashboard/lifecycle_ops.rs
This commit is contained in:
parent
4e06a9682d
commit
55705f17d3
2 changed files with 198 additions and 169 deletions
|
|
@ -32,6 +32,7 @@ use crate::lifecycle::{self, MANAGER_NAME};
|
||||||
|
|
||||||
mod build_logs;
|
mod build_logs;
|
||||||
mod journal;
|
mod journal;
|
||||||
|
mod lifecycle_ops;
|
||||||
mod permissions;
|
mod permissions;
|
||||||
mod questions;
|
mod questions;
|
||||||
mod reminders;
|
mod reminders;
|
||||||
|
|
@ -69,12 +70,12 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.route("/api/state", get(api_state))
|
.route("/api/state", get(api_state))
|
||||||
.route("/approve/{id}", post(post_approve))
|
.route("/approve/{id}", post(post_approve))
|
||||||
.route("/deny/{id}", post(post_deny))
|
.route("/deny/{id}", post(post_deny))
|
||||||
.route("/destroy/{name}", post(post_destroy))
|
.route("/destroy/{name}", post(lifecycle_ops::post_destroy))
|
||||||
.route("/kill/{name}", post(post_kill))
|
.route("/kill/{name}", post(lifecycle_ops::post_kill))
|
||||||
.route("/restart/{name}", post(post_restart))
|
.route("/restart/{name}", post(lifecycle_ops::post_restart))
|
||||||
.route("/start/{name}", post(post_start))
|
.route("/start/{name}", post(lifecycle_ops::post_start))
|
||||||
.route("/rebuild/{name}", post(post_rebuild))
|
.route("/rebuild/{name}", post(lifecycle_ops::post_rebuild))
|
||||||
.route("/update-all", post(post_update_all))
|
.route("/update-all", post(lifecycle_ops::post_update_all))
|
||||||
.route(
|
.route(
|
||||||
"/answer-question/{id}",
|
"/answer-question/{id}",
|
||||||
post(questions::post_answer_question),
|
post(questions::post_answer_question),
|
||||||
|
|
@ -1863,144 +1864,6 @@ async fn post_request_spawn(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> 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<F, Fut>(
|
|
||||||
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<Output = anyhow::Result<()>>,
|
|
||||||
{
|
|
||||||
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<AppState>, AxumPath(name): AxumPath<String>) -> 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<AppState>, AxumPath(name): AxumPath<String>) -> 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<AppState>, AxumPath(name): AxumPath<String>) -> 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<AppState>) -> 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 {
|
fn transient_label(k: crate::coordinator::TransientKind) -> &'static str {
|
||||||
use crate::coordinator::TransientKind::{
|
use crate::coordinator::TransientKind::{
|
||||||
Destroying, Rebuilding, Restarting, Spawning, Starting, Stopping,
|
Destroying, Rebuilding, Restarting, Spawning, Starting, Stopping,
|
||||||
|
|
@ -2023,31 +1886,6 @@ fn strip_container_prefix(name: &str) -> String {
|
||||||
.to_owned()
|
.to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Default)]
|
|
||||||
struct DestroyForm {
|
|
||||||
#[serde(default)]
|
|
||||||
purge: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_destroy(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(name): AxumPath<String>,
|
|
||||||
Form(form): Form<DestroyForm>,
|
|
||||||
) -> 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 {
|
fn error_response(message: &str) -> Response {
|
||||||
// Plain text — the JS app surfaces this in an alert(), so HTML
|
// Plain text — the JS app surfaces this in an alert(), so HTML
|
||||||
// wrapping would just clutter the message.
|
// wrapping would just clutter the message.
|
||||||
|
|
|
||||||
191
hive-c0re/src/dashboard/lifecycle_ops.rs
Normal file
191
hive-c0re/src/dashboard/lifecycle_ops.rs
Normal file
|
|
@ -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<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
) -> 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<F, Fut>(
|
||||||
|
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<Output = anyhow::Result<()>>,
|
||||||
|
{
|
||||||
|
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<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
) -> 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<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
) -> 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<AppState>) -> 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn post_destroy(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
Form(form): Form<DestroyForm>,
|
||||||
|
) -> 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:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue