21 of 28 non-test call sites now insert directly. power.rs compiles.
The only remaining errors are server.rs's 5, which are blocked: those
sites feed the returned id into HostResponse::queued -> `queued_dags`,
a wire field hivectl polls via QueueDag. Removing the container without
answering that breaks hivectl's wait/progress loop; asked on the issue.
Also swept the deleted symbol out of prose, not just code:
- docs/coordinator.md: "the submit layer (job_queue/submit.rs)" ->
the power layer (job_queue/power.rs), and "submits" -> "inserts".
- templates.rs module doc: points at super::power for the power ops.
- lifecycle_ops.rs module doc: says which path each op takes now.
- mod.rs's insert_group comment restated the open issue verbatim
("a DAG is addressed by its container node, which submit inserts
itself"). Replaced with what is actually true for that path.
Dashboard behaviour deltas worth review: insert failures are now
logged per agent instead of swallowed, and UPDATE-ALL emits one queue
snapshot after the loop rather than one per agent.
453 lines
17 KiB
Rust
453 lines
17 KiB
Rust
//! Container lifecycle endpoints for the dashboard.
|
|
//!
|
|
//! Rebuild / restart / start / stop (hard + graceful) / update-all all
|
|
//! insert DAGs into the job queue — the power ops via
|
|
//! [`crate::job_queue::power`], the static shapes straight through
|
|
//! `JobQueue::insert` — 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
|
|
//! inserting; 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;
|
|
use utoipa::{IntoParams, ToSchema};
|
|
|
|
/// 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, IntoParams)]
|
|
pub(super) struct GracefulParams {
|
|
#[serde(default)]
|
|
graceful: bool,
|
|
}
|
|
|
|
use super::{AppState, Ident, error_response, guard_agent_name, strip_container_prefix};
|
|
use crate::{actions, lifecycle};
|
|
|
|
/// Queue a rebuild DAG for `name`.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/rebuild/{name}",
|
|
params(("name" = String, Path, description = "agent name")),
|
|
responses(
|
|
(status = 200, description = "rebuild queued", body = String),
|
|
(status = 400, description = "bad agent name"),
|
|
(status = 404, description = "no such agent"),
|
|
),
|
|
tag = "lifecycle_ops"
|
|
)]
|
|
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;
|
|
}
|
|
if let Err(e) = state.coord.job_queue.insert(|b| {
|
|
crate::job_queue::templates::rebuild(b, &logical, true);
|
|
Vec::new()
|
|
}) {
|
|
tracing::error!(agent = %logical, error = ?e, "rebuild: insert failed");
|
|
}
|
|
state.coord.emit_rebuild_queue_snapshot();
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
|
|
/// Stop `name`, hard by default or
|
|
/// gracefully when `graceful=1`.
|
|
///
|
|
/// Graceful mode: quiesce → drain → stop.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/kill/{name}",
|
|
params(
|
|
("name" = String, Path, description = "agent name"),
|
|
GracefulParams,
|
|
),
|
|
responses(
|
|
(status = 200, description = "stop queued/performed", body = String),
|
|
(status = 400, description = "bad agent name"),
|
|
(status = 404, description = "no such agent"),
|
|
),
|
|
tag = "lifecycle_ops"
|
|
)]
|
|
pub(super) async fn post_kill(
|
|
State(state): State<AppState>,
|
|
AxumPath(name): AxumPath<String>,
|
|
Query(params): Query<GracefulParams>,
|
|
) -> 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.
|
|
if let Err(e) =
|
|
crate::job_queue::power::stop_many(&state.coord, &[logical.clone()], true).await
|
|
{
|
|
tracing::error!(agent = %logical, error = ?e, "graceful stop: insert failed");
|
|
}
|
|
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.
|
|
if let Err(e) = crate::job_queue::power::stop_many(&state.coord, &[logical.clone()], false).await
|
|
{
|
|
tracing::error!(agent = %logical, error = ?e, "stop: insert failed");
|
|
}
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
|
|
/// Restart `name`, hard by default
|
|
/// or gracefully when `graceful=1`.
|
|
///
|
|
/// Graceful mode: quiesce → drain → restart.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/restart/{name}",
|
|
params(
|
|
("name" = String, Path, description = "agent name"),
|
|
GracefulParams,
|
|
),
|
|
responses(
|
|
(status = 200, description = "restart queued/performed", body = String),
|
|
(status = 400, description = "bad agent name"),
|
|
(status = 404, description = "no such agent"),
|
|
),
|
|
tag = "lifecycle_ops"
|
|
)]
|
|
pub(super) async fn post_restart(
|
|
State(state): State<AppState>,
|
|
AxumPath(name): AxumPath<String>,
|
|
Query(params): Query<GracefulParams>,
|
|
) -> Response {
|
|
let logical = strip_container_prefix(&name);
|
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
|
return reject;
|
|
}
|
|
if params.graceful {
|
|
if let Err(e) =
|
|
crate::job_queue::power::restart_many(&state.coord, &[logical.clone()], true).await
|
|
{
|
|
tracing::error!(agent = %logical, error = ?e, "graceful restart: insert failed");
|
|
}
|
|
return (StatusCode::OK, "ok").into_response();
|
|
}
|
|
if let Err(e) =
|
|
crate::job_queue::power::restart_many(&state.coord, &[logical.clone()], false).await
|
|
{
|
|
tracing::error!(agent = %logical, error = ?e, "restart: insert failed");
|
|
}
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
|
|
/// Query params for `post_start`. `?paused=1` writes the pause marker
|
|
/// before (or instead of) starting — see `post_start`'s doc.
|
|
#[derive(Deserialize, IntoParams)]
|
|
pub(super) struct StartParams {
|
|
#[serde(default)]
|
|
paused: bool,
|
|
}
|
|
|
|
/// Start `name`, optionally paused.
|
|
///
|
|
/// Plain `?paused=1` mirrors `hivectl agent <name> start --paused`: if
|
|
/// `name` is already running, this just writes the pause marker in place
|
|
/// and returns without submitting a start DAG (nothing to start). If it's
|
|
/// down, the marker is written *before* the start DAG is submitted, so
|
|
/// the container comes up paused rather than racing the harness's own
|
|
/// pause-gate poll against an already-in-flight start.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/start/{name}",
|
|
params(
|
|
("name" = String, Path, description = "agent name"),
|
|
StartParams,
|
|
),
|
|
responses(
|
|
(status = 200, description = "start queued (or paused in place)", body = String),
|
|
(status = 400, description = "bad agent name"),
|
|
(status = 404, description = "no such agent"),
|
|
(status = 500, description = "pause marker write failed"),
|
|
),
|
|
tag = "lifecycle_ops"
|
|
)]
|
|
pub(super) async fn post_start(
|
|
State(state): State<AppState>,
|
|
AxumPath(name): AxumPath<String>,
|
|
Query(params): Query<StartParams>,
|
|
) -> Response {
|
|
let logical = strip_container_prefix(&name);
|
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
|
return reject;
|
|
}
|
|
if params.paused {
|
|
let ident = match Ident::parse(&logical) {
|
|
Ok(i) => i,
|
|
Err(e) => {
|
|
return (StatusCode::BAD_REQUEST, format!("bad agent name: {e}")).into_response();
|
|
}
|
|
};
|
|
let already_running = lifecycle::is_running(&logical).await;
|
|
if let Err(e) = crate::coordinator::Coordinator::set_paused(&ident, true).await {
|
|
return error_response(&format!("pause {logical}: {e}"));
|
|
}
|
|
state.coord.rescan_containers_and_emit().await;
|
|
if already_running {
|
|
// Already up — pausing in place is the whole request, no DAG
|
|
// to submit.
|
|
return (StatusCode::OK, "ok").into_response();
|
|
}
|
|
}
|
|
if let Err(e) = crate::job_queue::power::start_many(&state.coord, &[logical.clone()]).await {
|
|
tracing::error!(agent = %logical, error = ?e, "start: insert failed");
|
|
}
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
|
|
/// 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.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/pause/{name}",
|
|
params(("name" = String, Path, description = "agent name")),
|
|
responses(
|
|
(status = 200, description = "pause marker written", body = String),
|
|
(status = 400, description = "bad agent name"),
|
|
(status = 404, description = "no such agent"),
|
|
(status = 500, description = "marker write failed"),
|
|
),
|
|
tag = "lifecycle_ops"
|
|
)]
|
|
pub(super) async fn post_pause(
|
|
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;
|
|
}
|
|
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).await {
|
|
return error_response(&format!("pause {logical}: {e}"));
|
|
}
|
|
state.coord.rescan_containers_and_emit().await;
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
|
|
/// 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.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/resume/{name}",
|
|
params(("name" = String, Path, description = "agent name")),
|
|
responses(
|
|
(status = 200, description = "pause marker removed", body = String),
|
|
(status = 400, description = "bad agent name"),
|
|
(status = 404, description = "no such agent"),
|
|
(status = 500, description = "marker removal failed"),
|
|
),
|
|
tag = "lifecycle_ops"
|
|
)]
|
|
pub(super) async fn post_resume(
|
|
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;
|
|
}
|
|
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).await {
|
|
return error_response(&format!("resume {logical}: {e}"));
|
|
}
|
|
state.coord.rescan_containers_and_emit().await;
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
|
|
/// Form fields for `post_resource_limits`. Both fields are optional strings;
|
|
/// an empty value clears the per-agent override for that field, falling back
|
|
/// to the hive-wide default.
|
|
#[derive(Deserialize, Default, ToSchema)]
|
|
pub(super) struct ResourceLimitsForm {
|
|
#[serde(default)]
|
|
cpu_quota: String,
|
|
#[serde(default)]
|
|
memory_max: String,
|
|
}
|
|
|
|
/// Write per-agent CPU/memory limit
|
|
/// overrides for `name`.
|
|
///
|
|
/// An empty `cpu_quota` or `memory_max` field clears that field's override,
|
|
/// falling back to the hive-wide default. Both empty together removes the
|
|
/// agent's entry entirely. The new drop-in is written immediately — the
|
|
/// limits take effect on the next container start or restart. Triggers an
|
|
/// immediate rescan so `ContainerView.cpu_quota`/`memory_max` update on
|
|
/// the dashboard via SSE without waiting for the next periodic sweep.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/resource-limits/{name}",
|
|
params(("name" = String, Path, description = "agent name")),
|
|
request_body(content = ResourceLimitsForm, content_type = "application/x-www-form-urlencoded"),
|
|
responses(
|
|
(status = 200, description = "limits written", body = String),
|
|
(status = 400, description = "bad agent name"),
|
|
(status = 404, description = "no such agent"),
|
|
(status = 422, description = "invalid cpu_quota/memory_max value"),
|
|
(status = 500, description = "commit or drop-in write failed"),
|
|
),
|
|
tag = "lifecycle_ops"
|
|
)]
|
|
pub(super) async fn post_resource_limits(
|
|
State(state): State<AppState>,
|
|
AxumPath(name): AxumPath<String>,
|
|
Form(form): Form<ResourceLimitsForm>,
|
|
) -> 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(),
|
|
};
|
|
let cpu_quota = if form.cpu_quota.is_empty() {
|
|
None
|
|
} else {
|
|
Some(form.cpu_quota.as_str())
|
|
};
|
|
let memory_max = if form.memory_max.is_empty() {
|
|
None
|
|
} else {
|
|
Some(form.memory_max.as_str())
|
|
};
|
|
if let Some(v) = cpu_quota
|
|
&& let Err(e) = crate::resource_limits::validate_cpu_quota(v)
|
|
{
|
|
return (StatusCode::UNPROCESSABLE_ENTITY, e).into_response();
|
|
}
|
|
if let Some(v) = memory_max
|
|
&& let Err(e) = crate::resource_limits::validate_memory_max(v)
|
|
{
|
|
return (StatusCode::UNPROCESSABLE_ENTITY, e).into_response();
|
|
}
|
|
let limits = crate::resource_limits::AgentLimits {
|
|
cpu_quota: cpu_quota.map(str::to_owned),
|
|
memory_max: memory_max.map(str::to_owned),
|
|
};
|
|
if let Err(e) = crate::meta::commit_resource_limits(ident.as_str(), &limits).await {
|
|
return error_response(&format!("set limits {logical}: {e:#}"));
|
|
}
|
|
let agent_dir = crate::paths::agent_runtime_dir(ident.as_str());
|
|
let hive = state.coord.hive_env();
|
|
let paths = crate::coordinator::Coordinator::agent_paths(ident.as_str(), agent_dir);
|
|
if let Err(e) = crate::lifecycle::write_dropins(ident.as_str(), &hive, &paths).await {
|
|
return error_response(&format!("write_dropins {logical}: {e:#}"));
|
|
}
|
|
state.coord.rescan_containers_and_emit().await;
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
|
|
/// Queue a rebuild DAG for every live agent
|
|
/// container.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/update-all",
|
|
responses((status = 200, description = "rebuilds queued", body = String)),
|
|
tag = "lifecycle_ops"
|
|
)]
|
|
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;
|
|
};
|
|
if let Err(e) = state.coord.job_queue.insert(|b| {
|
|
crate::job_queue::templates::rebuild(b, &logical, true);
|
|
Vec::new()
|
|
}) {
|
|
tracing::error!(agent = %logical, error = ?e, "update-all: insert failed");
|
|
}
|
|
}
|
|
state.coord.emit_rebuild_queue_snapshot();
|
|
(StatusCode::OK, "ok").into_response()
|
|
}
|
|
|
|
#[derive(Deserialize, Default, ToSchema)]
|
|
pub(super) struct DestroyForm {
|
|
#[serde(default)]
|
|
purge: Option<String>,
|
|
}
|
|
|
|
/// Destroy `name`'s container.
|
|
///
|
|
/// Form field `purge` (any non-empty value, e.g. `"on"`) also wipes the
|
|
/// retained state dir instead of leaving a tombstone.
|
|
#[utoipa::path(
|
|
post,
|
|
path = "/api/destroy/{name}",
|
|
params(("name" = String, Path, description = "agent name")),
|
|
request_body(content = DestroyForm, content_type = "application/x-www-form-urlencoded"),
|
|
responses(
|
|
(status = 200, description = "destroyed", body = String),
|
|
(status = 400, description = "bad agent name"),
|
|
(status = 404, description = "no such agent"),
|
|
(status = 500, description = "destroy failed"),
|
|
),
|
|
tag = "lifecycle_ops"
|
|
)]
|
|
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:#}")),
|
|
}
|
|
}
|