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

157 lines
6.3 KiB
Rust

//! Operator action POST handlers (send, cancel, compact, model, effort, reset).
use axum::{
Form,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use super::{AppState, 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_sh4re::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_sh4re::Response::Ok) => (axum::http::StatusCode::OK, "ok").into_response(),
Ok(hive_sh4re::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 {
Ok(o) if o.status.success() => "operator: /cancel — sent SIGINT to claude".to_owned(),
Ok(o) if o.status.code() == Some(1) => {
"operator: /cancel — no claude process to interrupt".to_owned()
}
Ok(o) => format!(
"operator: /cancel — pkill exited {} stderr={}",
o.status,
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => format!("operator: /cancel — pkill 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()
}