pub(crate) struct ControlReply {
pub(crate) ok: bool,
pub(crate) error: Option<String>,
pub(crate) body: serde_json::Value,
}
pub(crate) fn post_control(url: &str, route: &str, body: &str) -> Result<ControlReply, String> {
let addr = url.strip_prefix("http://").unwrap_or(url);
let mut stream = std::net::TcpStream::connect(addr).map_err(|err| err.to_string())?;
let timeout = Duration::from_secs(15);
let _ = stream.set_read_timeout(Some(timeout));
let _ = stream.set_write_timeout(Some(timeout));
let request = format!(
"POST {route} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(request.as_bytes()).map_err(|err| err.to_string())?;
let mut response = Vec::new();
stream.read_to_end(&mut response).map_err(|err| err.to_string())?;
let split = response
.windows(4)
.position(|window| window == b"\r\n\r\n")
.ok_or_else(|| "malformed HTTP response".to_string())?;
let parsed: serde_json::Value = serde_json::from_slice(&response[split + 4..])
.map_err(|err| format!("invalid response body: {err}"))?;
Ok(ControlReply {
ok: parsed.get("ok").and_then(serde_json::Value::as_bool).unwrap_or(false),
error: parsed
.get("error")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
body: parsed,
})
}
fn no_control_server() -> String {
"this run serves no control endpoint — restart it with `--dashboard` to intervene or \
release gates from an attached surface"
.to_string()
}
pub(crate) struct ControlInterveneSink {
control_url: Option<String>,
}
impl ControlInterveneSink {
pub(crate) fn new(control_url: Option<String>) -> Self {
Self { control_url }
}
}
impl rhei_tui::InterveneSink for ControlInterveneSink {
fn deliver(
&self,
task_id: Option<&str>,
slot: Option<rhei_tui::Slot>,
message: &str,
) -> Result<(), String> {
let url = self.control_url.as_deref().ok_or_else(no_control_server)?;
let body =
serde_json::json!({ "task_id": task_id, "slot": slot, "message": message }).to_string();
let reply = post_control(url, "/intervene", &body)
.map_err(|err| format!("could not reach the run at {url}: {err}"))?;
if reply.ok {
Ok(())
} else {
Err(reply.error.unwrap_or_else(|| "intervention not delivered".to_string()))
}
}
fn reachable(&self, task_id: &str, slot: Option<rhei_tui::Slot>) -> bool {
let Some(url) = self.control_url.as_deref() else {
return false;
};
let Some(snapshot) = fetch_control_snapshot(url) else {
return false;
};
let Some(runtime) = snapshot.get("task_runtime").and_then(|r| r.get(task_id)) else {
return false;
};
let slot_matches = slot.is_none_or(|wanted| {
runtime.get("in_slot").and_then(serde_json::Value::as_u64) == Some(u64::from(wanted))
});
slot_matches
&& runtime.get("intervene").and_then(serde_json::Value::as_bool).unwrap_or(false)
}
}
pub(crate) struct ControlGateSink {
control_url: Option<String>,
}
impl ControlGateSink {
pub(crate) fn new(control_url: Option<String>) -> Self {
Self { control_url }
}
}
impl rhei_tui::GateTransitionSink for ControlGateSink {
fn transition_gate(
&self,
task_id: &str,
from: &str,
to: &str,
result: Option<&str>,
) -> Result<String, String> {
let url = self.control_url.as_deref().ok_or_else(no_control_server)?;
let body =
serde_json::json!({ "task_id": task_id, "from": from, "to": to, "result": result })
.to_string();
let reply = post_control(url, "/transition-gate", &body)
.map_err(|err| format!("could not reach the run at {url}: {err}"))?;
if reply.ok {
Ok(reply
.body
.get("to")
.and_then(serde_json::Value::as_str)
.unwrap_or(to)
.to_string())
} else {
Err(reply.error.unwrap_or_else(|| "the run refused the gate release".to_string()))
}
}
}
fn fetch_control_snapshot(url: &str) -> Option<serde_json::Value> {
let addr = url.strip_prefix("http://").unwrap_or(url);
let mut stream = std::net::TcpStream::connect(addr).ok()?;
let timeout = Duration::from_secs(5);
let _ = stream.set_read_timeout(Some(timeout));
let _ = stream.set_write_timeout(Some(timeout));
stream
.write_all(b"GET /snapshot HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.ok()?;
let mut response = Vec::new();
stream.read_to_end(&mut response).ok()?;
let split = response.windows(4).position(|window| window == b"\r\n\r\n")?;
serde_json::from_slice(&response[split + 4..]).ok()
}