206 lines
8.4 KiB
Rust
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()
|
|
}
|