Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 8 additions & 18 deletions cli/src/cli_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ pub struct TopLevelCommandMetadata {
}

pub const AUTH_CLAP_ABOUT: &str = "Authenticate with `WorkOS` device authorization flow";
pub const AUTH_TOP_LEVEL_PURPOSE: &str = "Authenticate with WorkOS and inspect local auth state";
pub const AUTH_SHOW_IN_TOP_LEVEL_HELP: bool = false;
pub const AUTH_TOP_LEVEL_PURPOSE: &str = "Authenticate with WorkOS";
pub const AUTH_SHOW_IN_TOP_LEVEL_HELP: bool = true;

pub const CONFIG_CLAP_ABOUT: &str =
"Inspect or validate runtime config and observability resolution";
Expand Down Expand Up @@ -143,12 +143,11 @@ pub fn auth_help_text() -> String {
let base = render_help_for_path(&["auth"]).expect("auth help should be renderable");

format!(
"{}\n{}:\n {}\n {}\n {}\n {}\n",
"{}\n{}:\n {}\n {}\n {}\n",
base,
heading("Examples"),
command_name("sce auth login"),
command_name("sce auth renew"),
command_name("sce auth status"),
command_name("sce auth whoami"),
command_name("sce auth logout")
)
}
Expand Down Expand Up @@ -241,29 +240,20 @@ pub enum Commands {

#[derive(Subcommand, Debug, Clone, PartialEq, Eq)]
pub enum AuthSubcommand {
#[command(about = "Start login flow and store credentials")]
#[command(about = "Start the login flow")]
Login {
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},

#[command(about = "Renew stored credentials when they are expired or near expiry")]
Renew {
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,

#[arg(long)]
force: bool,
},

#[command(about = "Remove stored credentials from the local machine")]
#[command(about = "Log out the currently authenticated user")]
Logout {
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},

#[command(about = "Show current authentication status from stored credentials")]
Status {
#[command(about = "Show information about the currently authenticated user")]
Whoami {
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
},
Expand Down
47 changes: 46 additions & 1 deletion cli/src/services/agent_trace_sync/control_plane.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
//! Wire-contract DTOs for the control-plane Agent Trace ingestion API.
//!
//! These types define the request/response shapes for
//! `POST /agent-trace/ingestion/state` and `POST /agent-trace/ingestion/batch`.
//! `GET /me`, `POST /agent-trace/ingestion/state`, and
//! `POST /agent-trace/ingestion/batch`.
//! They perform no HTTP I/O and hold no cursor state themselves.

use std::fmt;
Expand Down Expand Up @@ -105,6 +106,7 @@ pub struct AgentTraceIngestionBatchResponse {

const STATE_PATH: &str = "agent-trace/ingestion/state";
const BATCH_PATH: &str = "agent-trace/ingestion/batch";
const ME_PATH: &str = "me";

const STATE_RETRY_MAX_ATTEMPTS: u32 = 1;
const STATE_RETRY_TIMEOUT_MS: u64 = 60_000;
Expand Down Expand Up @@ -234,6 +236,39 @@ pub struct AuthenticatedControlPlaneClient {
refresh_lock: Arc<tokio::sync::Mutex<()>>,
}

/// Response body for `GET /me`.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeResponse {
pub user: MeUser,
pub authorization: MeAuthorization,
pub workspace: Option<MeWorkspace>,
}

/// User profile returned by the Control Plane's `/me` endpoint.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeUser {
pub email: String,
pub first_name: Option<String>,
pub last_name: Option<String>,
}

/// Authorization information returned by the Control Plane's `/me` endpoint.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeAuthorization {
pub permissions: Vec<String>,
pub role: Option<String>,
}

/// Current workspace returned by the Control Plane's `/me` endpoint.
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MeWorkspace {
pub name: String,
}

impl AuthenticatedControlPlaneClient {
pub fn new(
http: reqwest::Client,
Expand Down Expand Up @@ -306,6 +341,16 @@ impl AuthenticatedControlPlaneClient {
}
}

/// Calls `GET /me` to retrieve the current authenticated user's profile,
/// authorization, and optional workspace from the Control Plane.
pub async fn me(&self) -> Result<MeResponse, ControlPlaneError> {
let url = self.endpoint(ME_PATH);
let response = self
.execute_authenticated(|token| self.http.get(&url).bearer_auth(token))
.await?;
classify_response(response).await
}

pub async fn ingest_messages(
&self,
request: &AgentTraceIngestionBatchRequest<AgentTraceMessageExportRow>,
Expand Down
45 changes: 42 additions & 3 deletions cli/src/services/agent_trace_sync/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@ impl AgentTraceExportRow for AgentTraceAgentTraceExportRow {
/// Result of one batch-ingest attempt, as classified by the caller-supplied
/// ingest closure. `Conflict` and `Ambiguous` carry no data: reconciliation
/// always re-derives truth from a fresh `/state` call rather than trusting
/// anything about the failed attempt itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// anything about the failed attempt itself. `Terminal` is different: the
/// attempt is known to have failed in a way that cannot be resolved by
/// `/state`, so the stream stops without invoking its refresh closure.
#[derive(Debug, PartialEq, Eq)]
pub enum BatchAttemptOutcome {
/// The batch was accepted. `accepted` and `cursor` are the server
/// response's own fields, validated by the engine before the stream
Expand All @@ -67,6 +69,9 @@ pub enum BatchAttemptOutcome {
/// The batch outcome could not be determined (`5xx`, a transport
/// failure, or an invalid response).
Ambiguous,
/// The batch failed with a terminal control-plane error. The string is
/// already safe to surface as a stream error and is never reconciled.
Terminal(String),
}

/// Terminal failure of [`sync_stream`].
Expand All @@ -80,6 +85,9 @@ pub enum StreamSyncError {
/// that were sent (`accepted != rows.len()` or
/// `cursor != rows.last().source_row_id()`).
InvalidResponse(String),
/// The batch failed with a terminal control-plane error. Unlike
/// [`Self::Refresh`], this does not represent a failed `/state` call.
Terminal(String),
/// The reconciliation loop exceeded [`RECONCILIATION_MAX_ATTEMPTS`]
/// without converging.
DidNotConverge,
Expand All @@ -93,6 +101,7 @@ impl fmt::Display for StreamSyncError {
Self::InvalidResponse(reason) => {
write!(f, "control-plane batch response did not match the sent rows: {reason}")
}
Self::Terminal(reason) => write!(f, "terminal control-plane failure: {reason}"),
Self::DidNotConverge => write!(
f,
"stream did not converge after {RECONCILIATION_MAX_ATTEMPTS} reconciliation attempts"
Expand Down Expand Up @@ -122,7 +131,8 @@ pub struct StreamSyncOutcome {
/// On `Conflict` or `Ambiguous`, calls `refresh_cursor` and resumes from the
/// refreshed value: if it advanced, the next read naturally skips the
/// already-accepted rows; if unchanged, the same rows are re-read and
/// resent. Both cases share one bounded reconciliation counter.
/// resent. Both cases share one bounded reconciliation counter. A `Terminal`
/// outcome stops immediately without calling `refresh_cursor`.
pub type SyncFuture<'a, Output> = Pin<Box<dyn Future<Output = Output> + 'a>>;

pub async fn sync_stream<'a, T, ReadFn, IngestFn, RefreshFn>(
Expand Down Expand Up @@ -171,6 +181,9 @@ where
batches += 1;
reconciliation_attempts = 0;
}
BatchAttemptOutcome::Terminal(reason) => {
return Err(StreamSyncError::Terminal(reason));
}
BatchAttemptOutcome::Conflict | BatchAttemptOutcome::Ambiguous => {
reconciliation_attempts += 1;
if reconciliation_attempts > RECONCILIATION_MAX_ATTEMPTS {
Expand Down Expand Up @@ -414,6 +427,32 @@ mod tests {
assert_eq!(outcome.final_cursor, 3);
}

#[test]
fn terminal_failure_does_not_call_refresh() {
let local = FakeLocalRows::new(3);
let refresh_calls = RefCell::new(0usize);
let result = block_on(sync_stream(
0,
500,
|cursor, limit| ready(Ok(local.after(cursor, limit))),
|_cursor, _rows: &[AgentTraceMessageExportRow]| {
ready(BatchAttemptOutcome::Terminal(
"batch route is not supported".to_string(),
))
},
|| {
*refresh_calls.borrow_mut() += 1;
ready(Ok(0))
},
));

assert!(matches!(
result,
Err(StreamSyncError::Terminal(reason)) if reason == "batch route is not supported"
));
assert_eq!(*refresh_calls.borrow(), 0);
}

#[test]
fn ambiguous_failure_with_unchanged_refresh_resends_once() {
let local = FakeLocalRows::new(3);
Expand Down
Loading
Loading