hyperhive/hive-bash-mcp/src/protocol.rs

136 lines
5.3 KiB
Rust

//! Wire types for the unix socket protocol between `hive-bash-daemon`
//! and `hive-bash-mcp`. One JSON request line in, one JSON response line
//! out per connection. Connections are short-lived (per tool call).
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Task state (shared between runner and protocol)
// ---------------------------------------------------------------------------
/// Lifecycle state of a bash task.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
Pending,
Running,
Done,
TimedOut,
/// Daemon was restarted while the task was running; process is gone.
Interrupted,
/// Killed on request via `BashKill` (SIGINT or SIGKILL to the task's
/// process group). Distinct from `Interrupted` (daemon-restart) and
/// `TimedOut` (exceeded `timeout_secs`).
Killed,
}
/// Task metadata + result written to `<id>.json` under the tasks dir.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskFile {
pub id: String,
pub cmd: String,
/// Kill timeout in seconds. `None` means no timeout — task runs until
/// natural exit. Old task files with a numeric value are still readable
/// (serde coerces `u64` → `Some(u64)` is handled by the caller).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
pub status: TaskStatus,
pub created_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completed_at: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
/// Last [`crate::runner::SUMMARY_BYTES`] of stdout.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stdout_tail: Option<String>,
/// Last [`crate::runner::SUMMARY_BYTES`] of stderr.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stderr_tail: Option<String>,
}
// ---------------------------------------------------------------------------
// Request / response
// ---------------------------------------------------------------------------
/// Requests the MCP bridge sends to the daemon.
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DaemonRequest {
/// Liveness probe — fast round-trip that doesn't touch any subprocess.
Ping,
/// Submit a new bash task. Returns the task ID on success.
/// `wait_seconds`: optional inline poll (capped at 30s); when the
/// task finishes within the window the response carries the full
/// status payload. When it doesn't, the response carries just the
/// task ID so the caller can check back with `BashStatus`.
BashRun {
cmd: String,
#[serde(default)]
timeout_secs: Option<u64>,
/// Inline wait cap: 30s. Pass `None` or `0` to get the
/// task-started-id response immediately.
#[serde(default)]
wait_seconds: Option<u64>,
/// Optional caller-chosen task name, used as the task id (so it
/// flows into the wake `from`, status lookups, and the loose-ends
/// summary). Must be filesystem-safe. Reusable once any prior task
/// of the same name has finished; rejected while one is still
/// running. `None` falls back to the auto-generated id.
#[serde(default)]
name: Option<String>,
},
/// Query the current status of a task. Returns the full `TaskFile`
/// (formatted as text by the bridge). `wait_seconds`: optional
/// inline poll (capped at 30s) — daemon returns as soon as the
/// task reaches a terminal state or the window expires.
BashStatus {
id: String,
#[serde(default)]
wait_seconds: Option<u64>,
},
/// Return all tasks currently in `Pending` or `Running` state.
/// Used by the harness `get_loose_ends` to surface active
/// background work.
ActiveTasks,
/// Kill a running (or still-pending) task. `force = false` sends
/// `SIGINT` to the task's process group (graceful — the process can
/// clean up); `force = true` sends `SIGKILL` (immediate). Signalling
/// the whole process group reaps `sh -c` plus any children it spawned,
/// so a runaway grandchild (e.g. `cargo`/`nix`) is actually stopped.
BashKill {
id: String,
#[serde(default)]
force: bool,
},
}
/// Response shape from the daemon. `Ok` carries a JSON payload; `Error`
/// carries a human-readable message.
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DaemonResponse {
Ok { payload: serde_json::Value },
Error { message: String },
}
impl DaemonResponse {
/// Build an Ok response from any serialisable value.
pub fn ok<T: Serialize>(payload: &T) -> Self {
let payload = serde_json::to_value(payload)
.unwrap_or_else(|e| serde_json::json!({ "serialise_error": e.to_string() }));
Self::Ok { payload }
}
/// Build an Error response from any `Display` value.
pub fn error(msg: impl std::fmt::Display) -> Self {
Self::Error {
message: msg.to_string(),
}
}
}