//! 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, Form(form): Form, ) -> 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) -> 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) -> 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 /// `.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) -> 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 ` 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 `; 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, Form(form): Form, ) -> 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, Form(form): Form, ) -> 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() }