pub mod entities;
pub mod gpu;
pub mod sse;
use std::io::Read;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use serde_json::{json, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GwErrorKind {
Http,
Unreachable,
Timeout,
BodyTooLarge,
Transport,
}
#[derive(Debug, Clone)]
pub struct GwError {
pub status: Option<u16>,
pub kind: GwErrorKind,
pub message: String,
}
impl GwError {
pub fn http(code: u16, message: impl Into<String>) -> GwError {
GwError {
status: Some(code),
kind: GwErrorKind::Http,
message: message.into(),
}
}
pub fn unreachable(message: impl Into<String>) -> GwError {
GwError {
status: None,
kind: GwErrorKind::Unreachable,
message: message.into(),
}
}
pub fn timeout(message: impl Into<String>) -> GwError {
GwError {
status: None,
kind: GwErrorKind::Timeout,
message: message.into(),
}
}
pub fn body_too_large(message: impl Into<String>) -> GwError {
GwError {
status: None,
kind: GwErrorKind::BodyTooLarge,
message: message.into(),
}
}
pub fn transport(message: impl Into<String>) -> GwError {
GwError {
status: None,
kind: GwErrorKind::Transport,
message: message.into(),
}
}
pub fn from_io_read(message: String, e: &std::io::Error) -> GwError {
match e.kind() {
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => {
GwError::timeout(message)
}
std::io::ErrorKind::ConnectionRefused
| std::io::ErrorKind::HostUnreachable
| std::io::ErrorKind::NetworkUnreachable => GwError::unreachable(message),
_ => GwError::transport(message),
}
}
pub fn is_gone(&self) -> bool {
self.kind == GwErrorKind::Unreachable
}
pub fn is_transient(&self) -> bool {
self.status.is_none() && self.kind != GwErrorKind::BodyTooLarge
}
pub fn compact_reason(&self) -> String {
match (self.status, self.kind) {
(Some(code), _) => format!("HTTP {code}"),
(None, GwErrorKind::Unreachable) => "gateway unreachable".into(),
(None, GwErrorKind::Timeout) => "request timed out".into(),
(None, GwErrorKind::BodyTooLarge) => "response exceeded the reader cap".into(),
(None, _) => "transient transport failure".into(),
}
}
}
impl std::fmt::Display for GwError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match (self.status, self.kind) {
(Some(code), _) => write!(f, "gateway HTTP {code}: {}", self.message),
(None, GwErrorKind::Unreachable) => {
write!(f, "gateway unreachable: {}", self.message)
}
(None, GwErrorKind::Timeout) => write!(f, "gateway timed out: {}", self.message),
(None, GwErrorKind::BodyTooLarge) => {
write!(f, "response too large: {}", self.message)
}
(None, _) => write!(f, "gateway request failed: {}", self.message),
}
}
}
impl std::error::Error for GwError {}
pub type GwResult<T> = Result<T, GwError>;
pub(crate) fn err_from_ureq(label: &str, e: ureq::Error) -> GwError {
match e {
ureq::Error::Status(code, resp) => {
let body = resp.into_string().unwrap_or_default();
let detail = serde_json::from_str::<Value>(&body)
.ok()
.and_then(|v| {
v.get("detail")
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| v.get("error").map(|e| e.to_string()))
})
.unwrap_or_else(|| {
let t = body.trim();
if t.is_empty() {
format!("{label} failed")
} else {
t.to_string()
}
});
GwError::http(code, detail)
}
ureq::Error::Transport(t) => {
let kind = classify_transport(&t);
GwError {
status: None,
kind,
message: format!("{label}: {t}"),
}
}
}
}
fn classify_transport(t: &ureq::Transport) -> GwErrorKind {
use ureq::ErrorKind as K;
match t.kind() {
K::ConnectionFailed
| K::Dns
| K::InvalidUrl
| K::UnknownScheme
| K::InsecureRequestHttpsOnly
| K::ProxyConnect
| K::ProxyUnauthorized
| K::InvalidProxyUrl => GwErrorKind::Unreachable,
K::Io => {
let io_kind = std::error::Error::source(t)
.and_then(|s| s.downcast_ref::<std::io::Error>())
.map(std::io::Error::kind);
match io_kind {
Some(std::io::ErrorKind::TimedOut) | Some(std::io::ErrorKind::WouldBlock) => {
GwErrorKind::Timeout
}
Some(std::io::ErrorKind::ConnectionRefused)
| Some(std::io::ErrorKind::HostUnreachable)
| Some(std::io::ErrorKind::NetworkUnreachable) => GwErrorKind::Unreachable,
_ => GwErrorKind::Transport,
}
}
K::BadStatus | K::BadHeader | K::TooManyRedirects | K::HTTP => GwErrorKind::Transport,
}
}
const MAX_JSON_BODY_BYTES: u64 = 256 * 1024 * 1024;
fn read_body_capped(resp: ureq::Response, path: &str, cap: u64) -> GwResult<String> {
use std::io::Read;
let len_hint = resp
.header("Content-Length")
.and_then(|v| v.parse::<u64>().ok());
let mut buf: Vec<u8> = Vec::with_capacity(len_hint.unwrap_or(64 * 1024).min(cap) as usize);
let mut reader = resp.into_reader().take(cap + 1);
reader
.read_to_end(&mut buf)
.map_err(|e| GwError::from_io_read(format!("{path}: read failed: {e}"), &e))?;
if buf.len() as u64 > cap {
return Err(GwError::body_too_large(format!(
"{path}: response body exceeds the {} MiB reader ceiling{} — refusing to buffer (never truncating)",
cap / (1024 * 1024),
len_hint
.map(|l| format!(" (Content-Length {l} bytes)"))
.unwrap_or_default()
)));
}
String::from_utf8(buf)
.map_err(|e| GwError::transport(format!("{path}: body is not UTF-8: {e}")))
}
pub(crate) fn read_body_capped_for_tests(resp: ureq::Response, path: &str) -> GwResult<String> {
read_body_capped(resp, path, MAX_JSON_BODY_BYTES)
}
#[derive(Clone)]
pub struct GatewayClient {
base_url: String,
token: Option<String>,
agent: ureq::Agent,
stream_agent: ureq::Agent,
}
impl GatewayClient {
pub fn new(base_url: &str, token: Option<&str>) -> GatewayClient {
let agent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(5))
.timeout_read(Duration::from_secs(60))
.timeout_write(Duration::from_secs(30))
.build();
let stream_agent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(5))
.timeout_read(Duration::from_secs(75))
.build();
GatewayClient {
base_url: base_url.trim_end_matches('/').to_string(),
token: token.map(str::to_string).filter(|t| !t.is_empty()),
agent,
stream_agent,
}
}
fn url(&self, path: &str) -> String {
format!("{}/api/gateway{}", self.base_url, path)
}
pub(crate) fn connection(&self) -> (String, Option<String>) {
(self.base_url.clone(), self.token.clone())
}
fn with_auth(&self, req: ureq::Request) -> ureq::Request {
match &self.token {
Some(t) => req.set("Authorization", &format!("Bearer {t}")),
None => req,
}
}
fn get_json(&self, path: &str) -> GwResult<Value> {
let req = self.with_auth(
self.agent
.get(&self.url(path))
.set("Accept", "application/json"),
);
let resp = req.call().map_err(|e| err_from_ureq(path, e))?;
let body = read_body_capped(resp, path, MAX_JSON_BODY_BYTES)?;
serde_json::from_str(&body)
.map_err(|e| GwError::transport(format!("{path}: invalid JSON: {e}")))
}
fn post_json(&self, path: &str, payload: &Value) -> GwResult<Value> {
self.post_json_via(&self.agent, path, payload)
}
fn post_json_via(&self, agent: &ureq::Agent, path: &str, payload: &Value) -> GwResult<Value> {
let req = self.with_auth(
agent
.post(&self.url(path))
.set("Accept", "application/json")
.set("Content-Type", "application/json"),
);
let resp = req
.send_string(&payload.to_string())
.map_err(|e| err_from_ureq(path, e))?;
let body = read_body_capped(resp, path, MAX_JSON_BODY_BYTES)?;
if body.trim().is_empty() {
return Ok(Value::Null);
}
serde_json::from_str(&body)
.map_err(|e| GwError::transport(format!("{path}: invalid JSON: {e}")))
}
pub fn ping(&self) -> GwResult<Value> {
self.get_json("/ping")
}
pub fn upload_attachment(
&self,
session_id: &str,
filename: &str,
bytes: &[u8],
) -> GwResult<Value> {
let path = "/attachments/upload";
let boundary = format!("acodeb{}", crate::config::mint_session_id());
let body = encode_multipart(&boundary, session_id, filename, bytes);
let req = self.with_auth(
self.agent
.post(&self.url(path))
.set("Accept", "application/json")
.set(
"Content-Type",
&format!("multipart/form-data; boundary={boundary}"),
),
);
let resp = req.send_bytes(&body).map_err(|e| err_from_ureq(path, e))?;
let body = read_body_capped(resp, path, MAX_JSON_BODY_BYTES)?;
let v: Value = serde_json::from_str(&body)
.map_err(|e| GwError::transport(format!("{path}: invalid JSON: {e}")))?;
attachment_ref_from_response(&v)
.ok_or_else(|| GwError::transport(format!("{path}: response carried no artifact ref")))
}
pub fn list_bundles(&self) -> GwResult<Value> {
self.get_json("/bundles?all_versions=false&include_deprecated=false")
}
pub fn discovery_providers(&self, include_models: bool) -> GwResult<Value> {
self.get_json(&format!(
"/discovery/providers?include_models={}",
if include_models { "true" } else { "false" }
))
}
pub fn discovery_tools(&self) -> GwResult<Value> {
self.get_json("/discovery/tools")
}
pub fn workspace_policy(&self) -> GwResult<Value> {
self.get_json("/workspace/policy")
}
pub fn skills(&self) -> GwResult<Value> {
self.get_json("/skills")
}
pub fn mcp_servers(&self) -> GwResult<Value> {
self.get_json("/mcp/servers")
}
pub fn capability_defaults(&self) -> GwResult<Value> {
self.get_json("/config/capability-defaults")
}
pub fn provider_models(&self, provider: &str) -> GwResult<Value> {
self.get_json(&format!(
"/discovery/providers/{}/models",
url_encode(provider)
))
}
pub fn prompt_cache_capabilities(&self, provider: &str, model: &str) -> GwResult<Value> {
self.get_json(&format!(
"/prompt_cache/capabilities?provider={}&model={}",
url_encode(provider),
url_encode(model)
))
}
pub fn host_gpu_metrics(&self) -> GwResult<Value> {
self.get_json("/host/metrics/gpu")
}
pub fn discovery_capabilities(&self) -> GwResult<Value> {
self.get_json("/discovery/capabilities")
}
pub fn host_state(&self) -> GwResult<Value> {
self.get_json("/host/state")
}
pub fn unload_model(&self, provider: &str, model: &str, force: bool) -> GwResult<Value> {
self.post_json(
"/models/unload",
&json!({"provider": provider, "model": model, "force": force}),
)
}
pub fn lock_model(&self, provider: &str, model: &str) -> GwResult<Value> {
self.post_json(
"/models/lock",
&json!({"provider": provider, "model": model}),
)
}
pub fn unlock_model(&self, provider: &str, model: &str) -> GwResult<Value> {
self.post_json(
"/models/unlock",
&json!({"provider": provider, "model": model}),
)
}
pub fn context_estimate(
&self,
provider: &str,
model: &str,
context_length: Option<u64>,
) -> GwResult<Value> {
let mut path = format!(
"/models/context_estimate?provider={}&model={}",
url_encode(provider),
url_encode(model)
);
if let Some(n) = context_length {
path.push_str(&format!("&context_length={n}"));
}
self.get_json(&path)
}
pub fn start_run(
&self,
flow_id: &str,
bundle_id: Option<&str>,
session_id: Option<&str>,
input_data: Value,
) -> GwResult<String> {
let mut body = json!({ "flow_id": flow_id, "input_data": input_data });
if let Some(b) = bundle_id {
if !b.trim().is_empty() {
body["bundle_id"] = json!(b.trim());
}
}
if let Some(s) = session_id {
if !s.trim().is_empty() {
body["session_id"] = json!(s.trim());
}
}
let resp = self.post_json("/runs/start", &body)?;
let run_id = resp
.get("run_id")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
if run_id.is_empty() {
return Err(GwError::transport(format!(
"runs/start: missing run_id in {resp}"
)));
}
Ok(run_id)
}
pub fn get_run(&self, run_id: &str) -> GwResult<Value> {
self.get_json(&format!("/runs/{}", url_encode(run_id)))
}
pub fn list_runs(&self, session_id: &str, limit: u32) -> GwResult<Value> {
self.get_json(&format!(
"/runs?limit={limit}&session_id={}&root_only=true&include_ledger_len=false",
url_encode(session_id)
))
}
pub fn get_ledger(&self, run_id: &str, after: u64, limit: u32) -> GwResult<(Vec<Value>, u64)> {
let v = self.get_json(&format!(
"/runs/{}/ledger?after={after}&limit={limit}",
url_encode(run_id)
))?;
let items = v
.get("items")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let next = v
.get("next_after")
.and_then(Value::as_u64)
.unwrap_or(after + items.len() as u64);
Ok((items, next))
}
pub fn artifact_bytes(
&self,
run_id: &str,
artifact_id: &str,
max_bytes: usize,
) -> GwResult<(Vec<u8>, String)> {
let path = format!(
"/runs/{}/artifacts/{}/content",
url_encode(run_id),
url_encode(artifact_id)
);
let req = self.with_auth(self.agent.get(&self.url(&path)));
let resp = req.call().map_err(|e| err_from_ureq(&path, e))?;
let content_type = resp.content_type().to_string();
let mut bytes = Vec::new();
resp.into_reader()
.take(max_bytes as u64 + 1)
.read_to_end(&mut bytes)
.map_err(|e| GwError::from_io_read(format!("artifact read failed: {e}"), &e))?;
if bytes.len() > max_bytes {
return Err(GwError::transport(format!(
"artifact larger than {max_bytes} bytes; not rendering inline"
)));
}
Ok((bytes, content_type))
}
pub fn submit_command(&self, run_id: &str, typ: &str, payload: Value) -> GwResult<Value> {
self.submit_command_with_id(&mint_command_id(), run_id, typ, payload)
}
pub fn submit_command_with_id(
&self,
command_id: &str,
run_id: &str,
typ: &str,
payload: Value,
) -> GwResult<Value> {
let body = json!({
"command_id": command_id,
"run_id": run_id,
"type": typ,
"payload": payload,
"client_id": "abstractcode",
});
self.post_json("/commands", &body)
}
pub fn submit_command_retried(
&self,
command_id: &str,
run_id: &str,
typ: &str,
payload: Value,
) -> (GwResult<Value>, bool) {
let ambiguous = |e: &GwError| e.status.is_none() && !e.is_gone();
let first = self.submit_command_with_id(command_id, run_id, typ, payload.clone());
let Err(e) = &first else {
return (first, false);
};
let saw_ambiguous = ambiguous(e);
if !e.is_transient() {
return (first, saw_ambiguous);
}
let second = self.submit_command_on_a_fresh_connection(command_id, run_id, typ, payload);
let saw_ambiguous = saw_ambiguous || second.as_ref().err().is_some_and(ambiguous);
(second, saw_ambiguous)
}
fn submit_command_on_a_fresh_connection(
&self,
command_id: &str,
run_id: &str,
typ: &str,
payload: Value,
) -> GwResult<Value> {
let body = json!({
"command_id": command_id,
"run_id": run_id,
"type": typ,
"payload": payload,
"client_id": "abstractcode",
});
let agent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(5))
.timeout_read(Duration::from_secs(60))
.timeout_write(Duration::from_secs(30))
.build();
self.post_json_via(&agent, "/commands", &body)
}
pub fn model_capabilities(&self, provider: &str, model: &str) -> GwResult<Value> {
let name = if provider.is_empty() {
model.to_string()
} else {
format!("{provider}/{model}")
};
self.get_json(&format!(
"/discovery/models/capabilities?model_name={}",
url_encode(&name)
))
}
pub fn resume(&self, run_id: &str, wait_key: &str, payload: Value) -> GwResult<Value> {
self.submit_command(
run_id,
"resume",
json!({ "wait_key": wait_key, "payload": payload }),
)
}
pub fn cancel(&self, run_id: &str) -> GwResult<Value> {
self.submit_command(run_id, "cancel", json!({}))
}
pub fn pause(&self, run_id: &str) -> GwResult<Value> {
self.submit_command(run_id, "pause", json!({}))
}
pub fn resume_paused(&self, run_id: &str) -> GwResult<Value> {
self.submit_command(run_id, "resume", json!({}))
}
pub fn steer(&self, run_id: &str, guidance: &str) -> (GwResult<Value>, bool) {
self.submit_command_retried(
&mint_command_id(),
run_id,
"inject_guidance",
json!({ "guidance": guidance }),
)
}
pub fn list_recent_runs(&self, limit: u32) -> GwResult<Value> {
self.get_json(&Self::session_listing_path(limit))
}
pub(crate) fn session_listing_path(limit: u32) -> String {
format!(
"/runs?limit={}&root_only=true&include_ledger_len=false",
limit.max(1)
)
}
pub fn history_bundle(
&self,
run_id: &str,
include_session: bool,
turn_limit: u32,
) -> GwResult<Value> {
let mut path = format!("/runs/{}/history_bundle?detail=replay", url_encode(run_id));
if include_session {
path.push_str(&format!(
"&include_session=true&session_turn_limit={}",
turn_limit.clamp(1, 500)
));
}
self.get_json(&path)
}
pub fn input_data(&self, run_id: &str) -> GwResult<Value> {
self.get_json(&format!("/runs/{}/input_data", url_encode(run_id)))
}
pub fn stream_ledger(
&self,
run_id: &str,
after: u64,
stop: &Arc<AtomicBool>,
mut on_cursor: impl FnMut(u64),
mut on_batch: impl FnMut(Vec<Value>),
mut on_skipped: impl FnMut(usize),
) -> GwResult<bool> {
let path = format!("/runs/{}/ledger/stream?after={after}", url_encode(run_id));
let req = self.with_auth(
self.stream_agent
.get(&self.url(&path))
.set("Accept", "text/event-stream"),
);
let resp = req.call().map_err(|e| err_from_ureq(&path, e))?;
let mut reader = resp.into_reader();
let mut parser = sse::SseParser::new();
let mut buf = [0u8; 16 * 1024];
loop {
if stop.load(Ordering::Relaxed) {
return Ok(false);
}
let n = match reader.read(&mut buf) {
Ok(0) => return Ok(false), Ok(n) => n,
Err(e) => {
let kind = e.kind();
if matches!(
kind,
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) {
return Ok(false);
}
return Err(GwError::from_io_read(
format!("stream read failed: {e}"),
&e,
));
}
};
let mut records: Vec<Value> = Vec::new();
let mut skipped = 0usize;
let mut saw_done = false;
for ev in parser.push(&buf[..n]) {
match ev.event.as_str() {
"step" => match parse_step_data(&ev.data) {
Ok((cursor, record)) => {
on_cursor(cursor);
match record {
Some(r) => records.push(r),
None => skipped += 1,
}
}
Err(()) => skipped += 1,
},
"done" => saw_done = true,
_ => {}
}
}
if !records.is_empty() {
on_batch(records);
}
if skipped > 0 {
on_skipped(skipped);
}
if saw_done {
return Ok(true);
}
}
}
}
pub(crate) fn parse_step_data(data: &str) -> Result<(u64, Option<Value>), ()> {
let v: Value = serde_json::from_str(data).map_err(|_| ())?;
let cursor = v.get("cursor").and_then(Value::as_u64).unwrap_or(0);
let record = v.get("record").filter(|r| r.is_object()).cloned();
Ok((cursor, record))
}
pub(crate) fn encode_multipart(
boundary: &str,
session_id: &str,
filename: &str,
bytes: &[u8],
) -> Vec<u8> {
let field_name: String = filename.chars().filter(|c| !c.is_control()).collect();
let header_name: String = field_name.chars().filter(|c| *c != '"').collect();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len() + 512);
let text_part = |name: &str, value: &str, out: &mut Vec<u8>| {
out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
out.extend_from_slice(
format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n").as_bytes(),
);
out.extend_from_slice(value.as_bytes());
out.extend_from_slice(b"\r\n");
};
text_part("session_id", session_id, &mut out);
text_part("filename", &field_name, &mut out);
let ctype = guess_content_type(filename);
text_part("content_type", ctype, &mut out);
out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
out.extend_from_slice(
format!(
"Content-Disposition: form-data; name=\"file\"; filename=\"{header_name}\"\r\n\
Content-Type: {ctype}\r\n\r\n"
)
.as_bytes(),
);
out.extend_from_slice(bytes);
out.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
out
}
pub(crate) fn guess_content_type(name: &str) -> &'static str {
let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
match ext.as_str() {
"txt" | "log" | "text" => "text/plain",
"md" | "markdown" => "text/markdown",
"json" => "application/json",
"yaml" | "yml" => "application/yaml",
"xml" => "application/xml",
"html" | "htm" => "text/html",
"css" => "text/css",
"csv" => "text/csv",
"js" | "mjs" => "text/javascript",
"ts" | "tsx" | "jsx" | "py" | "rs" | "go" | "java" | "c" | "h" | "cpp" | "hpp" | "sh"
| "bash" | "zsh" | "toml" | "ini" | "cfg" | "sql" | "rb" | "php" | "swift" | "kt"
| "tex" | "rst" => "text/plain",
"pdf" => "application/pdf",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"bmp" => "image/bmp",
"tif" | "tiff" => "image/tiff",
"svg" => "application/xml",
"wav" => "audio/wav",
"mp3" => "audio/mpeg",
"mp4" => "video/mp4",
"zip" => "application/zip",
_ => "application/octet-stream",
}
}
pub(crate) fn attachment_ref_from_response(v: &Value) -> Option<Value> {
for key in ["artifact", "attachment"] {
if let Some(r) = v.get(key) {
if r.get("$artifact").and_then(Value::as_str).is_some() {
return Some(r.clone());
}
}
}
None
}
pub fn mint_command_id() -> String {
format!(
"cmd_{}",
crate::config::mint_session_id().trim_start_matches("acode-")
)
}
pub fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_session_listing_query_is_exactly_what_the_gateway_accepts() {
let p = GatewayClient::session_listing_path(200);
assert_eq!(p, "/runs?limit=200&root_only=true&include_ledger_len=false");
assert!(!p.contains("session_id"));
assert!(GatewayClient::session_listing_path(0).contains("limit=1"));
assert!(GatewayClient::session_listing_path(5000).contains("limit=5000"));
}
#[test]
fn capped_reader_parses_big_bodies_and_refuses_over_cap_honestly() {
let big = format!("{{\"pad\": \"{}\"}}", "x".repeat(12 * 1024 * 1024));
let resp = ureq::Response::new(200, "OK", &big).expect("test response");
let body = read_body_capped(resp, "/test", MAX_JSON_BODY_BYTES)
.expect("12 MiB passes the 256 MiB ceiling");
assert_eq!(body.len(), big.len(), "no silent truncation");
assert!(serde_json::from_str::<Value>(&body).is_ok());
let resp = ureq::Response::new(200, "OK", &big).expect("test response");
let err = read_body_capped(resp, "/test", 1024 * 1024).expect_err("over-cap refuses");
assert_eq!(err.kind, GwErrorKind::BodyTooLarge);
assert!(
err.to_string().contains("1 MiB") && err.to_string().contains("never truncating"),
"the error names the ceiling: {err}"
);
assert!(!err.is_gone(), "a big body is never gateway-down evidence");
assert!(!err.is_transient(), "deterministic — never retried");
}
#[test]
fn multipart_encoding_is_byte_exact_and_sanitizes_header_names() {
let body = encode_multipart("BB", "sid-1", "re\"po\nrt.pdf", b"DATA");
let s = String::from_utf8_lossy(&body);
assert_eq!(
s,
"--BB\r\nContent-Disposition: form-data; name=\"session_id\"\r\n\r\nsid-1\r\n\
--BB\r\nContent-Disposition: form-data; name=\"filename\"\r\n\r\nre\"port.pdf\r\n\
--BB\r\nContent-Disposition: form-data; name=\"content_type\"\r\n\r\napplication/pdf\r\n\
--BB\r\nContent-Disposition: form-data; name=\"file\"; filename=\"report.pdf\"\r\n\
Content-Type: application/pdf\r\n\r\nDATA\r\n--BB--\r\n"
);
assert_eq!(guess_content_type("notes.MD"), "text/markdown");
assert_eq!(guess_content_type("photo.jpeg"), "image/jpeg");
assert_eq!(guess_content_type("blob"), "application/octet-stream");
assert_eq!(guess_content_type("scan.tif"), "image/tiff");
assert_eq!(guess_content_type("scan.TIFF"), "image/tiff");
}
#[test]
fn attachment_ref_reads_artifact_first_then_attachment_and_demands_id() {
let both = serde_json::json!({
"artifact": {"$artifact": "a1", "filename": "x"},
"attachment": {"$artifact": "a2"}
});
assert_eq!(
attachment_ref_from_response(&both).unwrap()["$artifact"],
"a1"
);
let alias_only = serde_json::json!({"attachment": {"$artifact": "a2"}});
assert_eq!(
attachment_ref_from_response(&alias_only).unwrap()["$artifact"],
"a2"
);
let bad = serde_json::json!({"artifact": {"artifact_id": "a3"}});
assert!(attachment_ref_from_response(&bad).is_none());
}
#[test]
fn url_encoding_is_conservative() {
assert_eq!(url_encode("run-1_2.3~x"), "run-1_2.3~x");
assert_eq!(url_encode("a b/c"), "a%20b%2Fc");
assert_eq!(url_encode("é"), "%C3%A9");
}
#[test]
fn step_parse_separates_good_records_from_counted_skips() {
let good = r#"{"cursor": 7, "record": {"run_id": "r1", "status": "completed"}}"#;
let (cursor, rec) = parse_step_data(good).expect("well-formed parses");
assert_eq!(cursor, 7);
assert_eq!(rec.expect("record present").get("run_id").unwrap(), "r1");
assert!(parse_step_data(r#"{"cursor": 8, "record": {"run_id"#).is_err());
let (cursor, rec) = parse_step_data(r#"{"cursor": 9}"#).expect("envelope parses");
assert_eq!(cursor, 9);
assert!(rec.is_none());
let (_, rec) = parse_step_data(r#"{"cursor": 10, "record": null}"#).unwrap();
assert!(rec.is_none());
}
#[test]
fn transport_classification_separates_refused_from_timeout() {
let port = {
let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
l.local_addr().unwrap().port()
};
let agent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_millis(500))
.timeout_read(Duration::from_millis(500))
.build();
let err = agent
.get(&format!("http://127.0.0.1:{port}/api/gateway/ping"))
.call()
.expect_err("nothing listens on a dropped port");
let gw = err_from_ureq("/ping", err);
assert_eq!(gw.kind, GwErrorKind::Unreachable, "refused = gone");
assert!(gw.is_gone());
assert!(
gw.to_string().starts_with("gateway unreachable: "),
"refused wording: {gw}"
);
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
let err = agent
.get(&format!("http://127.0.0.1:{port}/api/gateway/ping"))
.call()
.expect_err("no response ever comes");
let gw = err_from_ureq("/ping", err);
assert_eq!(
gw.kind,
GwErrorKind::Timeout,
"silence is a timeout, never gone-evidence: {gw}"
);
assert!(!gw.is_gone());
assert!(
gw.to_string().starts_with("gateway timed out: "),
"timeout wording must not claim unreachable: {gw}"
);
drop(listener);
}
#[cfg(test)]
fn read_http_request(stream: &mut std::net::TcpStream) -> String {
use std::io::Read;
let mut raw: Vec<u8> = Vec::new();
let mut buf = [0u8; 1024];
loop {
let n = match stream.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
raw.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&raw).to_string();
let Some(head_end) = text.find("\r\n\r\n") else {
continue;
};
let want: usize = text[..head_end]
.lines()
.find_map(|l| {
let (k, v) = l.split_once(':')?;
(k.trim().eq_ignore_ascii_case("content-length"))
.then(|| v.trim().parse::<usize>().ok())?
})
.unwrap_or(0);
if raw.len() >= head_end + 4 + want {
break;
}
}
String::from_utf8_lossy(&raw).to_string()
}
#[test]
fn a_reset_after_the_prelude_still_delivers_the_command() {
use std::io::Write;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
let seen = Arc::new(AtomicUsize::new(0));
let seen_srv = seen.clone();
let server = std::thread::spawn(move || {
for stream in listener.incoming().take(2) {
let Ok(mut stream) = stream else { break };
let n = seen_srv.fetch_add(1, Ordering::SeqCst);
if n == 0 {
std::thread::sleep(Duration::from_millis(60));
drop(stream);
continue;
}
let _ = read_http_request(&mut stream);
let body = "{\"ok\":true}";
let _ = stream.write_all(
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.as_bytes(),
);
let _ = stream.flush();
}
});
let client = GatewayClient::new(&format!("http://127.0.0.1:{port}"), None);
let (delivered, ambiguous) = client.steer("run-1", "please use AbstractTUI");
drop(server);
assert_eq!(
seen.load(Ordering::SeqCst),
2,
"the client must try again after the reset"
);
assert!(
delivered.is_ok(),
"a steer must survive one reset — the command store dedups the \
same-id retry, so losing the operator's words here is pure loss: {:?}",
delivered.err()
);
assert!(
ambiguous,
"the first attempt may have landed before the reset — the caller \
must be told the outcome is not definitive"
);
}
#[test]
fn both_attempts_carry_the_same_command_id() {
use std::io::Write;
use std::sync::mpsc;
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
let (tx, rx) = mpsc::channel::<String>();
std::thread::spawn(move || {
for (n, stream) in listener.incoming().take(2).enumerate() {
let Ok(mut stream) = stream else { break };
let _ = tx.send(read_http_request(&mut stream));
if n == 0 {
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\nshort",
);
let _ = stream.flush();
drop(stream);
} else {
let body = "{}";
let _ = stream.write_all(
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.as_bytes(),
);
let _ = stream.flush();
}
}
});
let client = GatewayClient::new(&format!("http://127.0.0.1:{port}"), None);
let (outcome, _) = client.steer("run-1", "keep going");
assert!(outcome.is_ok(), "the retry lands: {:?}", outcome.err());
let first = rx.recv_timeout(Duration::from_secs(5)).expect("attempt 1");
let second = rx.recv_timeout(Duration::from_secs(5)).expect("attempt 2");
let id_of = |req: &str| -> String {
let body = req.split("\r\n\r\n").nth(1).unwrap_or_default().to_string();
serde_json::from_str::<Value>(&body)
.ok()
.and_then(|v| {
v.get("command_id")
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_default()
};
let (a, b) = (id_of(&first), id_of(&second));
assert!(!a.is_empty(), "attempt 1 carries a command id: {first}");
assert_eq!(
a, b,
"the retry MUST reuse the id — dedup is what makes it safe"
);
}
#[test]
fn http_status_errors_prove_reachability() {
let resp = ureq::Response::new(500, "Internal Server Error", "boom").unwrap();
let gw = err_from_ureq("/ping", ureq::Error::Status(500, resp));
assert_eq!(gw.status, Some(500));
assert_eq!(gw.kind, GwErrorKind::Http);
assert!(!gw.is_gone());
assert!(gw.to_string().starts_with("gateway HTTP 500: "), "{gw}");
}
#[test]
fn io_read_classification_covers_both_unix_timeout_kinds() {
for k in [std::io::ErrorKind::TimedOut, std::io::ErrorKind::WouldBlock] {
let e = std::io::Error::new(k, "slow");
let gw = GwError::from_io_read("read failed".into(), &e);
assert_eq!(gw.kind, GwErrorKind::Timeout, "{k:?}");
}
let reset = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
let gw = GwError::from_io_read("read failed".into(), &reset);
assert_eq!(gw.kind, GwErrorKind::Transport);
assert!(gw.to_string().starts_with("gateway request failed: "));
let refused = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
assert!(GwError::from_io_read("read failed".into(), &refused).is_gone());
}
}