mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-23 00:41:13 +00:00
Some checks are pending
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
Rust / Format (push) Waiting to run
TypeScript / Build (push) Waiting to run
TypeScript / Typecheck (push) Waiting to run
TypeScript / Test (push) Waiting to run
## Summary
Run pairing previously required callers to supply an opaque
`agent_session_id` alongside a `stage_id` to start or target a pair
session. This leaked an internal runtime identifier across the public
HTTP API, generated TypeScript client, and would have bled into any MCP
tooling. This PR removes that coupling: the public pair API now
identifies targets by `StageId` alone, the server resolves the live
session internally, and a new `fabro_run_pair` MCP tool exposes the full
pair lifecycle without ever seeing session identifiers.
### What changed
**Public contract simplification** (`fabro-types`, OpenAPI, generated TS
client)
- `PairTarget` is now `{ stage_id, node_label }` — `node_id`, `visit`,
`agent_session_id`, `provider`, and `model` are removed.
- `PairStartRequest` accepts `{ stage_id }` instead of `{ target:
PairTargetSelector }`.
- `PairTargetSelector` and `PairTranscriptModel` types are deleted
entirely.
- `PairMessageRecord.target` (selector) replaced by
`PairMessageRecord.stage_id`.
- `PairTranscriptAssistantMessage.model` field removed.
- `MAX_PAIR_MESSAGE_BYTES` extracted as a public constant shared between
the server handler and the MCP tool.
**Internal session binding** (`SteeringHub`, server projection)
- `ActivePair` now carries `session_id: String` separately from the
public `PairRecord`. This preserves the stale-session protection that
previously relied on `target.agent_session_id`.
- Transcript matching changed from `(session_id AND stage_id)` to
`stage_id` within the already-scoped pair window sequence range —
simpler and sufficient.
- `active_api_targets` deactivation no longer does a per-target
`agent_session_id` check; it relies on the `active_steerable_stages`
lease already doing that guard.
**New `fabro_run_pair` MCP tool** (`fabro-mcp-server`)
- Actions: `status`, `start`, `get`, `message`, `end`, `transcript`.
- Validation happens before any network call; missing `run_id`, missing
`stage_id` for `start`, missing/invalid `pair_id` for other actions, and
overlong message text all return clean tool-level errors.
- `strum::IntoStaticStr` on `RunPairAction` enables the
`parse_pair_id_for_action` helper to embed the action name in error
messages without a `match`.
- MCP result schema and serialized results are covered by leakage
assertions confirming none of the removed fields surface.
**Tests**
- Negative leakage assertions added to pair DTO tests, event round-trip
tests, control-protocol tests, server handler tests, MCP validation
tests, and MCP schema test.
- Tool count updated from 5 → 6 in all CLI MCP integration tests.
- Steering hub test renamed:
`pair_start_rejects_non_selected_or_missing_target` →
`pair_start_rejects_missing_target` (session-mismatch rejection is now
an internal concern).
### Fabro Details
<details>
<summary>Ran 9 stages in 60m 29s for $31.78</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 9s | – | 0 |
| preflight_lint | 2m 26s | – | 0 |
| implement | 33m 14s | $25.51 | 0 |
| simplify_opus | 15m 7s | $4.18 | 0 |
| simplify_gpt | 3m 46s | $2.08 | 0 |
| verify | 3m 9s | – | 0 |
| fmt | 3s | – | 0 |
| **Total** | **60m 29s** | **$31.78** | **0** |
</details>
<details>
<summary>Ran <code>ImplementPlan.fabro</code> (12 nodes and 15
edges)</summary>
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-7; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3]
fmt [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> fmt [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
fmt -> exit
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
2362 lines
78 KiB
Rust
2362 lines
78 KiB
Rust
use std::collections::VecDeque;
|
|
use std::future::Future;
|
|
use std::num::NonZeroU64;
|
|
use std::path::Path;
|
|
use std::pin::Pin;
|
|
use std::sync::{Arc, RwLock};
|
|
|
|
use anyhow::{Context as _, Result, anyhow, bail};
|
|
use bytes::Bytes;
|
|
use fabro_api::types;
|
|
use fabro_http::header::{ACCEPT, AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE};
|
|
use fabro_http::multipart::{Form, Part};
|
|
use fabro_model::{Model, ModelTestMode, ProviderId};
|
|
use fabro_types::settings::run::MergeStrategy;
|
|
use fabro_types::{
|
|
ArtifactUpload, EventEnvelope, PairId, PairMessageRecord, PairMessageRequest, PairRecord,
|
|
PairStartRequest, PairTranscriptResponse, Run, RunBlobId, RunEvent, RunEventDetailResponse,
|
|
RunId, RunPairStatusResponse, RunProjection, SessionId, SessionRecord, StageId,
|
|
};
|
|
use fabro_util::exit::{ErrorExt, ExitClass};
|
|
use futures::future::BoxFuture;
|
|
use futures::{Stream, StreamExt};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::fs::File;
|
|
use tokio::sync::Mutex;
|
|
use tokio::time;
|
|
use tokio_util::io::ReaderStream;
|
|
|
|
use crate::credential::Credential;
|
|
use crate::error::{
|
|
ApiError, ApiFailure, api_failure_for, classify_api_error, classify_http_response,
|
|
convert_type, is_not_found_error, raw_response_failure_error,
|
|
};
|
|
use crate::session::OAuthSession;
|
|
use crate::target::ServerTarget;
|
|
use crate::{AuthEntry, OAuthEntry, StoredSubject, sse};
|
|
|
|
const DEFAULT_CONTROL_PLANE_REQUEST_TIMEOUT: std::time::Duration =
|
|
std::time::Duration::from_secs(30);
|
|
const DEFAULT_HEALTH_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(250);
|
|
|
|
type TransportFuture = BoxFuture<'static, Result<(fabro_http::HttpClient, String)>>;
|
|
|
|
pub struct RunEventStream {
|
|
stream: progenitor_client::ByteStream,
|
|
pending_bytes: Vec<u8>,
|
|
buffered_events: VecDeque<EventEnvelope>,
|
|
}
|
|
|
|
type HttpByteStream = Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>>;
|
|
|
|
pub struct SessionEventStream {
|
|
stream: HttpByteStream,
|
|
pending_bytes: Vec<u8>,
|
|
buffered_events: VecDeque<EventEnvelope>,
|
|
}
|
|
|
|
pub struct RewindRunResult {
|
|
pub status: u16,
|
|
pub response: types::RewindResponse,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct ListStoreRunsOptions {
|
|
parent_id: Option<RunId>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ClientState {
|
|
client: fabro_api::ApiClient,
|
|
http_client: fabro_http::HttpClient,
|
|
bearer_token: Option<String>,
|
|
base_url: String,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct Client {
|
|
state: Arc<RwLock<ClientState>>,
|
|
oauth_session: Option<OAuthSession>,
|
|
refresh_lock: Arc<Mutex<()>>,
|
|
transport_connector: Option<TransportConnector>,
|
|
request_timeout: Option<std::time::Duration>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct TransportConnector {
|
|
connect: Arc<dyn Fn(Option<String>) -> TransportFuture + Send + Sync>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct ClientBuilder {
|
|
target: Option<ServerTarget>,
|
|
credential: Option<Credential>,
|
|
oauth_session: Option<OAuthSession>,
|
|
transport: Option<(String, fabro_http::HttpClient)>,
|
|
transport_connector: Option<TransportConnector>,
|
|
request_timeout: Option<std::time::Duration>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct CliTokenResponse {
|
|
access_token: String,
|
|
access_token_expires_at: chrono::DateTime<chrono::Utc>,
|
|
refresh_token: String,
|
|
refresh_token_expires_at: chrono::DateTime<chrono::Utc>,
|
|
subject: CliTokenSubject,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct CliTokenSubject {
|
|
idp_issuer: String,
|
|
idp_subject: String,
|
|
login: String,
|
|
name: String,
|
|
email: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct OAuthErrorBody {
|
|
error: String,
|
|
#[serde(default)]
|
|
error_description: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ArtifactBatchUploadManifest {
|
|
entries: Vec<ArtifactBatchUploadEntry>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ArtifactBatchUploadEntry {
|
|
part: String,
|
|
path: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
sha256: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
expected_bytes: Option<u64>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
content_type: Option<String>,
|
|
}
|
|
|
|
impl RunEventStream {
|
|
#[must_use]
|
|
pub fn new(stream: progenitor_client::ByteStream) -> Self {
|
|
Self {
|
|
stream,
|
|
pending_bytes: Vec::new(),
|
|
buffered_events: VecDeque::new(),
|
|
}
|
|
}
|
|
|
|
pub async fn next_event(&mut self) -> Result<Option<EventEnvelope>> {
|
|
loop {
|
|
if let Some(event) = self.buffered_events.pop_front() {
|
|
return Ok(Some(event));
|
|
}
|
|
|
|
if let Some(chunk) = self.stream.next().await {
|
|
let chunk = chunk.map_err(anyhow::Error::new)?;
|
|
self.pending_bytes.extend_from_slice(&chunk);
|
|
self.buffer_sse_events(false)?;
|
|
} else {
|
|
self.buffer_sse_events(true)?;
|
|
return Ok(self.buffered_events.pop_front());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn buffer_sse_events(&mut self, finalize: bool) -> Result<()> {
|
|
for payload in sse::drain_sse_payloads(&mut self.pending_bytes, finalize) {
|
|
self.buffered_events
|
|
.push_back(serde_json::from_str(&payload)?);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl SessionEventStream {
|
|
#[must_use]
|
|
pub fn new(stream: HttpByteStream) -> Self {
|
|
Self {
|
|
stream,
|
|
pending_bytes: Vec::new(),
|
|
buffered_events: VecDeque::new(),
|
|
}
|
|
}
|
|
|
|
pub async fn next_event(&mut self) -> Result<Option<EventEnvelope>> {
|
|
loop {
|
|
if let Some(event) = self.buffered_events.pop_front() {
|
|
return Ok(Some(event));
|
|
}
|
|
|
|
if let Some(chunk) = self.stream.next().await {
|
|
let chunk = chunk?;
|
|
self.pending_bytes.extend_from_slice(&chunk);
|
|
self.buffer_sse_events(false)?;
|
|
} else {
|
|
self.buffer_sse_events(true)?;
|
|
return Ok(self.buffered_events.pop_front());
|
|
}
|
|
}
|
|
}
|
|
|
|
fn buffer_sse_events(&mut self, finalize: bool) -> Result<()> {
|
|
for payload in sse::drain_sse_payloads(&mut self.pending_bytes, finalize) {
|
|
self.buffered_events
|
|
.push_back(serde_json::from_str(&payload)?);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl TransportConnector {
|
|
pub fn new<F, Fut>(connect: F) -> Self
|
|
where
|
|
F: Fn(Option<String>) -> Fut + Send + Sync + 'static,
|
|
Fut: Future<Output = Result<(fabro_http::HttpClient, String)>> + Send + 'static,
|
|
{
|
|
Self {
|
|
connect: Arc::new(move |bearer_token| Box::pin(connect(bearer_token))),
|
|
}
|
|
}
|
|
|
|
pub async fn connect(
|
|
&self,
|
|
bearer_token: Option<String>,
|
|
) -> Result<(fabro_http::HttpClient, String)> {
|
|
(self.connect)(bearer_token).await
|
|
}
|
|
}
|
|
|
|
impl ClientBuilder {
|
|
#[must_use]
|
|
pub fn target(mut self, target: ServerTarget) -> Self {
|
|
self.target = Some(target);
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn credential(mut self, credential: Credential) -> Self {
|
|
self.credential = Some(credential);
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn oauth_session(mut self, oauth_session: OAuthSession) -> Self {
|
|
self.oauth_session = Some(oauth_session);
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn transport(
|
|
mut self,
|
|
base_url: impl Into<String>,
|
|
http_client: fabro_http::HttpClient,
|
|
) -> Self {
|
|
self.transport = Some((base_url.into(), http_client));
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn transport_connector(mut self, transport_connector: TransportConnector) -> Self {
|
|
self.transport_connector = Some(transport_connector);
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn request_timeout(mut self, timeout: std::time::Duration) -> Self {
|
|
self.request_timeout = Some(timeout);
|
|
self
|
|
}
|
|
|
|
pub async fn connect(self) -> Result<Client> {
|
|
let bearer_token = self
|
|
.credential
|
|
.as_ref()
|
|
.map(Credential::bearer_token)
|
|
.map(ToOwned::to_owned);
|
|
let target = self.target.clone().or_else(|| {
|
|
self.oauth_session
|
|
.as_ref()
|
|
.map(|session| session.target.clone())
|
|
});
|
|
let uses_default_target_transport =
|
|
self.transport.is_none() && self.transport_connector.is_none() && target.is_some();
|
|
let request_timeout = self.request_timeout.or_else(|| {
|
|
uses_default_target_transport.then_some(DEFAULT_CONTROL_PLANE_REQUEST_TIMEOUT)
|
|
});
|
|
let transport_connector = self
|
|
.transport_connector
|
|
.or_else(|| target.map(default_transport_connector));
|
|
|
|
let state = if let Some((base_url, http_client)) = self.transport {
|
|
client_state(base_url, http_client, bearer_token.clone())
|
|
} else {
|
|
let Some(transport_connector) = transport_connector.clone() else {
|
|
bail!("client builder requires a target, transport, or transport connector");
|
|
};
|
|
let (http_client, base_url) = transport_connector.connect(bearer_token.clone()).await?;
|
|
client_state(base_url, http_client, bearer_token.clone())
|
|
};
|
|
|
|
Ok(Client {
|
|
state: Arc::new(RwLock::new(state)),
|
|
oauth_session: self.oauth_session,
|
|
refresh_lock: Arc::new(Mutex::new(())),
|
|
transport_connector,
|
|
request_timeout,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Client {
|
|
#[must_use]
|
|
pub fn builder() -> ClientBuilder {
|
|
ClientBuilder::default()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn from_http_client(
|
|
base_url: impl Into<String>,
|
|
http_client: fabro_http::HttpClient,
|
|
) -> Self {
|
|
Self {
|
|
state: Arc::new(RwLock::new(client_state(
|
|
base_url.into(),
|
|
http_client,
|
|
None,
|
|
))),
|
|
oauth_session: None,
|
|
refresh_lock: Arc::new(Mutex::new(())),
|
|
transport_connector: None,
|
|
request_timeout: None,
|
|
}
|
|
}
|
|
|
|
pub fn new_no_proxy(base_url: &str) -> Result<Self> {
|
|
let http_client = fabro_http::HttpClientBuilder::new().no_proxy().build()?;
|
|
Ok(Self::from_http_client(base_url.to_string(), http_client))
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn clone_for_reuse(&self) -> Self {
|
|
self.clone()
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn api_client(&self) -> fabro_api::ApiClient {
|
|
self.current_state().client
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn http_client(&self) -> fabro_http::HttpClient {
|
|
self.current_state().http_client
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn base_url(&self) -> String {
|
|
self.current_state().base_url
|
|
}
|
|
|
|
fn current_state(&self) -> ClientState {
|
|
self.state
|
|
.read()
|
|
.expect("client state lock should not be poisoned")
|
|
.clone()
|
|
}
|
|
|
|
fn replace_state(&self, state: ClientState) {
|
|
*self
|
|
.state
|
|
.write()
|
|
.expect("client state lock should not be poisoned") = state;
|
|
}
|
|
|
|
async fn with_request_timeout<T>(&self, future: impl Future<Output = T>) -> Result<T> {
|
|
let Some(timeout) = self.request_timeout else {
|
|
return Ok(future.await);
|
|
};
|
|
|
|
match time::timeout(timeout, future).await {
|
|
Ok(value) => Ok(value),
|
|
Err(_) => bail!("server request timed out after {timeout:?}"),
|
|
}
|
|
}
|
|
|
|
async fn send_api<T, E, F, Fut>(
|
|
&self,
|
|
request: F,
|
|
) -> Result<progenitor_client::ResponseValue<T>>
|
|
where
|
|
F: FnOnce(fabro_api::ApiClient) -> Fut + Clone,
|
|
Fut: Future<
|
|
Output = std::result::Result<
|
|
progenitor_client::ResponseValue<T>,
|
|
progenitor_client::Error<E>,
|
|
>,
|
|
>,
|
|
E: serde::Serialize + std::fmt::Debug + Send + Sync + 'static,
|
|
{
|
|
let state = self.current_state();
|
|
match self
|
|
.with_request_timeout(Box::pin(request.clone()(state.client.clone())))
|
|
.await?
|
|
{
|
|
Ok(response) => Ok(response),
|
|
Err(err) => {
|
|
let mapped = classify_api_error(err).await;
|
|
if self.should_refresh(mapped.failure.as_ref()) {
|
|
if let Some(failed_token) = state.bearer_token.as_deref() {
|
|
self.refresh_access_token(failed_token).await?;
|
|
let state = self.current_state();
|
|
let retry_response = self
|
|
.with_request_timeout(Box::pin(request(state.client.clone())))
|
|
.await?;
|
|
return match retry_response {
|
|
Ok(response) => Ok(response),
|
|
Err(err) => Err(classify_api_error(err).await.error),
|
|
};
|
|
}
|
|
}
|
|
Err(mapped.error)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn should_refresh(&self, failure: Option<&ApiFailure>) -> bool {
|
|
self.oauth_session.is_some()
|
|
&& failure.is_some_and(|failure| {
|
|
failure.status == fabro_http::StatusCode::UNAUTHORIZED
|
|
&& failure.code.as_deref() == Some("access_token_expired")
|
|
})
|
|
}
|
|
|
|
async fn refresh_access_token(&self, failed_access_token: &str) -> Result<()> {
|
|
fn session_expired() -> anyhow::Error {
|
|
anyhow!("CLI session has expired. Run `fabro auth login` again.")
|
|
.classify(ExitClass::AuthRequired)
|
|
}
|
|
|
|
let Some(oauth_session) = &self.oauth_session else {
|
|
return Err(session_expired());
|
|
};
|
|
|
|
let _guard = self.refresh_lock.lock().await;
|
|
let current_state = self.current_state();
|
|
if current_state.bearer_token.as_deref() != Some(failed_access_token) {
|
|
return Ok(());
|
|
}
|
|
|
|
let Some(entry) = oauth_session.auth_store.get(&oauth_session.target)? else {
|
|
self.rebuild_with_fallback(oauth_session).await?;
|
|
return Err(session_expired());
|
|
};
|
|
let oauth_entry = match entry {
|
|
AuthEntry::DevToken(entry) => {
|
|
self.rebuild_client(Some(entry.token)).await?;
|
|
return Ok(());
|
|
}
|
|
AuthEntry::OAuth(entry) => entry,
|
|
};
|
|
if oauth_entry.refresh_token_expires_at <= chrono::Utc::now() {
|
|
oauth_session.auth_store.remove(&oauth_session.target)?;
|
|
self.rebuild_with_fallback(oauth_session).await?;
|
|
return Err(session_expired());
|
|
}
|
|
let (http_client, base_url) = oauth_session.target.build_public_http_client()?;
|
|
let response = http_client
|
|
.post(format!("{base_url}/auth/cli/refresh"))
|
|
.header(
|
|
AUTHORIZATION,
|
|
format!("Bearer {}", oauth_entry.refresh_token),
|
|
)
|
|
.send()
|
|
.await?;
|
|
|
|
if response.status().is_success() {
|
|
let tokens = response
|
|
.json::<CliTokenResponse>()
|
|
.await
|
|
.context("failed to parse CLI auth refresh response")?;
|
|
let entry = OAuthEntry {
|
|
access_token: tokens.access_token.clone(),
|
|
access_token_expires_at: tokens.access_token_expires_at,
|
|
refresh_token: tokens.refresh_token.clone(),
|
|
refresh_token_expires_at: tokens.refresh_token_expires_at,
|
|
subject: StoredSubject {
|
|
idp_issuer: tokens.subject.idp_issuer,
|
|
idp_subject: tokens.subject.idp_subject,
|
|
login: tokens.subject.login,
|
|
name: tokens.subject.name,
|
|
email: tokens.subject.email,
|
|
},
|
|
logged_in_at: oauth_entry.logged_in_at,
|
|
};
|
|
oauth_session
|
|
.auth_store
|
|
.put(&oauth_session.target, AuthEntry::OAuth(entry.clone()))
|
|
.context("failed to persist refreshed CLI auth tokens")?;
|
|
self.rebuild_client(Some(entry.access_token)).await?;
|
|
return Ok(());
|
|
}
|
|
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
let parsed_error = serde_json::from_str::<OAuthErrorBody>(&body).ok();
|
|
let auth_recoverable = parsed_error.as_ref().is_some_and(|error| {
|
|
matches!(
|
|
error.error.as_str(),
|
|
"refresh_token_expired" | "refresh_token_revoked"
|
|
)
|
|
});
|
|
if auth_recoverable {
|
|
oauth_session.auth_store.remove(&oauth_session.target)?;
|
|
self.rebuild_with_fallback(oauth_session).await?;
|
|
}
|
|
|
|
let err = if let Some(parsed_error) = parsed_error {
|
|
let message = parsed_error
|
|
.error_description
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or_else(|| format!("request failed with status {status}"));
|
|
anyhow!("{message}")
|
|
} else if body.is_empty() {
|
|
anyhow!("request failed with status {status}")
|
|
} else {
|
|
anyhow!("request failed with status {status}: {body}")
|
|
};
|
|
|
|
Err(if auth_recoverable {
|
|
err.classify(ExitClass::AuthRequired)
|
|
} else {
|
|
err
|
|
})
|
|
}
|
|
|
|
async fn rebuild_with_fallback(&self, oauth_session: &OAuthSession) -> Result<()> {
|
|
let credential = oauth_session.resolve_fallback();
|
|
self.rebuild_client(
|
|
credential
|
|
.as_ref()
|
|
.map(Credential::bearer_token)
|
|
.map(ToOwned::to_owned),
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn rebuild_client(&self, bearer_token: Option<String>) -> Result<()> {
|
|
let Some(transport_connector) = &self.transport_connector else {
|
|
bail!("client transport cannot be rebuilt");
|
|
};
|
|
let (http_client, base_url) = transport_connector.connect(bearer_token.clone()).await?;
|
|
self.replace_state(client_state(base_url, http_client, bearer_token));
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn send_http_response<T, F, Fut>(
|
|
&self,
|
|
request: F,
|
|
) -> Result<std::result::Result<fabro_http::Response, ApiError>>
|
|
where
|
|
F: FnOnce(fabro_http::HttpClient) -> Fut + Clone,
|
|
Fut: Future<Output = std::result::Result<fabro_http::Response, T>>,
|
|
T: Into<anyhow::Error>,
|
|
{
|
|
let state = self.current_state();
|
|
let response = self
|
|
.with_request_timeout(Box::pin(request.clone()(state.http_client.clone())))
|
|
.await?
|
|
.map_err(Into::into)?;
|
|
match classify_http_response(response).await? {
|
|
Ok(response) => Ok(Ok(response)),
|
|
Err(failure) => {
|
|
if self.should_refresh(Some(failure.api_failure())) {
|
|
if let Some(failed_token) = state.bearer_token.as_deref() {
|
|
self.refresh_access_token(failed_token).await?;
|
|
let state = self.current_state();
|
|
let response = self
|
|
.with_request_timeout(Box::pin(request(state.http_client.clone())))
|
|
.await?
|
|
.map_err(Into::into)?;
|
|
return classify_http_response(response).await;
|
|
}
|
|
}
|
|
Ok(Err(failure))
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn send_http<T, F, Fut>(&self, request: F) -> Result<fabro_http::Response>
|
|
where
|
|
F: FnOnce(fabro_http::HttpClient) -> Fut + Clone,
|
|
Fut: Future<Output = std::result::Result<fabro_http::Response, T>>,
|
|
T: Into<anyhow::Error>,
|
|
{
|
|
match self.send_http_response(request).await? {
|
|
Ok(response) => Ok(response),
|
|
Err(failure) => Err(raw_response_failure_error(&failure)),
|
|
}
|
|
}
|
|
|
|
pub async fn retrieve_resolved_server_settings(&self) -> Result<types::ServerSettings> {
|
|
let url = format!("{}/api/v1/settings", self.base_url());
|
|
let response = self
|
|
.send_http(|http_client| async move { http_client.get(&url).send().await })
|
|
.await?;
|
|
|
|
response
|
|
.json::<types::ServerSettings>()
|
|
.await
|
|
.context("server returned invalid JSON for server settings")
|
|
}
|
|
|
|
pub async fn create_run_session(
|
|
&self,
|
|
run_id: RunId,
|
|
body: types::CreateRunSessionRequest,
|
|
) -> Result<SessionRecord> {
|
|
let response = self
|
|
.send_api(|client| {
|
|
let body = body.clone();
|
|
async move {
|
|
client
|
|
.create_run_session()
|
|
.id(run_id.to_string())
|
|
.body(body)
|
|
.send()
|
|
.await
|
|
}
|
|
})
|
|
.await?;
|
|
Ok(response.into_inner())
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_types,
|
|
reason = "Client builds raw server API request URLs for wire transit; logging redaction is handled at log boundaries."
|
|
)]
|
|
pub async fn submit_session_turn_stream(
|
|
&self,
|
|
session_id: SessionId,
|
|
input: impl Into<String>,
|
|
) -> Result<SessionEventStream> {
|
|
let base_url = self.base_url();
|
|
let mut url = fabro_http::Url::parse(&base_url)
|
|
.with_context(|| format!("invalid server base URL {base_url}"))?;
|
|
url.path_segments_mut()
|
|
.map_err(|()| anyhow!("server base URL cannot accept path segments"))?
|
|
.extend(["api", "v1", "sessions", &session_id.to_string(), "turns"]);
|
|
let body = types::SubmitTurnRequest {
|
|
input: input.into(),
|
|
turn_id: None,
|
|
};
|
|
let response = self
|
|
.send_http(|http_client| {
|
|
let url = url.clone();
|
|
let body = body.clone();
|
|
async move {
|
|
http_client
|
|
.post(url)
|
|
.header(ACCEPT, "text/event-stream")
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
}
|
|
})
|
|
.await?;
|
|
let stream = response
|
|
.bytes_stream()
|
|
.map(|chunk| chunk.map_err(anyhow::Error::new));
|
|
Ok(SessionEventStream::new(Box::pin(stream)))
|
|
}
|
|
|
|
pub async fn create_run_from_manifest(&self, manifest: types::RunManifest) -> Result<RunId> {
|
|
let response = self
|
|
.send_api(
|
|
|client| async move { client.create_run().body(manifest.clone()).send().await },
|
|
)
|
|
.await?;
|
|
let status = response.into_inner();
|
|
Ok(status.id)
|
|
}
|
|
|
|
pub async fn list_secrets(&self) -> Result<Vec<types::SecretMetadata>> {
|
|
let response = self
|
|
.send_api(|client| async move { client.list_secrets().send().await })
|
|
.await?;
|
|
Ok(response.into_inner().data)
|
|
}
|
|
|
|
pub async fn create_secret(
|
|
&self,
|
|
body: types::CreateSecretRequest,
|
|
) -> Result<types::SecretMetadata> {
|
|
let response = self
|
|
.send_api(
|
|
|client| async move { client.create_secret().body(body.clone()).send().await },
|
|
)
|
|
.await?;
|
|
Ok(response.into_inner())
|
|
}
|
|
|
|
pub async fn delete_secret_by_name(&self, name: &str) -> Result<()> {
|
|
self.send_api(|client| async move {
|
|
client
|
|
.delete_secret_by_name()
|
|
.body(types::DeleteSecretRequest {
|
|
name: name.to_string(),
|
|
})
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_models(
|
|
&self,
|
|
provider: Option<&str>,
|
|
query: Option<&str>,
|
|
) -> Result<Vec<Model>> {
|
|
let provider = provider.map(ProviderId::new);
|
|
let mut offset = 0u64;
|
|
let mut models = Vec::new();
|
|
|
|
loop {
|
|
let provider = provider.clone();
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
let mut request = client.list_models().page_limit(100u64).page_offset(offset);
|
|
if let Some(provider) = provider {
|
|
request = request.provider(provider);
|
|
}
|
|
if let Some(query) = query {
|
|
request = request.query(query.to_string());
|
|
}
|
|
request.send().await
|
|
})
|
|
.await?;
|
|
let parsed = response.into_inner();
|
|
let count = parsed.data.len() as u64;
|
|
models.extend(convert_type::<_, Vec<Model>>(parsed.data)?);
|
|
if !parsed.meta.has_more {
|
|
break;
|
|
}
|
|
offset += count;
|
|
}
|
|
|
|
Ok(models)
|
|
}
|
|
|
|
pub async fn test_model(
|
|
&self,
|
|
id: &str,
|
|
mode: Option<ModelTestMode>,
|
|
) -> Result<types::ModelTestResult> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
let mut request = client.test_model().id(id.to_string());
|
|
if let Some(mode) = mode {
|
|
request = request.mode(mode);
|
|
}
|
|
request.send().await
|
|
})
|
|
.await?;
|
|
Ok(response.into_inner())
|
|
}
|
|
|
|
pub async fn attach_events(&self, run_ids: &[String]) -> Result<progenitor_client::ByteStream> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
let mut request = client.attach_events();
|
|
if !run_ids.is_empty() {
|
|
request = request.run_id(run_ids.join(","));
|
|
}
|
|
request.send().await
|
|
})
|
|
.await?;
|
|
Ok(response.into_inner())
|
|
}
|
|
|
|
pub async fn get_system_info(&self) -> Result<types::SystemInfoResponse> {
|
|
let response = self
|
|
.send_api(|client| async move { client.get_system_info().send().await })
|
|
.await?;
|
|
Ok(response.into_inner())
|
|
}
|
|
|
|
pub async fn get_system_disk_usage(&self, verbose: bool) -> Result<types::DiskUsageResponse> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client.get_system_disk_usage().verbose(verbose).send().await
|
|
})
|
|
.await?;
|
|
Ok(response.into_inner())
|
|
}
|
|
|
|
pub async fn get_system_repair_runs(&self) -> Result<types::SystemRepairRunsResponse> {
|
|
let response = self
|
|
.send_api(|client| async move { client.get_system_repair_runs().send().await })
|
|
.await?;
|
|
Ok(response.into_inner())
|
|
}
|
|
|
|
pub async fn prune_runs(
|
|
&self,
|
|
body: types::PruneRunsRequest,
|
|
) -> Result<types::PruneRunsResponse> {
|
|
let response = self
|
|
.send_api(|client| async move { client.prune_runs().body(body.clone()).send().await })
|
|
.await?;
|
|
Ok(response.into_inner())
|
|
}
|
|
|
|
pub async fn get_health(&self) -> Result<()> {
|
|
match time::timeout(
|
|
DEFAULT_HEALTH_REQUEST_TIMEOUT,
|
|
self.send_api(|client| async move { client.get_health().send().await }),
|
|
)
|
|
.await
|
|
{
|
|
Ok(result) => {
|
|
result?;
|
|
}
|
|
Err(_) => bail!("server health check timed out"),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn run_diagnostics(&self) -> Result<types::DiagnosticsReport> {
|
|
let response = self
|
|
.send_api(|client| async move { client.run_diagnostics().send().await })
|
|
.await?;
|
|
Ok(response.into_inner())
|
|
}
|
|
|
|
pub async fn get_github_repo(
|
|
&self,
|
|
owner: &str,
|
|
name: &str,
|
|
) -> Result<types::RepoCheckResponse> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.get_github_repo()
|
|
.owner(owner.to_string())
|
|
.name(name.to_string())
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
Ok(response.into_inner())
|
|
}
|
|
|
|
pub async fn run_preflight(
|
|
&self,
|
|
manifest: types::RunManifest,
|
|
) -> Result<types::PreflightResponse> {
|
|
self.send_api(
|
|
|client| async move { client.run_preflight().body(manifest.clone()).send().await },
|
|
)
|
|
.await
|
|
.map(progenitor_client::ResponseValue::into_inner)
|
|
}
|
|
|
|
pub async fn validate_run_manifest(
|
|
&self,
|
|
manifest: types::RunManifest,
|
|
) -> Result<types::ValidateResponse> {
|
|
self.send_api(|client| async move {
|
|
client
|
|
.validate_run_manifest()
|
|
.body(manifest.clone())
|
|
.send()
|
|
.await
|
|
})
|
|
.await
|
|
.map(progenitor_client::ResponseValue::into_inner)
|
|
}
|
|
|
|
pub async fn render_workflow_graph(
|
|
&self,
|
|
request: types::RenderWorkflowGraphRequest,
|
|
) -> Result<Vec<u8>> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.render_workflow_graph()
|
|
.body(request.clone())
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
let mut stream = response.into_inner();
|
|
let mut bytes = Vec::new();
|
|
while let Some(chunk) = stream.next().await {
|
|
let chunk = chunk.map_err(anyhow::Error::new)?;
|
|
bytes.extend_from_slice(&chunk);
|
|
}
|
|
Ok(bytes)
|
|
}
|
|
|
|
pub async fn start_run(&self, run_id: &RunId, resume: bool) -> Result<Run> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.start_run()
|
|
.id(run_id.to_string())
|
|
.body(types::StartRunRequest { resume })
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn cancel_run(&self, run_id: &RunId) -> Result<Run> {
|
|
let response = self
|
|
.send_api(
|
|
|client| async move { client.cancel_run().id(run_id.to_string()).send().await },
|
|
)
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn interrupt_run(&self, run_id: &RunId) -> Result<()> {
|
|
self.send_api(|client| async move {
|
|
client.interrupt_run().id(run_id.to_string()).send().await
|
|
})
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn steer_run(&self, run_id: &RunId, text: String, interrupt: bool) -> Result<()> {
|
|
let body: types::SteerRunRequest = types::SteerRunRequest::builder()
|
|
.text(text)
|
|
.interrupt(interrupt)
|
|
.try_into()
|
|
.map_err(|e| anyhow!("failed to build SteerRunRequest: {e}"))?;
|
|
self.send_api(|client| {
|
|
let body = body.clone();
|
|
async move {
|
|
client
|
|
.steer_run()
|
|
.id(run_id.to_string())
|
|
.body(body)
|
|
.send()
|
|
.await
|
|
}
|
|
})
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_run_pair_status(&self, run_id: &RunId) -> Result<RunPairStatusResponse> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.get_run_pair_status()
|
|
.id(run_id.to_string())
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn start_run_pair(&self, run_id: &RunId, stage_id: StageId) -> Result<PairRecord> {
|
|
let body = PairStartRequest { stage_id };
|
|
let response = self
|
|
.send_api(|client| {
|
|
let body = body.clone();
|
|
async move {
|
|
client
|
|
.start_run_pair()
|
|
.id(run_id.to_string())
|
|
.body(body)
|
|
.send()
|
|
.await
|
|
}
|
|
})
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn get_run_pair(&self, run_id: &RunId, pair_id: &PairId) -> Result<PairRecord> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.get_run_pair()
|
|
.id(run_id.to_string())
|
|
.pair_id(*pair_id)
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn end_run_pair(&self, run_id: &RunId, pair_id: &PairId) -> Result<PairRecord> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.end_run_pair()
|
|
.id(run_id.to_string())
|
|
.pair_id(*pair_id)
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn send_run_pair_message(
|
|
&self,
|
|
run_id: &RunId,
|
|
pair_id: &PairId,
|
|
request: PairMessageRequest,
|
|
) -> Result<PairMessageRecord> {
|
|
let body = request;
|
|
let response = self
|
|
.send_api(|client| {
|
|
let body = body.clone();
|
|
async move {
|
|
client
|
|
.send_run_pair_message()
|
|
.id(run_id.to_string())
|
|
.pair_id(*pair_id)
|
|
.body(body)
|
|
.send()
|
|
.await
|
|
}
|
|
})
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn get_run_pair_transcript(
|
|
&self,
|
|
run_id: &RunId,
|
|
pair_id: &PairId,
|
|
since_seq: Option<u32>,
|
|
limit: Option<u32>,
|
|
) -> Result<PairTranscriptResponse> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
let mut builder = client
|
|
.get_run_pair_transcript()
|
|
.id(run_id.to_string())
|
|
.pair_id(*pair_id);
|
|
if let Some(since_seq) = since_seq.and_then(non_zero_u64_from_u32) {
|
|
builder = builder.since_seq(since_seq);
|
|
}
|
|
if let Some(limit) = limit.and_then(non_zero_u64_from_u32) {
|
|
builder = builder.limit(limit);
|
|
}
|
|
builder.send().await
|
|
})
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn get_run_event_detail(
|
|
&self,
|
|
run_id: &RunId,
|
|
seq: u32,
|
|
max_content_length: Option<u32>,
|
|
) -> Result<RunEventDetailResponse> {
|
|
let seq = non_zero_u64_from_u32(seq).context("event seq must be non-zero")?;
|
|
let max_content_length = max_content_length.and_then(non_zero_u64_from_u32);
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
let mut builder = client
|
|
.get_run_event_detail()
|
|
.id(run_id.to_string())
|
|
.seq(seq);
|
|
if let Some(max_content_length) = max_content_length {
|
|
builder = builder.max_content_length(max_content_length);
|
|
}
|
|
builder.send().await
|
|
})
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn archive_run(&self, run_id: &RunId) -> Result<Run> {
|
|
let response = self
|
|
.send_api(
|
|
|client| async move { client.archive_run().id(run_id.to_string()).send().await },
|
|
)
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn unarchive_run(&self, run_id: &RunId) -> Result<Run> {
|
|
let response = self
|
|
.send_api(
|
|
|client| async move { client.unarchive_run().id(run_id.to_string()).send().await },
|
|
)
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn rewind_run(
|
|
&self,
|
|
run_id: &RunId,
|
|
request: types::RewindRequest,
|
|
) -> Result<RewindRunResult> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.rewind_run()
|
|
.id(run_id.to_string())
|
|
.body(request)
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
let status = response.status().as_u16();
|
|
Ok(RewindRunResult {
|
|
status,
|
|
response: response.into_inner(),
|
|
})
|
|
}
|
|
|
|
pub async fn fork_run(
|
|
&self,
|
|
run_id: &RunId,
|
|
request: types::ForkRequest,
|
|
) -> Result<types::ForkResponse> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.fork_run()
|
|
.id(run_id.to_string())
|
|
.body(request)
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
Ok(response.into_inner())
|
|
}
|
|
|
|
pub async fn run_timeline(&self, run_id: &RunId) -> Result<Vec<types::TimelineEntryResponse>> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.get_run_timeline()
|
|
.id(run_id.to_string())
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
Ok(response.into_inner())
|
|
}
|
|
|
|
pub async fn list_store_runs(&self) -> Result<Vec<Run>> {
|
|
self.list_store_runs_with_options(ListStoreRunsOptions::default())
|
|
.await
|
|
}
|
|
|
|
pub async fn list_store_runs_by_parent(&self, parent_id: RunId) -> Result<Vec<Run>> {
|
|
self.list_store_runs_with_options(ListStoreRunsOptions {
|
|
parent_id: Some(parent_id),
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn list_store_runs_with_options(
|
|
&self,
|
|
options: ListStoreRunsOptions,
|
|
) -> Result<Vec<Run>> {
|
|
let mut all_runs = Vec::new();
|
|
let mut offset = 0_u64;
|
|
let limit = 100_u64;
|
|
let parent_id = options.parent_id.map(|run_id| run_id.to_string());
|
|
|
|
loop {
|
|
let response = self
|
|
.send_api(|client| {
|
|
let parent_id = parent_id.clone();
|
|
async move {
|
|
let mut request = client
|
|
.list_runs()
|
|
.page_limit(limit)
|
|
.page_offset(offset)
|
|
.include_archived(true);
|
|
if let Some(parent_id) = parent_id {
|
|
request = request.parent_id(parent_id);
|
|
}
|
|
request.send().await
|
|
}
|
|
})
|
|
.await?;
|
|
let parsed = response.into_inner();
|
|
let batch = parsed
|
|
.data
|
|
.into_iter()
|
|
.map(convert_type)
|
|
.collect::<Result<Vec<_>>>()?;
|
|
let batch_len = batch.len() as u64;
|
|
all_runs.extend(batch);
|
|
|
|
if !parsed.meta.has_more || batch_len == 0 {
|
|
break;
|
|
}
|
|
offset += batch_len;
|
|
}
|
|
|
|
Ok(all_runs)
|
|
}
|
|
|
|
pub async fn link_run_parent(&self, child_id: &RunId, parent_id: &RunId) -> Result<Run> {
|
|
let body = types::UpdateRunParentRequest {
|
|
parent_id: parent_id.to_string(),
|
|
};
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.link_run_parent()
|
|
.id(child_id.to_string())
|
|
.body(body.clone())
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn unlink_run_parent(&self, child_id: &RunId) -> Result<Run> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.unlink_run_parent()
|
|
.id(child_id.to_string())
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn retrieve_run(&self, run_id: &RunId) -> Result<Run> {
|
|
let response = self
|
|
.send_api(
|
|
|client| async move { client.retrieve_run().id(run_id.to_string()).send().await },
|
|
)
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn resolve_run(&self, selector: &str) -> Result<Run> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.resolve_run()
|
|
.selector(selector.to_string())
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn get_run_state(&self, run_id: &RunId) -> Result<RunProjection> {
|
|
let response = self
|
|
.send_api(
|
|
|client| async move { client.get_run_state().id(run_id.to_string()).send().await },
|
|
)
|
|
.await?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn get_run_logs(&self, run_id: &RunId) -> Result<Option<Vec<u8>>> {
|
|
let response = self
|
|
.current_state()
|
|
.client
|
|
.get_run_logs()
|
|
.id(run_id.to_string())
|
|
.send()
|
|
.await;
|
|
match response {
|
|
Ok(response) => {
|
|
let mut stream = response.into_inner();
|
|
let mut bytes = Vec::new();
|
|
while let Some(chunk) = stream.next().await {
|
|
let chunk = chunk.map_err(anyhow::Error::new)?;
|
|
bytes.extend_from_slice(&chunk);
|
|
}
|
|
Ok(Some(bytes))
|
|
}
|
|
Err(err) => {
|
|
let err = classify_api_error(err).await.error;
|
|
if is_not_found_error(&err) {
|
|
Ok(None)
|
|
} else {
|
|
Err(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub async fn create_run_pull_request(
|
|
&self,
|
|
run_id: &RunId,
|
|
force: bool,
|
|
model: Option<String>,
|
|
) -> Result<fabro_types::PullRequestLink> {
|
|
let body = types::CreateRunPullRequestRequest { force, model };
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.create_run_pull_request()
|
|
.id(run_id.to_string())
|
|
.body(body.clone())
|
|
.send()
|
|
.await
|
|
})
|
|
.await
|
|
.map_err(add_pr_upgrade_hint)?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn get_run_pull_request(
|
|
&self,
|
|
run_id: &RunId,
|
|
) -> Result<fabro_types::PullRequestResponse> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.get_run_pull_request()
|
|
.id(run_id.to_string())
|
|
.send()
|
|
.await
|
|
})
|
|
.await
|
|
.map_err(add_pr_upgrade_hint)?;
|
|
Ok(response.into_inner())
|
|
}
|
|
|
|
pub async fn link_run_pull_request(
|
|
&self,
|
|
run_id: &RunId,
|
|
html_url: String,
|
|
) -> Result<fabro_types::PullRequestLink> {
|
|
let body = types::LinkRunPullRequestRequest { html_url };
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.link_run_pull_request()
|
|
.id(run_id.to_string())
|
|
.body(body.clone())
|
|
.send()
|
|
.await
|
|
})
|
|
.await
|
|
.map_err(add_pr_upgrade_hint)?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn unlink_run_pull_request(
|
|
&self,
|
|
run_id: &RunId,
|
|
) -> Result<fabro_types::PullRequestLink> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.unlink_run_pull_request()
|
|
.id(run_id.to_string())
|
|
.send()
|
|
.await
|
|
})
|
|
.await
|
|
.map_err(add_pr_upgrade_hint)?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn merge_run_pull_request(
|
|
&self,
|
|
run_id: &RunId,
|
|
method: MergeStrategy,
|
|
) -> Result<types::MergeRunPullRequestResponse> {
|
|
let body = types::MergeRunPullRequestRequest { method };
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.merge_run_pull_request()
|
|
.id(run_id.to_string())
|
|
.body(body.clone())
|
|
.send()
|
|
.await
|
|
})
|
|
.await
|
|
.map_err(add_pr_upgrade_hint)?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn close_run_pull_request(
|
|
&self,
|
|
run_id: &RunId,
|
|
) -> Result<types::CloseRunPullRequestResponse> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.close_run_pull_request()
|
|
.id(run_id.to_string())
|
|
.send()
|
|
.await
|
|
})
|
|
.await
|
|
.map_err(add_pr_upgrade_hint)?;
|
|
convert_type(response.into_inner())
|
|
}
|
|
|
|
pub async fn list_run_events(
|
|
&self,
|
|
run_id: &RunId,
|
|
since_seq: Option<u32>,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<EventEnvelope>> {
|
|
let mut next_since_seq = since_seq;
|
|
let mut all_events = Vec::new();
|
|
|
|
loop {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
let mut request = client.list_run_events().id(run_id.to_string());
|
|
if let Some(seq) = next_since_seq.and_then(non_zero_u64_from_u32) {
|
|
request = request.since_seq(seq);
|
|
}
|
|
if let Some(limit) = limit.and_then(non_zero_u64_from_usize) {
|
|
request = request.limit(limit);
|
|
}
|
|
request.send().await
|
|
})
|
|
.await?;
|
|
let parsed = response.into_inner();
|
|
let page_events = parsed
|
|
.data
|
|
.into_iter()
|
|
.map(convert_type::<_, EventEnvelope>)
|
|
.collect::<Result<Vec<EventEnvelope>>>()?;
|
|
let next_page_since_seq = page_events.last().map(|event| event.seq.saturating_add(1));
|
|
all_events.extend(page_events);
|
|
|
|
if limit.is_some() || !parsed.meta.has_more || next_page_since_seq.is_none() {
|
|
break;
|
|
}
|
|
next_since_seq = next_page_since_seq;
|
|
}
|
|
|
|
Ok(all_events)
|
|
}
|
|
|
|
pub async fn list_run_events_until(
|
|
&self,
|
|
run_id: &RunId,
|
|
since_seq: Option<u32>,
|
|
max_events: usize,
|
|
) -> Result<Vec<EventEnvelope>> {
|
|
if max_events == 0 {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut next_since_seq = since_seq;
|
|
let mut all_events = Vec::new();
|
|
while all_events.len() < max_events {
|
|
let remaining = max_events - all_events.len();
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
let mut request = client
|
|
.list_run_events()
|
|
.id(run_id.to_string())
|
|
.limit(remaining.min(1000) as u64);
|
|
if let Some(seq) = next_since_seq.and_then(non_zero_u64_from_u32) {
|
|
request = request.since_seq(seq);
|
|
}
|
|
request.send().await
|
|
})
|
|
.await?;
|
|
let parsed = response.into_inner();
|
|
let page_events = parsed
|
|
.data
|
|
.into_iter()
|
|
.map(convert_type::<_, EventEnvelope>)
|
|
.collect::<Result<Vec<EventEnvelope>>>()?;
|
|
let next_page_since_seq = page_events.last().map(|event| event.seq.saturating_add(1));
|
|
all_events.extend(page_events);
|
|
|
|
if !parsed.meta.has_more || next_page_since_seq.is_none() {
|
|
break;
|
|
}
|
|
next_since_seq = next_page_since_seq;
|
|
}
|
|
|
|
Ok(all_events)
|
|
}
|
|
|
|
pub async fn attach_run_events(
|
|
&self,
|
|
run_id: &RunId,
|
|
since_seq: Option<u32>,
|
|
) -> Result<RunEventStream> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
let mut request = client.attach_run_events().id(run_id.to_string());
|
|
if let Some(seq) = since_seq.and_then(non_zero_u64_from_u32) {
|
|
request = request.since_seq(seq);
|
|
}
|
|
request.send().await
|
|
})
|
|
.await?;
|
|
Ok(RunEventStream::new(response.into_inner()))
|
|
}
|
|
|
|
pub async fn list_run_questions(&self, run_id: &RunId) -> Result<Vec<types::ApiQuestion>> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.list_run_questions()
|
|
.id(run_id.to_string())
|
|
.page_limit(100)
|
|
.page_offset(0)
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
Ok(response.into_inner().data)
|
|
}
|
|
|
|
pub async fn submit_run_answer(
|
|
&self,
|
|
run_id: &RunId,
|
|
qid: &str,
|
|
body: types::SubmitAnswerRequest,
|
|
) -> Result<()> {
|
|
self.send_api(|client| async move {
|
|
client
|
|
.submit_run_answer()
|
|
.id(run_id.to_string())
|
|
.qid(qid)
|
|
.body(body.clone())
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn append_run_event(&self, run_id: &RunId, event: &RunEvent) -> Result<u32> {
|
|
let body: types::RunEvent = convert_type(event)?;
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.append_run_event()
|
|
.id(run_id.to_string())
|
|
.body(body.clone())
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
u32::try_from(response.into_inner().seq).context("append_run_event returned invalid seq")
|
|
}
|
|
|
|
pub async fn write_run_blob(&self, run_id: &RunId, data: &[u8]) -> Result<RunBlobId> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.write_run_blob()
|
|
.id(run_id.to_string())
|
|
.body(data.to_vec())
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
response
|
|
.into_inner()
|
|
.id
|
|
.parse()
|
|
.context("write_run_blob returned invalid blob id")
|
|
}
|
|
|
|
pub async fn read_run_blob(
|
|
&self,
|
|
run_id: &RunId,
|
|
blob_id: &RunBlobId,
|
|
) -> Result<Option<Bytes>> {
|
|
let response = self
|
|
.current_state()
|
|
.client
|
|
.read_run_blob()
|
|
.id(run_id.to_string())
|
|
.blob_id(blob_id.to_string())
|
|
.send()
|
|
.await;
|
|
match response {
|
|
Ok(response) => {
|
|
let mut stream = response.into_inner();
|
|
let mut bytes = Vec::new();
|
|
while let Some(chunk) = stream.next().await {
|
|
let chunk = chunk.map_err(anyhow::Error::new)?;
|
|
bytes.extend_from_slice(&chunk);
|
|
}
|
|
Ok(Some(Bytes::from(bytes)))
|
|
}
|
|
Err(err) => {
|
|
let err = classify_api_error(err).await.error;
|
|
if is_not_found_error(&err) {
|
|
Ok(None)
|
|
} else {
|
|
Err(err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_types,
|
|
reason = "Client builds raw server API request URLs for wire transit; logging redaction is handled at log boundaries."
|
|
)]
|
|
pub async fn delete_store_run(&self, run_id: &RunId, force: bool) -> Result<()> {
|
|
let base_url = self.base_url();
|
|
let mut url = fabro_http::Url::parse(&base_url)
|
|
.with_context(|| format!("invalid server base URL {base_url}"))?;
|
|
url.path_segments_mut()
|
|
.map_err(|()| anyhow!("server base URL cannot accept path segments"))?
|
|
.extend(["api", "v1", "runs", &run_id.to_string()]);
|
|
if force {
|
|
url.query_pairs_mut().append_pair("force", "true");
|
|
}
|
|
|
|
self.send_http(|http_client| async move { http_client.delete(url.clone()).send().await })
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_run_artifacts(&self, run_id: &RunId) -> Result<Vec<types::RunArtifactEntry>> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.list_run_artifacts()
|
|
.id(run_id.to_string())
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
Ok(response.into_inner().data)
|
|
}
|
|
|
|
pub async fn download_stage_artifact(
|
|
&self,
|
|
run_id: &RunId,
|
|
stage_id: &StageId,
|
|
retry: u32,
|
|
filename: &str,
|
|
) -> Result<Vec<u8>> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.get_stage_artifact()
|
|
.id(run_id.to_string())
|
|
.stage_id(stage_id.to_string())
|
|
.retry(retry.cast_signed())
|
|
.filename(filename)
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
let mut stream = response.into_inner();
|
|
let mut bytes = Vec::new();
|
|
while let Some(chunk) = stream.next().await {
|
|
let chunk = chunk.map_err(anyhow::Error::new)?;
|
|
bytes.extend_from_slice(&chunk);
|
|
}
|
|
Ok(bytes)
|
|
}
|
|
|
|
#[expect(
|
|
clippy::disallowed_types,
|
|
reason = "Client builds raw server API request URLs for wire transit; logging redaction is handled at log boundaries."
|
|
)]
|
|
fn stage_artifacts_url(
|
|
&self,
|
|
run_id: &RunId,
|
|
stage_id: &StageId,
|
|
retry: u32,
|
|
) -> Result<fabro_http::Url> {
|
|
let base_url = self.base_url();
|
|
let mut url = fabro_http::Url::parse(&base_url)
|
|
.with_context(|| format!("invalid server base URL {base_url}"))?;
|
|
url.path_segments_mut()
|
|
.map_err(|()| anyhow!("server base URL cannot accept path segments"))?
|
|
.extend([
|
|
"api",
|
|
"v1",
|
|
"runs",
|
|
&run_id.to_string(),
|
|
"stages",
|
|
&stage_id.to_string(),
|
|
"artifacts",
|
|
]);
|
|
url.query_pairs_mut()
|
|
.append_pair("retry", &retry.to_string());
|
|
Ok(url)
|
|
}
|
|
|
|
pub async fn upload_stage_artifact_file(
|
|
&self,
|
|
run_id: &RunId,
|
|
stage_id: &StageId,
|
|
retry: u32,
|
|
filename: &str,
|
|
path: &Path,
|
|
bearer_token: &str,
|
|
) -> Result<()> {
|
|
let mut url = self.stage_artifacts_url(run_id, stage_id, retry)?;
|
|
url.query_pairs_mut().append_pair("filename", filename);
|
|
|
|
let file = File::open(path)
|
|
.await
|
|
.with_context(|| format!("failed to open artifact {}", path.display()))?;
|
|
let content_length = file
|
|
.metadata()
|
|
.await
|
|
.with_context(|| format!("failed to stat artifact {}", path.display()))?
|
|
.len();
|
|
let body = fabro_http::Body::wrap_stream(ReaderStream::new(file));
|
|
|
|
let response = self
|
|
.current_state()
|
|
.http_client
|
|
.post(url)
|
|
.bearer_auth(bearer_token)
|
|
.header(CONTENT_TYPE, "application/octet-stream")
|
|
.header(CONTENT_LENGTH, content_length.to_string())
|
|
.body(body)
|
|
.send()
|
|
.await
|
|
.with_context(|| format!("failed to upload artifact {}", path.display()))?;
|
|
classify_http_response(response)
|
|
.await?
|
|
.map(|_| ())
|
|
.map_err(|failure| raw_response_failure_error(&failure))
|
|
}
|
|
|
|
pub async fn upload_stage_artifact_batch(
|
|
&self,
|
|
run_id: &RunId,
|
|
stage_id: &StageId,
|
|
retry: u32,
|
|
artifact_capture_dir: &Path,
|
|
artifacts: &[ArtifactUpload],
|
|
bearer_token: &str,
|
|
) -> Result<()> {
|
|
let url = self.stage_artifacts_url(run_id, stage_id, retry)?;
|
|
let mut manifest_entries = Vec::with_capacity(artifacts.len());
|
|
let mut file_parts = Vec::with_capacity(artifacts.len());
|
|
|
|
for (index, artifact) in artifacts.iter().enumerate() {
|
|
let part_name = format!("file{}", index + 1);
|
|
let path = artifact_capture_dir.join(&artifact.path);
|
|
let file = File::open(&path)
|
|
.await
|
|
.with_context(|| format!("failed to open artifact {}", path.display()))?;
|
|
let content_length = file
|
|
.metadata()
|
|
.await
|
|
.with_context(|| format!("failed to stat artifact {}", path.display()))?
|
|
.len();
|
|
|
|
manifest_entries.push(ArtifactBatchUploadEntry {
|
|
part: part_name.clone(),
|
|
path: artifact.path.clone(),
|
|
sha256: Some(artifact.content_sha256.clone()),
|
|
expected_bytes: Some(artifact.bytes),
|
|
content_type: Some(artifact.mime.clone()),
|
|
});
|
|
|
|
file_parts.push((
|
|
part_name,
|
|
Part::stream_with_length(
|
|
fabro_http::Body::wrap_stream(ReaderStream::new(file)),
|
|
content_length,
|
|
)
|
|
.file_name(artifact.path.clone()),
|
|
));
|
|
}
|
|
|
|
let manifest = ArtifactBatchUploadManifest {
|
|
entries: manifest_entries,
|
|
};
|
|
let manifest_part =
|
|
Part::text(serde_json::to_string(&manifest)?).mime_str("application/json")?;
|
|
let mut form = Form::new().part("manifest", manifest_part);
|
|
for (part_name, part) in file_parts {
|
|
form = form.part(part_name, part);
|
|
}
|
|
|
|
let response = self
|
|
.current_state()
|
|
.http_client
|
|
.post(url)
|
|
.bearer_auth(bearer_token)
|
|
.multipart(form)
|
|
.send()
|
|
.await
|
|
.context("failed to upload artifact batch")?;
|
|
classify_http_response(response)
|
|
.await?
|
|
.map(|_| ())
|
|
.map_err(|failure| raw_response_failure_error(&failure))
|
|
}
|
|
|
|
pub async fn generate_preview_url(
|
|
&self,
|
|
run_id: &RunId,
|
|
port: u16,
|
|
expires_in_secs: u64,
|
|
signed: bool,
|
|
) -> Result<types::PreviewUrlResponse> {
|
|
let expires_in_secs = NonZeroU64::new(expires_in_secs)
|
|
.ok_or_else(|| anyhow!("preview expiry must be greater than zero"))?;
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.generate_preview_url()
|
|
.id(run_id.to_string())
|
|
.body(types::PreviewUrlRequest {
|
|
expires_in_secs,
|
|
port: i64::from(port),
|
|
signed,
|
|
})
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
Ok(response.into_inner())
|
|
}
|
|
|
|
pub async fn create_run_ssh_access(
|
|
&self,
|
|
run_id: &RunId,
|
|
ttl_minutes: f64,
|
|
) -> Result<types::SshAccessResponse> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.create_run_ssh_access()
|
|
.id(run_id.to_string())
|
|
.body(types::SshAccessRequest { ttl_minutes })
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
Ok(response.into_inner())
|
|
}
|
|
|
|
pub async fn list_sandbox_files(
|
|
&self,
|
|
run_id: &RunId,
|
|
path: &str,
|
|
depth: Option<u32>,
|
|
) -> Result<Vec<types::SandboxFileEntry>> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
let mut request = client
|
|
.list_sandbox_files()
|
|
.id(run_id.to_string())
|
|
.path(path);
|
|
if let Some(depth) = depth.and_then(non_zero_u64_from_u32) {
|
|
request = request.depth(depth);
|
|
}
|
|
request.send().await
|
|
})
|
|
.await?;
|
|
Ok(response.into_inner().data)
|
|
}
|
|
|
|
pub async fn get_sandbox_file(&self, run_id: &RunId, path: &str) -> Result<Vec<u8>> {
|
|
let response = self
|
|
.send_api(|client| async move {
|
|
client
|
|
.get_sandbox_file()
|
|
.id(run_id.to_string())
|
|
.path(path)
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
let mut stream = response.into_inner();
|
|
let mut bytes = Vec::new();
|
|
while let Some(chunk) = stream.next().await {
|
|
let chunk = chunk.map_err(anyhow::Error::new)?;
|
|
bytes.extend_from_slice(&chunk);
|
|
}
|
|
Ok(bytes)
|
|
}
|
|
|
|
pub async fn put_sandbox_file(&self, run_id: &RunId, path: &str, bytes: Vec<u8>) -> Result<()> {
|
|
self.send_api(|client| async move {
|
|
client
|
|
.put_sandbox_file()
|
|
.id(run_id.to_string())
|
|
.path(path)
|
|
.body(bytes.clone())
|
|
.send()
|
|
.await
|
|
})
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn client_state(
|
|
base_url: String,
|
|
http_client: fabro_http::HttpClient,
|
|
bearer_token: Option<String>,
|
|
) -> ClientState {
|
|
let client = fabro_api::ApiClient::new_with_client(&base_url, http_client.clone());
|
|
ClientState {
|
|
client,
|
|
http_client,
|
|
bearer_token,
|
|
base_url,
|
|
}
|
|
}
|
|
|
|
fn default_transport_connector(target: ServerTarget) -> TransportConnector {
|
|
TransportConnector::new(move |bearer_token| {
|
|
let target = target.clone();
|
|
async move { connect_target_transport(&target, bearer_token.as_deref()) }
|
|
})
|
|
}
|
|
|
|
fn connect_target_transport(
|
|
target: &ServerTarget,
|
|
bearer_token: Option<&str>,
|
|
) -> Result<(fabro_http::HttpClient, String)> {
|
|
if let Some(api_url) = target.as_http_url() {
|
|
let mut builder = fabro_http::HttpClientBuilder::new();
|
|
builder = match bearer_token {
|
|
Some(token) => apply_bearer_token_auth(builder, token)?,
|
|
None => builder,
|
|
};
|
|
let http_client = builder.build()?;
|
|
return Ok((http_client, api_url.to_string()));
|
|
}
|
|
|
|
let Some(path) = target.as_unix_socket_path() else {
|
|
bail!("server target must be an http(s) URL or absolute Unix socket path");
|
|
};
|
|
let mut builder = fabro_http::HttpClientBuilder::new()
|
|
.unix_socket(path)
|
|
.no_proxy();
|
|
builder = match bearer_token {
|
|
Some(token) => apply_bearer_token_auth(builder, token)?,
|
|
None => builder,
|
|
};
|
|
let http_client = builder.build()?;
|
|
Ok((http_client, "http://fabro".to_string()))
|
|
}
|
|
|
|
pub fn apply_bearer_token_auth(
|
|
builder: fabro_http::HttpClientBuilder,
|
|
token: &str,
|
|
) -> Result<fabro_http::HttpClientBuilder> {
|
|
let mut headers = fabro_http::HeaderMap::new();
|
|
headers.insert(
|
|
AUTHORIZATION,
|
|
fabro_http::HeaderValue::from_str(&format!("Bearer {token}"))
|
|
.context("invalid bearer token header value")?,
|
|
);
|
|
Ok(builder.default_headers(headers))
|
|
}
|
|
|
|
fn non_zero_u64_from_u32(value: u32) -> Option<NonZeroU64> {
|
|
NonZeroU64::new(u64::from(value))
|
|
}
|
|
|
|
fn non_zero_u64_from_usize(value: usize) -> Option<NonZeroU64> {
|
|
u64::try_from(value).ok().and_then(NonZeroU64::new)
|
|
}
|
|
|
|
// A 404 without a structured error code means the server didn't know the
|
|
// route — PR commands moved server-side in a recent release. Point users at
|
|
// an upgrade rather than leaving them with an opaque message. A 404 with a
|
|
// code (e.g. no_stored_record) is a normal app-level response and passes
|
|
// through unchanged.
|
|
fn add_pr_upgrade_hint(err: anyhow::Error) -> anyhow::Error {
|
|
let is_missing_route = api_failure_for(&err).is_some_and(|failure| {
|
|
failure.status == fabro_http::StatusCode::NOT_FOUND && failure.code.is_none()
|
|
});
|
|
if is_missing_route {
|
|
anyhow!(
|
|
"{err}\n\n\
|
|
The fabro server may not support pull request endpoints — `fabro pr` commands \
|
|
moved server-side in a recent release. Upgrade the fabro server."
|
|
)
|
|
} else {
|
|
err
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use chrono::Duration as ChronoDuration;
|
|
use fabro_util::exit;
|
|
use httpmock::Method::{GET, POST};
|
|
use httpmock::MockServer;
|
|
use serde_json::json;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpListener;
|
|
|
|
use super::*;
|
|
use crate::error::tag_with_failure;
|
|
use crate::{AuthStore, DevTokenEntry};
|
|
|
|
fn oauth_entry(login: &str) -> OAuthEntry {
|
|
let now = chrono::Utc::now();
|
|
OAuthEntry {
|
|
access_token: format!("access-{login}"),
|
|
access_token_expires_at: now + ChronoDuration::minutes(10),
|
|
refresh_token: format!("refresh-{login}"),
|
|
refresh_token_expires_at: now + ChronoDuration::days(30),
|
|
subject: StoredSubject {
|
|
idp_issuer: "https://github.com".to_string(),
|
|
idp_subject: "12345".to_string(),
|
|
login: login.to_string(),
|
|
name: format!("Name {login}"),
|
|
email: format!("{login}@example.com"),
|
|
},
|
|
logged_in_at: now,
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn refresh_access_token_allows_plain_http_targets() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let auth_store = AuthStore::new(temp.path().join("auth.json"));
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
let server = tokio::spawn(async move {
|
|
let (mut stream, _) = listener.accept().await.unwrap();
|
|
let mut request = vec![0_u8; 4096];
|
|
let read = stream.read(&mut request).await.unwrap();
|
|
let request = String::from_utf8_lossy(&request[..read]);
|
|
assert!(
|
|
request.starts_with("POST /auth/cli/refresh HTTP/1.1"),
|
|
"unexpected refresh request: {request}"
|
|
);
|
|
let body = serde_json::json!({
|
|
"access_token": "access-refreshed",
|
|
"access_token_expires_at": (chrono::Utc::now() + ChronoDuration::minutes(10)).to_rfc3339(),
|
|
"refresh_token": "refresh-refreshed",
|
|
"refresh_token_expires_at": (chrono::Utc::now() + ChronoDuration::days(30)).to_rfc3339(),
|
|
"subject": {
|
|
"idp_issuer": "https://github.com",
|
|
"idp_subject": "12345",
|
|
"login": "octocat",
|
|
"name": "Name octocat",
|
|
"email": "octocat@example.com"
|
|
}
|
|
})
|
|
.to_string();
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
stream.write_all(response.as_bytes()).await.unwrap();
|
|
});
|
|
let target = ServerTarget::http_url(format!("http://localhost:{port}")).unwrap();
|
|
let entry = oauth_entry("octocat");
|
|
auth_store
|
|
.put(&target, AuthEntry::OAuth(entry.clone()))
|
|
.unwrap();
|
|
|
|
let client = Client::builder()
|
|
.target(target.clone())
|
|
.credential(Credential::OAuth(entry))
|
|
.oauth_session(OAuthSession::new(target.clone(), auth_store.clone()))
|
|
.transport("http://localhost", fabro_http::test_http_client().unwrap())
|
|
.connect()
|
|
.await
|
|
.unwrap();
|
|
|
|
client.refresh_access_token("access-octocat").await.unwrap();
|
|
let refreshed = auth_store.get(&target).unwrap().unwrap();
|
|
let AuthEntry::OAuth(refreshed) = refreshed else {
|
|
panic!("expected OAuth entry");
|
|
};
|
|
assert_eq!(refreshed.access_token, "access-refreshed");
|
|
assert_eq!(refreshed.refresh_token, "refresh-refreshed");
|
|
server.abort();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn request_timeout_does_not_cap_stream_body_after_headers() {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
let server = tokio::spawn(async move {
|
|
let (mut stream, _) = listener.accept().await.unwrap();
|
|
let mut request = vec![0_u8; 4096];
|
|
let read = stream.read(&mut request).await.unwrap();
|
|
let request = String::from_utf8_lossy(&request[..read]);
|
|
assert!(
|
|
request.starts_with("GET /api/v1/attach HTTP/1.1"),
|
|
"unexpected attach request: {request}"
|
|
);
|
|
|
|
let body = b"data: hello\n\n";
|
|
let headers = format!(
|
|
"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
|
|
body.len()
|
|
);
|
|
stream.write_all(headers.as_bytes()).await.unwrap();
|
|
time::sleep(Duration::from_millis(100)).await;
|
|
stream.write_all(body).await.unwrap();
|
|
});
|
|
|
|
let target = ServerTarget::http_url(format!("http://{addr}")).unwrap();
|
|
let client = Client::builder()
|
|
.target(target)
|
|
.request_timeout(Duration::from_millis(50))
|
|
.connect()
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut stream = client.attach_events(&[]).await.unwrap();
|
|
let chunk = time::timeout(Duration::from_millis(500), stream.next())
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
assert_eq!(chunk, Bytes::from_static(b"data: hello\n\n"));
|
|
server.await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_models_allows_custom_provider_filters() {
|
|
let server = MockServer::start_async().await;
|
|
let mock = server
|
|
.mock_async(|when, then| {
|
|
when.method(GET)
|
|
.path("/api/v1/models")
|
|
.query_param("provider", "bedrock");
|
|
then.status(200)
|
|
.header("Content-Type", "application/json")
|
|
.body(
|
|
serde_json::json!({
|
|
"data": [],
|
|
"meta": { "has_more": false }
|
|
})
|
|
.to_string(),
|
|
);
|
|
})
|
|
.await;
|
|
|
|
let client = Client::new_no_proxy(&server.url("")).unwrap();
|
|
let models = client.list_models(Some("bedrock"), None).await.unwrap();
|
|
|
|
mock.assert_async().await;
|
|
assert!(models.is_empty());
|
|
}
|
|
|
|
async fn oauth_client(
|
|
server: &MockServer,
|
|
) -> (tempfile::TempDir, Client, AuthStore, ServerTarget) {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let auth_store = AuthStore::new(temp.path().join("auth.json"));
|
|
let target = ServerTarget::http_url(server.base_url()).unwrap();
|
|
let entry = oauth_entry("octocat");
|
|
auth_store
|
|
.put(&target, AuthEntry::OAuth(entry.clone()))
|
|
.unwrap();
|
|
|
|
let client = Client::builder()
|
|
.target(target.clone())
|
|
.credential(Credential::OAuth(entry))
|
|
.oauth_session(OAuthSession::new(target.clone(), auth_store.clone()))
|
|
.transport(
|
|
server.base_url(),
|
|
fabro_http::HttpClientBuilder::new()
|
|
.no_proxy()
|
|
.build()
|
|
.unwrap(),
|
|
)
|
|
.connect()
|
|
.await
|
|
.unwrap();
|
|
|
|
(temp, client, auth_store, target)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_access_token_reinstalls_stored_dev_token_without_refresh_request() {
|
|
let server = MockServer::start();
|
|
let refresh_mock = server.mock(|when, then| {
|
|
when.method(POST).path("/auth/cli/refresh");
|
|
then.status(500);
|
|
});
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let auth_store = AuthStore::new(temp.path().join("auth.json"));
|
|
let target = ServerTarget::http_url(server.base_url()).unwrap();
|
|
let old_token =
|
|
"fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd";
|
|
let stored_token =
|
|
"fabro_dev_abababababababababababababababababababababababababababababababab";
|
|
auth_store
|
|
.put(
|
|
&target,
|
|
AuthEntry::DevToken(DevTokenEntry {
|
|
token: stored_token.to_string(),
|
|
logged_in_at: chrono::Utc::now(),
|
|
}),
|
|
)
|
|
.unwrap();
|
|
|
|
let seen_tokens = Arc::new(std::sync::Mutex::new(Vec::new()));
|
|
let seen_tokens_for_connector = Arc::clone(&seen_tokens);
|
|
let base_url = server.base_url();
|
|
let client = Client::builder()
|
|
.target(target.clone())
|
|
.credential(Credential::DevToken(old_token.to_string()))
|
|
.oauth_session(OAuthSession::new(target.clone(), auth_store.clone()))
|
|
.transport_connector(TransportConnector::new(move |bearer_token| {
|
|
let seen_tokens = Arc::clone(&seen_tokens_for_connector);
|
|
let base_url = base_url.clone();
|
|
async move {
|
|
seen_tokens.lock().unwrap().push(bearer_token);
|
|
Ok((fabro_http::test_http_client().unwrap(), base_url))
|
|
}
|
|
}))
|
|
.connect()
|
|
.await
|
|
.unwrap();
|
|
|
|
client.refresh_access_token(old_token).await.unwrap();
|
|
|
|
assert_eq!(refresh_mock.calls(), 0);
|
|
assert_eq!(*seen_tokens.lock().unwrap(), vec![
|
|
Some(old_token.to_string()),
|
|
Some(stored_token.to_string()),
|
|
]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_access_token_classifies_expired_refresh_tokens() {
|
|
let server = MockServer::start();
|
|
server.mock(|when, then| {
|
|
when.method(POST)
|
|
.path("/auth/cli/refresh")
|
|
.header("authorization", "Bearer refresh-octocat");
|
|
then.status(401)
|
|
.header("Content-Type", "application/json")
|
|
.json_body(json!({
|
|
"error": "refresh_token_expired",
|
|
"error_description": "CLI session has expired. Run `fabro auth login` again."
|
|
}));
|
|
});
|
|
|
|
let (_temp, client, auth_store, target) = oauth_client(&server).await;
|
|
let err = client
|
|
.refresh_access_token("access-octocat")
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(exit::exit_code_for(&err), 4);
|
|
assert!(auth_store.get(&target).unwrap().is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_access_token_keeps_server_errors_as_exit_1() {
|
|
let server = MockServer::start();
|
|
server.mock(|when, then| {
|
|
when.method(POST)
|
|
.path("/auth/cli/refresh")
|
|
.header("authorization", "Bearer refresh-octocat");
|
|
then.status(500)
|
|
.header("Content-Type", "application/json")
|
|
.json_body(json!({
|
|
"error": "server_error",
|
|
"error_description": "OAuth server exploded."
|
|
}));
|
|
});
|
|
|
|
let (_temp, client, auth_store, target) = oauth_client(&server).await;
|
|
let err = client
|
|
.refresh_access_token("access-octocat")
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(exit::exit_code_for(&err), 1);
|
|
assert!(auth_store.get(&target).unwrap().is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn refresh_access_token_keeps_login_not_permitted_as_exit_1() {
|
|
let server = MockServer::start();
|
|
server.mock(|when, then| {
|
|
when.method(POST)
|
|
.path("/auth/cli/refresh")
|
|
.header("authorization", "Bearer refresh-octocat");
|
|
then.status(403)
|
|
.header("Content-Type", "application/json")
|
|
.json_body(json!({
|
|
"error": "unauthorized",
|
|
"error_description": "Login not permitted for this user."
|
|
}));
|
|
});
|
|
|
|
let (_temp, client, auth_store, target) = oauth_client(&server).await;
|
|
let err = client
|
|
.refresh_access_token("access-octocat")
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(exit::exit_code_for(&err), 1);
|
|
assert!(auth_store.get(&target).unwrap().is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn add_pr_upgrade_hint_appends_on_unstructured_404() {
|
|
let err = tag_with_failure(
|
|
anyhow!("request failed with status 404 Not Found"),
|
|
ApiFailure {
|
|
status: fabro_http::StatusCode::NOT_FOUND,
|
|
code: None,
|
|
},
|
|
);
|
|
let wrapped = super::add_pr_upgrade_hint(err);
|
|
let message = wrapped.to_string();
|
|
assert!(
|
|
message.contains("Upgrade the fabro server"),
|
|
"expected upgrade hint, got: {message}"
|
|
);
|
|
assert!(message.contains("status 404"), "original preserved");
|
|
}
|
|
|
|
#[test]
|
|
fn add_pr_upgrade_hint_does_not_touch_structured_404() {
|
|
let err = tag_with_failure(
|
|
anyhow!("No pull request found in store. Create one first with: fabro pr create abc"),
|
|
ApiFailure {
|
|
status: fabro_http::StatusCode::NOT_FOUND,
|
|
code: Some("no_stored_record".to_string()),
|
|
},
|
|
);
|
|
let wrapped = super::add_pr_upgrade_hint(err);
|
|
let message = wrapped.to_string();
|
|
assert!(
|
|
!message.contains("Upgrade the fabro server"),
|
|
"hint should only fire on unstructured 404s, got: {message}"
|
|
);
|
|
}
|
|
}
|