Watch
0
0
Fork
You've already forked hyperhive
0

swarmctl: agent watch follows an agent's terminal rows

Reads GET /api/agents/{name}/term/stream over the controller socket or the
gateway and prints one line per row. The gateway path uses a client without
the total-request timeout so the stream isn't cut after 30 seconds.

Closes #6160
This commit is contained in:
atlas 2026-11-07 05:24:21 +01:00 • committed by mara
commit 9e0c51052b

View file

@ -80,11 +80,24 @@ impl Swarm {
/// An HTTP client that never follows a redirect: from the gateway one
/// means "log in", and following it would land on the login page.
pub(crate) fn http(&self) -> Result<reqwest::Client> {
let mut builder = bounded(
self.build_client(bounded(
reqwest::Client::builder(),
HTTP_CONNECT_TIMEOUT,
HTTP_TIMEOUT,
);
))
}
/// [`Swarm::http`] without the total-request bound, which would cut a
/// stream off after `HTTP_TIMEOUT`. Reaching the gateway is still bounded.
fn stream_http(&self) -> Result<reqwest::Client> {
self.build_client(
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(HTTP_CONNECT_TIMEOUT),
)
}
fn build_client(&self, mut builder: reqwest::ClientBuilder) -> Result<reqwest::Client> {
if let Some(path) = &self.ca_file {
let pem = fs::read(path)
.with_context(|| format!("reading the CA certificate {}", path.display()))?;
@ -125,6 +138,21 @@ impl Swarm {
.await
}
/// `GET uri` with the cached login, answered as soon as the headers arrive
/// so the caller can read the body as it comes. Auth failures are handled
/// as for [`Swarm::request`]; any other status is the caller's to judge.
pub(crate) async fn open_stream(&self, uri: &str, what: &str) -> Result<reqwest::Response> {
self.send_authed(
&self.stream_http()?,
hyper::Method::GET,
uri,
None,
what,
Some("text/event-stream"),
)
.await
}
async fn request_with(
&self,
http: &reqwest::Client,
@ -133,9 +161,29 @@ impl Swarm {
body: Option<Vec<u8>>,
what: &str,
) -> Result<(hyper::StatusCode, bytes::Bytes)> {
let resp = self
.send_authed(http, method, uri, body, what, None)
.await?;
let status = resp.status();
let body = resp.bytes().await.context("reading the response")?;
Ok((status, body))
}
async fn send_authed(
&self,
http: &reqwest::Client,
method: hyper::Method,
uri: &str,
body: Option<Vec<u8>>,
what: &str,
accept: Option<&str>,
) -> Result<reqwest::Response> {
let url = self.origin.join(uri).context("joining the request path")?;
let send = |token: String| {
let mut req = http.request(method.clone(), url.clone()).bearer_auth(token);
if let Some(accept) = accept {
req = req.header(reqwest::header::ACCEPT, accept);
}
if let Some(body) = &body {
req = req
.header(reqwest::header::CONTENT_TYPE, "application/json")
@ -165,8 +213,7 @@ impl Swarm {
self.origin
);
}
let body = resp.bytes().await.context("reading the response")?;
Ok((status, body))
Ok(resp)
}
/// The cached access token, refreshed first if it is about to expire or is
@ -203,7 +250,7 @@ impl Swarm {
}
/// `builder` following no redirect, with every request bounded by `connect`
/// and `total`. No caller streams a response, so a total bound fits them all.
/// and `total`. Not for a streamed response: `stream_http` has no total bound.
fn bounded(
builder: reqwest::ClientBuilder,
connect: Duration,