Files
codex/codex-rs/cloud-client/src/lib.rs
T
Benjamin Carlsson bc4116e4cd Add paginated history reads to the cloud client (#59075)
## Why

`Attach` delivers live events only. Clients also need access to stored turn metadata and item content.

## What changed

- Add `Client::list_turns` and `Client::list_items` using native gRPC history endpoints, with optional turn filtering for items.
- Expose page limits, sort order, access mode, and forward/backward cursors. Each call returns one page in server order without automatic pagination or retries.
- Preserve raw response and row payloads alongside typed summaries and item timestamps. Retain unknown item variants and command sources without typed summaries.
- Document cursor reuse and history/live overlap: history pages do not provide an atomic boundary with `Attach`, and coordinated reconciliation still requires caller-managed state.

## Testing

Add wire-fixture tests for request encoding, explicit pagination, raw payload preservation, unknown data, malformed rows, empty pages, RPC errors without retries, and diagnostic redaction.

GitOrigin-RevId: f507530b8e431236bc7e19d867517af56f74644f
2026-11-03 06:34:51 +00:00

215 lines
7.8 KiB
Rust

//! Native gRPC client for the Codex cloud ThreadService, using shared HTTP transport policy.
//!
//! Credentials and account selection belong to the caller. Requests are never retried:
//! a timeout or disconnect can leave Resume admitted. Attach delivers live events only;
//! dropping its stream detaches without interrupting the thread or answering approvals.
mod history;
mod history_wire;
mod item_wire;
mod items;
mod transcript;
mod transcript_wire;
mod turns_wire;
mod types;
mod wire;
pub use history::HistoryAccess;
pub use history::HistoryItem;
pub use history::HistoryOrder;
pub use history::HistoryPage;
pub use history::HistoryQuery;
pub use history::HistoryTurn;
pub use history::ListItemsRequest;
pub use history::ListTurnsRequest;
pub use items::AgentQuestion;
pub use items::CommandAction;
pub use items::CommandExecutionSource;
pub use items::CommandSummary;
pub use items::ItemLifecycle;
pub use items::ItemStatus;
pub use items::ItemSummary;
pub use transcript::FileUpdate;
pub use transcript::ItemDelta;
pub use transcript::PatchKind;
pub use transcript::PatchUpdate;
pub use transcript::TranscriptEvent;
pub use transcript::TranscriptUpdate;
pub use transcript::TurnFailure;
pub use transcript::TurnStatus;
pub use transcript::TurnSummary;
pub use types::Credentials;
pub use types::Error;
pub use types::Event;
pub use types::ResumeRequest;
pub use types::RpcStatus;
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClientBuilder;
use codex_http_client::HttpClientFactory;
use codex_http_client::RouteAwareClientPool;
use futures::StreamExt;
use futures::stream::BoxStream;
use http_body_util::BodyExt;
use std::io;
use std::time::Duration;
use tonic::body::Body;
use tonic::client::Grpc;
use tonic_prost::ProstCodec;
use tower::service_fn;
use tower::util::BoxCloneSyncService;
use url::Url;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(150);
const MAX_MESSAGE_BYTES: usize = 64 * 1024 * 1024;
type Transport = BoxCloneSyncService<http::Request<Body>, http::Response<Body>, io::Error>;
/// One fixed origin and account. Construct a new client when credentials change.
/// Supply a trusted native gRPC origin, without a path (not the HTTP `/grpc` gateway).
pub struct Client {
grpc: Grpc<Transport>,
credentials: Credentials,
}
impl Client {
pub fn new(
factory: &HttpClientFactory,
endpoint: &str,
credentials: Credentials,
) -> Result<Self, Error> {
let endpoint = Url::parse(endpoint).map_err(|_| Error::InvalidEndpoint)?;
let loopback = match endpoint.host() {
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
Some(url::Host::Domain(name)) => name == "localhost",
None => false,
};
if (endpoint.scheme() != "https" && !(endpoint.scheme() == "http" && loopback))
|| !endpoint.username().is_empty()
|| endpoint.password().is_some()
|| endpoint.query().is_some()
|| endpoint.fragment().is_some()
|| endpoint.path() != "/"
{
return Err(Error::InvalidEndpoint);
}
let local_http = endpoint.scheme() == "http";
if local_http
&& (factory.network_policy().is_managed()
|| factory
.network_policy()
.acquire_for_unsupported_sdk()
.is_err())
{
return Err(Error::InvalidEndpoint);
}
let builder = HttpClientBuilder::new()
.without_redirects()
.without_request_logging()
.http2_prior_knowledge()
.connect_timeout(Duration::from_secs(30));
let http = if local_http {
// Plaintext credentials must never leave loopback through an outbound proxy.
builder.build_direct().map_err(|_| Error::ClientBuild)?
} else {
RouteAwareClientPool::with_builder(factory.clone(), ClientRouteClass::Api, builder)
.into_client()
};
let origin = endpoint
.as_str()
.parse()
.map_err(|_| Error::InvalidEndpoint)?;
let transport = service_fn(move |request: http::Request<Body>| {
let http = http.clone();
async move {
let (parts, body) = request.into_parts();
let response = http
.request(parts.method, parts.uri.to_string())
.version(parts.version)
.headers(parts.headers)
.body_stream(body.into_data_stream())
.send()
.await
.map_err(|_| io::Error::other("gRPC transport failed"))?;
Ok(response.into_http_response().map(Body::new))
}
});
Ok(Self {
grpc: Grpc::with_origin(BoxCloneSyncService::new(transport), origin)
.max_decoding_message_size(MAX_MESSAGE_BYTES)
.max_encoding_message_size(MAX_MESSAGE_BYTES),
credentials,
})
}
/// With `wait: false`, success means admission only. Failures may leave work admitted.
pub async fn resume(&self, request: &ResumeRequest) -> Result<(), Error> {
let mut grpc = self.grpc.clone();
let mut request = tonic::Request::new(request.clone());
*request.metadata_mut() =
tonic::metadata::MetadataMap::from_headers(self.credentials.headers.clone());
// Bound the whole unary RPC, including its response body and trailers.
tokio::time::timeout(
REQUEST_TIMEOUT,
grpc.unary(
request,
http::uri::PathAndQuery::from_static(
"/oaiproto.codex_app_server_backend.ThreadService/Resume",
),
ProstCodec::<ResumeRequest, wire::ResumeResponse>::default(),
),
)
.await
.map_err(|_| Error::Timeout)?
.map_err(Error::from)?;
Ok(())
}
/// Live events only, without history or automatic reconnect. The stream terminates
/// on its first error. Setup is bounded; there is no idle or total stream deadline.
pub async fn attach(
&self,
thread_id: &str,
) -> Result<BoxStream<'static, Result<Event, Error>>, Error> {
let mut grpc = self.grpc.clone();
let mut request = tonic::Request::new(wire::AttachRequest {
thread_id: thread_id.to_owned(),
});
*request.metadata_mut() =
tonic::metadata::MetadataMap::from_headers(self.credentials.headers.clone());
let response = tokio::time::timeout(
REQUEST_TIMEOUT,
grpc.server_streaming(
request,
http::uri::PathAndQuery::from_static(
"/oaiproto.codex_app_server_backend.ThreadService/Attach",
),
ProstCodec::<wire::AttachRequest, wire::AttachResponse>::default(),
),
)
.await
.map_err(|_| Error::Timeout)?
.map_err(Error::from)?;
let mut stream = response.into_inner();
let thread_id = thread_id.to_owned();
Ok(async_stream::try_stream! {
while let Some(message) = stream.message().await.map_err(Error::from)? {
yield match message.event.ok_or(Error::InvalidResponse)? {
wire::Payload::Notification(bytes) => {
match TranscriptEvent::decode(&thread_id, &bytes)? {
Some(event) => Event::Transcript(Box::new(event)),
None => Event::Notification(bytes),
}
},
wire::Payload::ServerRequest(bytes) => Event::ServerRequest(bytes),
};
}
}
.boxed())
}
}
#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;