hyperhive/hive-agent/src/web_ui/actions.rs

206 lines
8.4 KiB
Rust

//! Operator action POST handlers (send, cancel, compact, model, effort,
//! reset, todos mark-done).
use axum::{
Form,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use super::{AppState, SigintOutcome, error_response};
#[derive(Deserialize)]
pub(super) struct SendForm {
body: String,
}
pub(super) async fn post_send(
State(state): State<AppState>,
Form(form): Form<SendForm>,
) -> Response {
let body = form.body.trim().to_owned();
if body.is_empty() {
return error_response(StatusCode::BAD_REQUEST, "send: `body` required");
}
match super::broker_request(
&state.socket,
&hive_core_agent_sock::Request::OperatorMsg { body },
)
.await
{
// 200 instead of 303 → the client doesn't refetch /api/state.
// The operator message becomes a broker `Sent` (already shown
// server-side in the dashboard); on the agent side, the
// resulting `TurnStart` SSE event drives the terminal + the
// inbox row gets consumed by the time `TurnEnd` fires the
// existing turn-end refresh.
Ok(hive_core_agent_sock::Response::Ok) => {
(axum::http::StatusCode::OK, "ok").into_response()
}
Ok(hive_core_agent_sock::Response::Err { message }) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("send failed: {message}"),
),
Ok(other) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("send failed: unexpected response: {other:?}"),
),
Err(e) => super::broker_error_response(&e, "send"),
}
}
pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response {
let out = super::sigint_claude().await;
let note = match out {
SigintOutcome::Signalled => {
// A process actually got signalled — the *next* turn's wake
// prompt should tell the agent it was cut off mid-work. Only
// set on an actual signal: `NoProcess` means /cancel raced an
// already-finished turn, so there's nothing to flag as
// interrupted.
state
.interrupted
.store(true, std::sync::atomic::Ordering::Relaxed);
"operator: /cancel — sent SIGINT to claude".to_owned()
}
SigintOutcome::NoProcess => "operator: /cancel — no claude process to interrupt".to_owned(),
SigintOutcome::Failed(e) => format!("operator: /cancel — kill failed: {e}"),
};
state
.bus
.emit(crate::events::LiveEvent::Note { text: note });
(axum::http::StatusCode::OK, "ok").into_response()
}
/// Operator-initiated `/compact`. Deferred: sets the `compact_pending` flag
/// that `turn::drive_turn` consumes at the end of the current/next turn, so it
/// works while a turn is in flight (a mid-turn compaction would race the live
/// claude process) rather than only when the agent is idle. Returns 200
/// immediately; the compaction stream lands in the live panel when it runs.
pub(super) async fn post_compact(State(state): State<AppState>) -> Response {
state.bus.request_compact();
state.bus.emit(crate::events::LiveEvent::Note {
text: "operator: /compact queued — runs at the end of the current turn".into(),
});
(axum::http::StatusCode::OK, "ok").into_response()
}
/// Request a session reset. The current session is archived (its backing
/// `<uuid>.jsonl` renamed out of claude's resolution glob) at the next turn
/// boundary, so the following turn's `--resume` misses and self-heals into a
/// freshly-named session. History is preserved on disk, not deleted.
///
/// Deferred (a one-shot flag consumed by `drive_turn`) rather than applied
/// here: renaming the session file while a claude turn is mid-write would
/// race the live process. Between turns there is no open session file (one
/// claude per container, serialized by the serve loop), so the archive is
/// safe there. Useful when the session-resume context is poisoned (claude
/// went off the rails, hit an unrecoverable refusal, etc.) and a full reset
/// is cheaper than asking claude to forget mid-stream.
pub(super) async fn post_new_session(State(state): State<AppState>) -> Response {
state.bus.request_session_reset();
state.bus.emit(crate::events::LiveEvent::Note {
text: "operator: session reset queued — takes effect at the next turn".into(),
});
(axum::http::StatusCode::OK, "ok").into_response()
}
#[derive(Deserialize)]
pub(super) struct ModelForm {
model: String,
}
/// Switch the model for future turns. The current turn (if any)
/// keeps its model; `/model <name>` applies starting with the next
/// `recv` cycle. Empty / whitespace-only inputs are rejected. No
/// claude-side validation — we just hand the string through to
/// `claude --model <name>`; an unknown model surfaces as a turn
/// failure in the live panel and the operator can revert.
pub(super) async fn post_set_model(
State(state): State<AppState>,
Form(form): Form<ModelForm>,
) -> Response {
let name = form.model.trim();
if name.is_empty() {
return error_response(StatusCode::BAD_REQUEST, "model: name required");
}
state.bus.set_model(name);
state.bus.emit(crate::events::LiveEvent::Note {
text: format!("operator: /model — claude model set to '{name}' for future turns"),
});
tracing::info!(%name, "operator set model");
(axum::http::StatusCode::OK, "ok").into_response()
}
#[derive(Deserialize)]
pub(super) struct EffortForm {
effort: String,
}
/// Switch the claude effort level for future sessions. Operator-only
/// (the dashboard picker POSTs here through the gateway). Validated
/// server-side against [`crate::harness_state::EFFORT_LEVELS`] — an out-of-set
/// value is rejected rather than handed to `claude --effort`, since an
/// unknown level would fail every subsequent launch. Applies on the next
/// session start (no mid-session swap).
pub(super) async fn post_set_effort(
State(state): State<AppState>,
Form(form): Form<EffortForm>,
) -> Response {
let level = form.effort.trim();
if !crate::harness_state::is_valid_effort(level) {
return error_response(
StatusCode::BAD_REQUEST,
&format!(
"effort: level must be one of {}",
crate::harness_state::EFFORT_LEVELS.join(", ")
),
);
}
state.bus.set_effort(level);
state.bus.emit(crate::events::LiveEvent::Note {
text: format!("operator: /effort — claude effort set to '{level}' for future sessions"),
});
tracing::info!(%level, "operator set effort");
(axum::http::StatusCode::OK, "ok").into_response()
}
#[derive(Deserialize)]
pub(super) struct MarkTodosDoneForm {
/// Comma-separated todo ids. Same "one field, JS joins the checked
/// boxes" shape as `hive-c0re`'s `meta_inputs::MetaUpdateForm` — axum's
/// `Form` extractor doesn't natively decode repeated same-name keys.
ids: String,
}
/// `POST /api/todos/mark-done` — dismiss one or more of this agent's own
/// todos (loose-ends v2) from the todos flyout. Loops a `MarkTodoDone` call
/// per id over the in-agent socket rather than adding a new bulk request to
/// `hive-agent-sock`: the todos list is small (single-digit rows most of the
/// time), so N same-host socket round-trips isn't a real cost, and it keeps
/// the wire protocol's `Request` enum — already used by the `cancel_loose_end`
/// MCP tool — unchanged. Unknown/already-acked ids just don't add to the
/// `acked` count (same "acking twice is not a new action" semantics as the
/// single-id path); a request with no ids or where every id fails to parse
/// is rejected as a client error rather than silently acking nothing.
pub(super) async fn post_mark_todos_done(Form(form): Form<MarkTodosDoneForm>) -> Response {
let ids: Vec<i64> = form
.ids
.split(',')
.filter_map(|s| s.trim().parse::<i64>().ok())
.collect();
if ids.is_empty() {
return error_response(StatusCode::BAD_REQUEST, "mark-done: no todo ids selected");
}
let mut acked = 0u64;
for id in ids {
if let Some(hive_agent_sock::Response::Acked { count }) =
crate::todo_server::dial(&hive_agent_sock::Request::MarkTodoDone { id }).await
{
acked += count;
}
}
axum::Json(serde_json::json!({ "acked": acked })).into_response()
}