hyperhive/hive-c0re/src/dashboard/lifecycle_ops.rs
atlas f5f06a5f14 refactor(#1865): consolidate agent + manager socket servers into one
The per-agent and manager sockets ran two parallel dispatchers with
duplicated lifecycle handlers (agent-side topology-gated, manager-side
ungated) plus a manager-only handler set. Collapse to one parameterized
server in socket_server.rs:

- one serve() + dispatch(req, agent, privileged, coord); start() binds
  the per-agent sockets (privileged=false), start_manager() binds the
  manager socket (privileged=true).
- each lifecycle/config handler (start/restart/kill/update/init_config/
  apply_commit) merges its dual: the topology guard (require_child /
  require_new_child) runs only on the !privileged path; init_config
  records the requester as parent only when !privileged. restart keeps
  the orthogonal, capability-gated + audited infra-container branch.
- the agent-state queries (loose-ends / reminder count + rollup) branch
  on privileged: privileged keeps any-target + the "*" hive-wide sweep
  (query_agent_state-gated), non-privileged keeps the topology/cap gate.
- the privileged-only verbs (schedules / meta-inputs / get_logs) plus
  the submit/schedule/watchdog helpers move into socket_server; they are
  reached via dispatch_privileged_only(), which rejects the whole group
  on a non-privileged socket.
- delete manager_server.rs; repoint refs; merge the test modules.

No behavior change: the topology guard still applies on every
non-privileged lifecycle call, the privileged socket still acts on any
agent, and privileged-only verbs are still rejected on agent sockets.
2026-06-22 13:58:52 +02:00

175 lines
6.3 KiB
Rust

//! Container lifecycle endpoints for the dashboard.
//!
//! Rebuild / restart / start / stop (hard + graceful) / update-all all
//! enqueue onto the rebuild queue, so each shows a visible queued→running
//! transient on the dashboard — a direct sub-second start/stop only flashed
//! the badge; 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`. `?graceful=1` routes to the graceful-stop
/// orchestration (quiesce the harness, flush `/state`, then container stop)
/// instead of an immediate hard stop. Defaults false → today's hard kill.
#[derive(Deserialize)]
pub(super) struct KillParams {
#[serde(default)]
graceful: bool,
}
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()
}
pub(super) async fn post_kill(
State(state): State<AppState>,
AxumPath(name): AxumPath<String>,
Query(params): Query<KillParams>,
) -> Response {
let logical = strip_container_prefix(&name);
if let Some(reject) = guard_agent_name(&state, &logical).await {
return reject;
}
if params.graceful {
// Graceful stop: enqueue the quiesce orchestration (signal the harness
// → one stop-checkpoint turn → drain → container stop, with a timeout
// fallback to a hard stop). Serialised through the rebuild queue so it
// can't race an in-flight rebuild for the same agent, and its per-step
// progress surfaces on the queue snapshot + build log.
state.coord.rebuild_queue.enqueue(
crate::rebuild_queue::QueueKind::GracefulStop,
logical,
crate::rebuild_queue::QueueSource::Manual,
"manual via dashboard graceful stop".to_owned(),
None,
);
state.coord.emit_rebuild_queue_snapshot();
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::ManagerRequest::Kill` stays in place: a
// manager calling Kill on its own container is self-suicide
// mid-call, not a legitimate operator action.
state.coord.rebuild_queue.enqueue(
crate::rebuild_queue::QueueKind::Stop,
logical,
crate::rebuild_queue::QueueSource::Manual,
"manual via dashboard stop".to_owned(),
None,
);
state.coord.emit_rebuild_queue_snapshot();
(StatusCode::OK, "ok").into_response()
}
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;
}
state.coord.rebuild_queue.enqueue(
crate::rebuild_queue::QueueKind::Start,
logical,
crate::rebuild_queue::QueueSource::Manual,
"manual via dashboard start".to_owned(),
None,
);
state.coord.emit_rebuild_queue_snapshot();
(StatusCode::OK, "ok").into_response()
}
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:#}")),
}
}