hivectl/dashboard: add --paused / ?paused=1 to agent start
This commit is contained in:
parent
c0f59089d4
commit
af3976a76a
4 changed files with 118 additions and 16 deletions
|
|
@ -26,9 +26,18 @@ import { el } from '@hive/shared/dom.js';
|
||||||
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
|
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
|
||||||
import '@hive/shared/hive-menu.js'; // registers <hive-menu> — side-effect import
|
import '@hive/shared/hive-menu.js'; // registers <hive-menu> — side-effect import
|
||||||
|
|
||||||
// Single-agent POST helper shared by all menu items.
|
// Single-agent POST helper shared by all menu items. `flags` is an object
|
||||||
async function agentMenuPost(actionPath, name, body, graceful) {
|
// of boolean query params to set truthy (e.g. `{ graceful: true }` or
|
||||||
const url = actionPath + encodeURIComponent(name) + (graceful ? '?graceful=true' : '');
|
// `{ paused: true }`) — every menu action so far needs at most one, but
|
||||||
|
// this stays a plain object rather than a single named arg so a future
|
||||||
|
// action needing two doesn't have to touch this signature again.
|
||||||
|
async function agentMenuPost(actionPath, name, body, flags) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(flags || {})) {
|
||||||
|
if (v) params.set(k, 'true');
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
const url = actionPath + encodeURIComponent(name) + (qs ? '?' + qs : '');
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(url, {
|
const resp = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -79,20 +88,25 @@ class HiveAgentMenu extends HTMLElement {
|
||||||
}, label);
|
}, label);
|
||||||
item.addEventListener('click', async () => {
|
item.addEventListener('click', async () => {
|
||||||
close();
|
close();
|
||||||
let graceful = false;
|
let flags = {};
|
||||||
if (opts.confirm) {
|
if (opts.confirm) {
|
||||||
|
const checkboxes = [];
|
||||||
|
if (opts.graceful) {
|
||||||
|
checkboxes.push({ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let the agent finish its turn and flush state before the container stops' });
|
||||||
|
}
|
||||||
|
if (opts.paused) {
|
||||||
|
checkboxes.push({ name: 'paused', label: opts.pausedLabel || 'start paused — come up without driving turns until resumed' });
|
||||||
|
}
|
||||||
const r = await themedConfirm({
|
const r = await themedConfirm({
|
||||||
message: opts.confirm,
|
message: opts.confirm,
|
||||||
danger: true,
|
danger: true,
|
||||||
confirmLabel: opts.confirmLabel || 'confirm',
|
confirmLabel: opts.confirmLabel || 'confirm',
|
||||||
checkboxes: opts.graceful
|
checkboxes,
|
||||||
? [{ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let the agent finish its turn and flush state before the container stops' }]
|
|
||||||
: [],
|
|
||||||
});
|
});
|
||||||
if (!r) return;
|
if (!r) return;
|
||||||
graceful = !!r.graceful;
|
flags = { graceful: !!r.graceful, paused: !!r.paused };
|
||||||
}
|
}
|
||||||
await agentMenuPost(opts.action, c.name, opts.body || null, graceful);
|
await agentMenuPost(opts.action, c.name, opts.body || null, flags);
|
||||||
});
|
});
|
||||||
li.append(item);
|
li.append(item);
|
||||||
return li;
|
return li;
|
||||||
|
|
@ -127,7 +141,12 @@ class HiveAgentMenu extends HTMLElement {
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
dropdown.append(
|
dropdown.append(
|
||||||
menuItem('▶ ST4RT', { action: '/api/start/', confirm: `start ${c.name}?` }),
|
menuItem('▶ ST4RT', {
|
||||||
|
action: '/api/start/',
|
||||||
|
confirm: `start ${c.name}?`,
|
||||||
|
paused: true,
|
||||||
|
pausedLabel: 'start paused — come up without driving turns until resumed',
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Pause/resume is orthogonal to running: a paused stopped agent boots
|
// Pause/resume is orthogonal to running: a paused stopped agent boots
|
||||||
|
|
|
||||||
|
|
@ -164,26 +164,64 @@ pub(super) async fn post_restart(
|
||||||
(StatusCode::OK, "ok").into_response()
|
(StatusCode::OK, "ok").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/start/{name}` — start `name`.
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/start/{name}?paused=1` — 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(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/start/{name}",
|
path = "/api/start/{name}",
|
||||||
params(("name" = String, Path, description = "agent name")),
|
params(
|
||||||
|
("name" = String, Path, description = "agent name"),
|
||||||
|
StartParams,
|
||||||
|
),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "start queued", body = String),
|
(status = 200, description = "start queued (or paused in place)", body = String),
|
||||||
(status = 400, description = "bad agent name"),
|
(status = 400, description = "bad agent name"),
|
||||||
(status = 404, description = "no such agent"),
|
(status = 404, description = "no such agent"),
|
||||||
|
(status = 500, description = "pause marker write failed"),
|
||||||
),
|
),
|
||||||
tag = "lifecycle_ops"
|
tag = "lifecycle_ops"
|
||||||
)]
|
)]
|
||||||
pub(super) async fn post_start(
|
pub(super) async fn post_start(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath(name): AxumPath<String>,
|
AxumPath(name): AxumPath<String>,
|
||||||
|
Query(params): Query<StartParams>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let logical = strip_container_prefix(&name);
|
let logical = strip_container_prefix(&name);
|
||||||
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||||
return reject;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
submit::start(
|
submit::start(
|
||||||
&state.coord,
|
&state.coord,
|
||||||
&logical,
|
&logical,
|
||||||
|
|
|
||||||
|
|
@ -43,13 +43,29 @@ async fn agents_restart(socket: &Path, name: &str, no_wait: bool) -> Result<()>
|
||||||
/// scope-based path), just scoped to this one name — a separate
|
/// scope-based path), just scoped to this one name — a separate
|
||||||
/// per-agent wire request would have duplicated logic this scope-based
|
/// per-agent wire request would have duplicated logic this scope-based
|
||||||
/// one already covers.
|
/// one already covers.
|
||||||
async fn agents_start(socket: &Path, name: &str) -> Result<()> {
|
///
|
||||||
|
/// `paused`: if the agent is already running, sets the pause marker in
|
||||||
|
/// place and returns without submitting a start DAG at all (nothing to
|
||||||
|
/// start). Otherwise sets the marker *before* the start request, so the
|
||||||
|
/// container comes up paused rather than racing the harness's own
|
||||||
|
/// pause-gate poll against a start that's already in flight — the same
|
||||||
|
/// "pausing a stopped agent makes it come up paused" guarantee `pause`
|
||||||
|
/// already provides on its own.
|
||||||
|
async fn agents_start(socket: &Path, name: &str, paused: bool) -> Result<()> {
|
||||||
if !crate::util::agent_exists(socket, name).await? {
|
if !crate::util::agent_exists(socket, name).await? {
|
||||||
bail!(
|
bail!(
|
||||||
"no such agent: '{name}' (no state dir under {}/) — use 'hivectl agent {name} create' to provision a brand-new agent",
|
"no such agent: '{name}' (no state dir under {}/) — use 'hivectl agent {name} create' to provision a brand-new agent",
|
||||||
hive_host_sock::AGENTS_ROOT
|
hive_host_sock::AGENTS_ROOT
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if paused {
|
||||||
|
let already_running = agent_running(socket, name).await?;
|
||||||
|
set_paused(socket, name, true).await?;
|
||||||
|
if already_running {
|
||||||
|
eprintln!("'{name}' is already running — paused in place, not (re)started");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
render(
|
render(
|
||||||
crate::client::request(
|
crate::client::request(
|
||||||
socket,
|
socket,
|
||||||
|
|
@ -64,6 +80,27 @@ async fn agents_start(socket: &Path, name: &str) -> Result<()> {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `name`'s container is currently running, per the same
|
||||||
|
/// `AgentStatus` roster `hivectl list-agents` renders. No per-agent wire
|
||||||
|
/// request exists for this (nor should one, for a single boolean a rarely
|
||||||
|
/// called CLI flag needs) — filter the hive-wide roster down to one row.
|
||||||
|
async fn agent_running(socket: &Path, name: &str) -> Result<bool> {
|
||||||
|
let resp = crate::client::request(socket, HostRequest::AgentStatus)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("connect to daemon socket {}", socket.display()))?;
|
||||||
|
if !resp.ok {
|
||||||
|
bail!(
|
||||||
|
"agent status: {}",
|
||||||
|
resp.error.as_deref().unwrap_or("unknown error")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(resp
|
||||||
|
.agent_statuses
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.name == name && r.running))
|
||||||
|
}
|
||||||
|
|
||||||
/// `hivectl agent <name> stop` — graceful-only stop (signal → drain →
|
/// `hivectl agent <name> stop` — graceful-only stop (signal → drain →
|
||||||
/// reconcile), never escalating to a hard kill. Reuses the hive-wide
|
/// reconcile), never escalating to a hard kill. Reuses the hive-wide
|
||||||
/// `hivectl stop --graceful` DAG (`HostRequest::Stop`'s scope-based
|
/// `hivectl stop --graceful` DAG (`HostRequest::Stop`'s scope-based
|
||||||
|
|
@ -198,7 +235,7 @@ pub(crate) async fn run_agent(socket: &Path, name: &str, cmd: AgentCmd) -> Resul
|
||||||
AgentCmd::Restart { no_wait } => agents_restart(socket, name, no_wait).await,
|
AgentCmd::Restart { no_wait } => agents_restart(socket, name, no_wait).await,
|
||||||
AgentCmd::Pause => set_paused(socket, name, true).await,
|
AgentCmd::Pause => set_paused(socket, name, true).await,
|
||||||
AgentCmd::Resume => set_paused(socket, name, false).await,
|
AgentCmd::Resume => set_paused(socket, name, false).await,
|
||||||
AgentCmd::Start => agents_start(socket, name).await,
|
AgentCmd::Start { paused } => agents_start(socket, name, paused).await,
|
||||||
AgentCmd::Create => {
|
AgentCmd::Create => {
|
||||||
let name = crate::util::parse_ident(name)?;
|
let name = crate::util::parse_ident(name)?;
|
||||||
render(crate::client::request(socket, HostRequest::Spawn { name }).await?)
|
render(crate::client::request(socket, HostRequest::Spawn { name }).await?)
|
||||||
|
|
|
||||||
|
|
@ -471,7 +471,15 @@ pub enum AgentCmd {
|
||||||
/// Start this EXISTING agent container. Fails immediately if `name`
|
/// Start this EXISTING agent container. Fails immediately if `name`
|
||||||
/// has no config/topology entry at all — it never attempts
|
/// has no config/topology entry at all — it never attempts
|
||||||
/// first-time creation. Use `create` for that.
|
/// first-time creation. Use `create` for that.
|
||||||
Start,
|
Start {
|
||||||
|
/// Start (or leave) the agent paused: if it's currently down, the
|
||||||
|
/// pause marker is written before the container boots, so it comes
|
||||||
|
/// up paused instead of driving turns immediately. If it's already
|
||||||
|
/// running, this pauses it in place and does not attempt a
|
||||||
|
/// (re)start.
|
||||||
|
#[arg(long)]
|
||||||
|
paused: bool,
|
||||||
|
},
|
||||||
/// Create this agent container from scratch (full first-time
|
/// Create this agent container from scratch (full first-time
|
||||||
/// provisioning), bypassing the approval queue.
|
/// provisioning), bypassing the approval queue.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue