157 lines
6.3 KiB
Rust
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()
|
|
}
|