//! Operator action POST handlers (send, cancel, compact, reset, todos //! mark-done). use axum::{ Form, extract::State, http::StatusCode, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use std::future::Future; use super::{AppState, SigintOutcome, 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_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) -> Response { if let Some(canceller) = state.bus.turn_canceller() { let note = if canceller.cancel() { // Same as a signalled claude turn: the next wake prompt says it // was cut off. state .interrupted .store(true, std::sync::atomic::Ordering::Relaxed); "operator: /cancel — sent session/cancel to the ACP agent" } else { "operator: /cancel — no ACP turn to cancel" }; state .bus .emit(crate::events::LiveEvent::Note { text: note.into() }); return (axum::http::StatusCode::OK, "ok").into_response(); } let out = super::sigint_claude(); 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) -> Response { // No wake prompt: the operator is watching the dashboard, not waiting on // an inbox message — `request_compact`'s wake-prompt arg exists for the // agent's own `compact` MCP tool (`todo_server.rs::compact`). state.bus.request_compact(None); 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 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. An id /// whose store call errors or gets no answer fails the request with a 500 /// naming how many failed. pub(super) async fn post_mark_todos_done(Form(form): Form) -> Response { let ids: Vec = form .ids .split(',') .filter_map(|s| s.trim().parse::().ok()) .collect(); if ids.is_empty() { return error_response(StatusCode::BAD_REQUEST, "mark-done: no todo ids selected"); } mark_ids_done(ids, |id| async move { crate::todo_server::dial(&hive_agent_sock::Request::MarkTodoDone { id }).await }) .await } /// The handler body behind `dial`, which answers one id's `MarkTodoDone` /// (`None` when the store doesn't answer). Tests inject it so they never /// reach the live in-agent socket. async fn mark_ids_done(ids: Vec, dial: F) -> Response where F: Fn(i64) -> Fut, Fut: Future>, { let mut answers = Vec::with_capacity(ids.len()); for id in ids { answers.push(dial(id).await); } match settle_mark_done(answers) { Ok(acked) => axum::Json(MarkTodosDoneBody { acked }).into_response(), Err(message) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &message), } } /// Folds the per-id answers into the acked total, or the operator-facing /// failure. `None` is a dial that got no answer; `Response::Err` and any /// other variant are failures. `Acked { count: 0 }` is a success. fn settle_mark_done(answers: Vec>) -> Result { let total = answers.len(); let mut acked = 0u64; let mut failed = 0usize; let mut first_error = None; for answer in answers { match answer { Some(hive_agent_sock::Response::Acked { count }) => acked += count, Some(hive_agent_sock::Response::Err { message }) => { failed += 1; first_error.get_or_insert(message); } Some(other) => { failed += 1; first_error.get_or_insert(format!("unexpected response: {other:?}")); } None => { failed += 1; first_error.get_or_insert_with(|| "the todo store did not answer".to_owned()); } } } match first_error { None => Ok(acked), Some(error) => Err(format!( "mark-done: {failed} of {total} failed ({acked} marked done): {error}" )), } } #[derive(Serialize)] struct MarkTodosDoneBody { acked: u64, } #[cfg(test)] mod tests { use super::{mark_ids_done, settle_mark_done}; async fn body_text(response: axum::response::Response) -> String { let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await .unwrap(); String::from_utf8(bytes.to_vec()).unwrap() } #[tokio::test] async fn mark_done_handler_answers_500_with_the_message_when_the_store_does_not_answer() { let response = mark_ids_done(vec![1, 2], |_| async { None }).await; assert_eq!( response.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR ); let body = body_text(response).await; assert!(body.contains("2 of 2 failed"), "{body}"); assert!(body.contains("did not answer"), "{body}"); } #[tokio::test] async fn mark_done_handler_answers_500_with_the_store_error() { let response = mark_ids_done(vec![7], |_| async { Some(hive_agent_sock::Response::Err { message: "database is locked".to_owned(), }) }) .await; assert_eq!( response.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR ); assert!(body_text(response).await.contains("database is locked")); } #[tokio::test] async fn mark_done_handler_answers_200_with_the_acked_total() { let response = mark_ids_done(vec![1, 2], |_| async { Some(hive_agent_sock::Response::Acked { count: 1 }) }) .await; assert_eq!(response.status(), axum::http::StatusCode::OK); assert_eq!(body_text(response).await, r#"{"acked":2}"#); } #[test] fn mark_done_with_no_answer_is_an_error() { let err = settle_mark_done(vec![None]).unwrap_err(); assert!(err.contains("1 of 1 failed"), "{err}"); } #[test] fn mark_done_surfaces_the_store_error() { let answers = vec![Some(hive_agent_sock::Response::Err { message: "database is locked".to_owned(), })]; let err = settle_mark_done(answers).unwrap_err(); assert!(err.contains("database is locked"), "{err}"); } #[test] fn mark_done_partial_failure_reports_both_counts() { let answers = vec![Some(hive_agent_sock::Response::Acked { count: 1 }), None]; let err = settle_mark_done(answers).unwrap_err(); assert!(err.contains("1 of 2 failed (1 marked done)"), "{err}"); } #[test] fn mark_done_acking_nothing_new_is_still_success() { let answers = vec![ Some(hive_agent_sock::Response::Acked { count: 0 }), Some(hive_agent_sock::Response::Acked { count: 2 }), ]; assert_eq!(settle_mark_done(answers).unwrap(), 2); } }