Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
63
crates/codegen/xai-file-utils/Cargo.toml
Normal file
63
crates/codegen/xai-file-utils/Cargo.toml
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
[package]
|
||||
license = "Apache-2.0"
|
||||
name = "xai-file-utils"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
description = "Local data collection: per-turn event tracking"
|
||||
authors = ["xAI"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
base64.workspace = true
|
||||
dunce = { workspace = true }
|
||||
xai-circuit-breaker = { workspace = true }
|
||||
xai-grok-version = { workspace = true }
|
||||
aws-sdk-s3 = { version = "1", default-features = false, features = [
|
||||
"rt-tokio",
|
||||
] }
|
||||
aws-config = { version = "1", default-features = false, features = [
|
||||
"rt-tokio",
|
||||
"sso",
|
||||
"credentials-process",
|
||||
] }
|
||||
aws-smithy-http-client = { version = "1", features = ["rustls-ring"] }
|
||||
futures = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
strum = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
indexmap = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
reqwest-middleware = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "time", "io-util", "fs"] }
|
||||
sha2 = { workspace = true, features = ["force-soft"] }
|
||||
xai-grok-auth = { path = "../xai-grok-auth", features = ["middleware"] }
|
||||
prod-mc-cli-chat-proxy-types = { path = "../../../prod/mc/cli-chat-proxy-types" }
|
||||
async-compression = { workspace = true, features = ["tokio"] }
|
||||
gcloud-storage = { workspace = true }
|
||||
opentelemetry = { workspace = true }
|
||||
reflink-copy = { workspace = true }
|
||||
tracing-opentelemetry = { workspace = true }
|
||||
url = { workspace = true }
|
||||
tokio-stream = { workspace = true }
|
||||
tokio-util = { workspace = true, features = ["io"] }
|
||||
zstd.workspace = true
|
||||
|
||||
|
||||
[features]
|
||||
default-bazel = []
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
axum = { workspace = true, features = ["multipart"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "net", "time"] }
|
||||
xai-test-utils = { workspace = true }
|
||||
filetime = { workspace = true }
|
||||
opentelemetry_sdk = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
//! Tracing-based observer for the storage circuit breaker.
|
||||
|
||||
use std::sync::Arc;
|
||||
use xai_circuit_breaker::{BreakerState, Observer, Outcome};
|
||||
|
||||
/// `Observer` impl that emits `tracing` events matching the legacy
|
||||
/// in-tree `circuit_breaker.rs` so existing analytics queries
|
||||
/// (`target=circuit_breaker AND breaker=storage_breaker`) keep firing.
|
||||
///
|
||||
/// Event routing keys on the **new** state — keying on `(old, new)`
|
||||
/// tuples invites arm-ordering bugs (an early `(Open, _)` arm would
|
||||
/// catch `Open -> HalfOpen` and mis-label it "closed").
|
||||
///
|
||||
/// | new state | level | message |
|
||||
/// |-----------|-------|--------------------------|
|
||||
/// | `Open` | warn | "circuit breaker opened" |
|
||||
/// | `HalfOpen`| debug | "circuit breaker half-open" |
|
||||
/// | `Closed` | info | "circuit breaker closed" |
|
||||
///
|
||||
/// `on_outcome` emits a `tracing::trace!` per `Outcome::Failure` so
|
||||
/// downstream failure-rate dashboards have a per-401 signal.
|
||||
/// Successes are dropped (steady state would otherwise dominate log volume).
|
||||
pub(crate) struct TracingObserver {
|
||||
name: &'static str,
|
||||
}
|
||||
|
||||
impl TracingObserver {
|
||||
pub(crate) fn new(name: &'static str) -> Arc<Self> {
|
||||
Arc::new(Self { name })
|
||||
}
|
||||
}
|
||||
|
||||
impl Observer for TracingObserver {
|
||||
fn on_state_change(&self, old: BreakerState, new: BreakerState, reason: &str) {
|
||||
match new {
|
||||
BreakerState::Open => tracing::warn!(
|
||||
target: "circuit_breaker",
|
||||
breaker = self.name,
|
||||
?old,
|
||||
?new,
|
||||
reason,
|
||||
"circuit breaker opened"
|
||||
),
|
||||
BreakerState::HalfOpen => tracing::debug!(
|
||||
target: "circuit_breaker",
|
||||
breaker = self.name,
|
||||
?old,
|
||||
?new,
|
||||
reason,
|
||||
"circuit breaker half-open"
|
||||
),
|
||||
BreakerState::Closed => tracing::info!(
|
||||
target: "circuit_breaker",
|
||||
breaker = self.name,
|
||||
?old,
|
||||
?new,
|
||||
reason,
|
||||
"circuit breaker closed"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_probe_admission(&self, allowed: bool) {
|
||||
tracing::debug!(
|
||||
target: "circuit_breaker",
|
||||
breaker = self.name,
|
||||
allowed,
|
||||
"circuit breaker probe admission"
|
||||
);
|
||||
}
|
||||
|
||||
fn on_outcome(&self, outcome: Outcome, state: BreakerState) {
|
||||
if let Outcome::Failure = outcome {
|
||||
tracing::trace!(
|
||||
target: "circuit_breaker",
|
||||
breaker = self.name,
|
||||
?state,
|
||||
"circuit breaker outcome failure"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "circuit_breaker_observer_tests.rs"]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
//! Tests for [`crate::circuit_breaker_observer::TracingObserver`].
|
||||
//!
|
||||
//! Snapshot the exact `tracing::Event` field set for each transition
|
||||
//! so a future rename or field-set change can't break analytics queries
|
||||
//! silently.
|
||||
|
||||
use super::*;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::sync::Mutex;
|
||||
use tracing::Level;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing_subscriber::layer::{Context, SubscriberExt};
|
||||
use tracing_subscriber::{Layer, Registry};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CapturedEvent {
|
||||
level: Level,
|
||||
target: String,
|
||||
message: String,
|
||||
breaker: Option<String>,
|
||||
/// All non-message field names present on the event. Pins the
|
||||
/// structured field set that analytics queries consume.
|
||||
field_names: BTreeSet<String>,
|
||||
/// All non-message field values rendered as strings. Lets a test
|
||||
/// assert e.g. `reason=="trip"` without re-emitting the event.
|
||||
field_values: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CapturingLayer {
|
||||
events: Arc<Mutex<Vec<CapturedEvent>>>,
|
||||
}
|
||||
|
||||
struct CapturingVisitor {
|
||||
message: Option<String>,
|
||||
breaker: Option<String>,
|
||||
field_names: BTreeSet<String>,
|
||||
field_values: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl CapturingVisitor {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
message: None,
|
||||
breaker: None,
|
||||
field_names: BTreeSet::new(),
|
||||
field_values: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Visit for CapturingVisitor {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
let name = field.name();
|
||||
let rendered = format!("{value:?}");
|
||||
let stripped = rendered.trim_matches('"').to_string();
|
||||
if name == "message" {
|
||||
self.message = Some(stripped);
|
||||
return;
|
||||
}
|
||||
self.field_names.insert(name.to_string());
|
||||
self.field_values.insert(name.to_string(), stripped.clone());
|
||||
if name == "breaker" {
|
||||
self.breaker = Some(stripped);
|
||||
}
|
||||
}
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
let name = field.name();
|
||||
if name == "message" {
|
||||
self.message = Some(value.to_string());
|
||||
return;
|
||||
}
|
||||
self.field_names.insert(name.to_string());
|
||||
self.field_values
|
||||
.insert(name.to_string(), value.to_string());
|
||||
if name == "breaker" {
|
||||
self.breaker = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
fn record_bool(&mut self, field: &Field, value: bool) {
|
||||
let name = field.name();
|
||||
self.field_names.insert(name.to_string());
|
||||
self.field_values
|
||||
.insert(name.to_string(), value.to_string());
|
||||
}
|
||||
fn record_i64(&mut self, field: &Field, value: i64) {
|
||||
let name = field.name();
|
||||
self.field_names.insert(name.to_string());
|
||||
self.field_values
|
||||
.insert(name.to_string(), value.to_string());
|
||||
}
|
||||
fn record_u64(&mut self, field: &Field, value: u64) {
|
||||
let name = field.name();
|
||||
self.field_names.insert(name.to_string());
|
||||
self.field_values
|
||||
.insert(name.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: tracing::Subscriber> Layer<S> for CapturingLayer {
|
||||
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
|
||||
let mut visitor = CapturingVisitor::new();
|
||||
event.record(&mut visitor);
|
||||
self.events
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(CapturedEvent {
|
||||
level: *event.metadata().level(),
|
||||
target: event.metadata().target().to_string(),
|
||||
message: visitor.message.unwrap_or_default(),
|
||||
breaker: visitor.breaker,
|
||||
field_names: visitor.field_names,
|
||||
field_values: visitor.field_values,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn run_with_capture<F: FnOnce()>(f: F) -> Vec<CapturedEvent> {
|
||||
let events: Arc<Mutex<Vec<CapturedEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let layer = CapturingLayer {
|
||||
events: events.clone(),
|
||||
};
|
||||
let subscriber = Registry::default().with(layer.with_filter(
|
||||
tracing_subscriber::filter::Targets::new().with_target(
|
||||
"circuit_breaker",
|
||||
tracing::level_filters::LevelFilter::TRACE,
|
||||
),
|
||||
));
|
||||
tracing::subscriber::with_default(subscriber, f);
|
||||
let guard = events.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_to_open_emits_warn_opened() {
|
||||
let events = run_with_capture(|| {
|
||||
let obs = TracingObserver::new("storage_breaker");
|
||||
obs.on_state_change(BreakerState::Closed, BreakerState::Open, "trip");
|
||||
});
|
||||
let openings: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.message == "circuit breaker opened")
|
||||
.collect();
|
||||
assert_eq!(openings.len(), 1);
|
||||
assert_eq!(openings[0].level, Level::WARN);
|
||||
assert_eq!(openings[0].target, "circuit_breaker");
|
||||
assert_eq!(openings[0].breaker.as_deref(), Some("storage_breaker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_to_half_open_emits_debug_half_open_not_info_closed() {
|
||||
let events = run_with_capture(|| {
|
||||
let obs = TracingObserver::new("storage_breaker");
|
||||
obs.on_state_change(BreakerState::Open, BreakerState::HalfOpen, "open_elapsed");
|
||||
});
|
||||
let half_open: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.message == "circuit breaker half-open")
|
||||
.collect();
|
||||
let closed: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.message == "circuit breaker closed")
|
||||
.collect();
|
||||
assert_eq!(
|
||||
half_open.len(),
|
||||
1,
|
||||
"Open -> HalfOpen must emit debug 'half-open'"
|
||||
);
|
||||
assert_eq!(half_open[0].level, Level::DEBUG);
|
||||
assert!(closed.is_empty(), "Open -> HalfOpen must NOT emit 'closed'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_open_to_closed_emits_info_closed() {
|
||||
let events = run_with_capture(|| {
|
||||
let obs = TracingObserver::new("storage_breaker");
|
||||
obs.on_state_change(
|
||||
BreakerState::HalfOpen,
|
||||
BreakerState::Closed,
|
||||
"probe_success",
|
||||
);
|
||||
});
|
||||
let closed: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.message == "circuit breaker closed")
|
||||
.collect();
|
||||
assert_eq!(
|
||||
closed.len(),
|
||||
1,
|
||||
"HalfOpen -> Closed must emit info 'closed'"
|
||||
);
|
||||
assert_eq!(closed[0].level, Level::INFO);
|
||||
assert_eq!(closed[0].target, "circuit_breaker");
|
||||
assert_eq!(closed[0].breaker.as_deref(), Some("storage_breaker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_open_to_open_on_probe_failure_emits_warn_opened() {
|
||||
let events = run_with_capture(|| {
|
||||
let obs = TracingObserver::new("storage_breaker");
|
||||
obs.on_state_change(BreakerState::HalfOpen, BreakerState::Open, "probe_failure");
|
||||
});
|
||||
let openings: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.message == "circuit breaker opened")
|
||||
.collect();
|
||||
assert_eq!(openings.len(), 1);
|
||||
assert_eq!(openings[0].level, Level::WARN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_outcome_failure_emits_trace_event() {
|
||||
let events = run_with_capture(|| {
|
||||
let obs = TracingObserver::new("storage_breaker");
|
||||
obs.on_outcome(Outcome::Failure, BreakerState::Closed);
|
||||
});
|
||||
let failures: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| e.message == "circuit breaker outcome failure")
|
||||
.collect();
|
||||
assert_eq!(failures.len(), 1);
|
||||
assert_eq!(failures[0].level, Level::TRACE);
|
||||
assert_eq!(failures[0].breaker.as_deref(), Some("storage_breaker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_outcome_success_is_silent() {
|
||||
let events = run_with_capture(|| {
|
||||
let obs = TracingObserver::new("storage_breaker");
|
||||
obs.on_outcome(Outcome::Success, BreakerState::Closed);
|
||||
});
|
||||
assert!(events.is_empty(), "successes must not emit events");
|
||||
}
|
||||
|
||||
/// Snapshot the full structured field set on a Closed->Open transition.
|
||||
/// Analytics queries depend on `target=circuit_breaker` + the named
|
||||
/// fields `{breaker, old, new, reason}`. A rename of `?old` -> `?prev`
|
||||
/// (or a dropped `reason` field) at the emit site must fail this test
|
||||
/// before silently breaking analytics dashboards.
|
||||
#[test]
|
||||
fn opened_event_field_set_is_stable() {
|
||||
let events = run_with_capture(|| {
|
||||
let obs = TracingObserver::new("storage_breaker");
|
||||
obs.on_state_change(BreakerState::Closed, BreakerState::Open, "trip");
|
||||
});
|
||||
let evt = events
|
||||
.iter()
|
||||
.find(|e| e.message == "circuit breaker opened")
|
||||
.expect("must emit one 'opened' event");
|
||||
assert_eq!(evt.level, Level::WARN);
|
||||
assert_eq!(evt.target, "circuit_breaker");
|
||||
assert_eq!(evt.breaker.as_deref(), Some("storage_breaker"));
|
||||
|
||||
// Pin the exact field-name set analytics queries consume.
|
||||
let expected: BTreeSet<String> = ["breaker", "old", "new", "reason"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
evt.field_names, expected,
|
||||
"opened event field-name set drifted: {:?}",
|
||||
evt.field_names
|
||||
);
|
||||
|
||||
// Pin the concrete reason string so a typo in `"trip"` fails here
|
||||
// rather than in analytics.
|
||||
assert_eq!(
|
||||
evt.field_values.get("reason").map(String::as_str),
|
||||
Some("trip")
|
||||
);
|
||||
// Old/new render as `Debug` of `BreakerState`.
|
||||
assert_eq!(
|
||||
evt.field_values.get("old").map(String::as_str),
|
||||
Some("Closed")
|
||||
);
|
||||
assert_eq!(
|
||||
evt.field_values.get("new").map(String::as_str),
|
||||
Some("Open")
|
||||
);
|
||||
}
|
||||
179
crates/codegen/xai-file-utils/src/events/log.rs
Normal file
179
crates/codegen/xai-file-utils/src/events/log.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::types::Event;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct EventEntry {
|
||||
ts: String,
|
||||
#[serde(flatten)]
|
||||
event: Event,
|
||||
}
|
||||
|
||||
const EVENTS_FILE: &str = "events.jsonl";
|
||||
|
||||
/// Shared event writer for `events.jsonl`. `Clone + Send + Sync`.
|
||||
#[derive(Clone)]
|
||||
pub struct EventWriter {
|
||||
inner: Arc<EventWriterInner>,
|
||||
}
|
||||
|
||||
struct EventWriterInner {
|
||||
file: Mutex<Option<File>>,
|
||||
error_logged: AtomicBool,
|
||||
}
|
||||
|
||||
impl EventWriter {
|
||||
pub fn open(session_dir: &Path) -> Self {
|
||||
let path = session_dir.join(EVENTS_FILE);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.map_err(|e| {
|
||||
tracing::warn!(path = %path.display(), error = %e, "failed to open {EVENTS_FILE}");
|
||||
e
|
||||
})
|
||||
.ok();
|
||||
Self {
|
||||
inner: Arc::new(EventWriterInner {
|
||||
file: Mutex::new(file),
|
||||
error_logged: AtomicBool::new(false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op writer that discards all events.
|
||||
pub fn noop() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(EventWriterInner {
|
||||
file: Mutex::new(None),
|
||||
error_logged: AtomicBool::new(true), // suppress error logging
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit(&self, event: Event) {
|
||||
let entry = EventEntry {
|
||||
ts: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
|
||||
event,
|
||||
};
|
||||
let Ok(mut line) = serde_json::to_vec(&entry) else {
|
||||
return;
|
||||
};
|
||||
line.push(b'\n');
|
||||
|
||||
let Ok(mut guard) = self.inner.file.lock() else {
|
||||
return;
|
||||
};
|
||||
if let Some(ref mut f) = *guard
|
||||
&& let Err(e) = f.write_all(&line)
|
||||
&& !self.inner.error_logged.swap(true, Ordering::Relaxed)
|
||||
{
|
||||
tracing::warn!(error = %e, "{EVENTS_FILE} write failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for EventWriter {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("EventWriter").finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::events::types::{
|
||||
EVENT_SCHEMA_VERSION, Event, SessionRelationship, TurnOutcomeLabel,
|
||||
};
|
||||
|
||||
fn _assert_event_writer_is_send_sync_clone()
|
||||
where
|
||||
EventWriter: Send + Sync + Clone,
|
||||
{
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_emit_writes_jsonl() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let writer = EventWriter::open(dir.path());
|
||||
|
||||
writer.emit(Event::TurnStarted {
|
||||
session_id: "test-session".into(),
|
||||
turn_number: 1,
|
||||
model_id: "grok-3".into(),
|
||||
yolo_mode: false,
|
||||
conversation_message_count: 0,
|
||||
session_relationship: SessionRelationship::Primary,
|
||||
schema_version: EVENT_SCHEMA_VERSION.into(),
|
||||
redirect_kind: None,
|
||||
});
|
||||
writer.emit(Event::FirstToken);
|
||||
writer.emit(Event::TurnEnded {
|
||||
outcome: TurnOutcomeLabel::Completed,
|
||||
cancellation_category: None,
|
||||
cancellation_context: None,
|
||||
});
|
||||
|
||||
let text = std::fs::read_to_string(dir.path().join("events.jsonl")).unwrap();
|
||||
let lines: Vec<&str> = text.trim().split('\n').collect();
|
||||
assert_eq!(lines.len(), 3);
|
||||
|
||||
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
|
||||
assert_eq!(first["type"], "turn_started");
|
||||
assert_eq!(first["session_id"], "test-session");
|
||||
assert!(first["ts"].as_str().is_some());
|
||||
|
||||
let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
|
||||
assert_eq!(second["type"], "first_token");
|
||||
|
||||
let third: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
|
||||
assert_eq!(third["type"], "turn_ended");
|
||||
assert_eq!(third["outcome"], "completed");
|
||||
assert!(third.get("cancellation_category").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloned_writer_shares_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let w1 = EventWriter::open(dir.path());
|
||||
let w2 = w1.clone();
|
||||
|
||||
w1.emit(Event::FirstToken);
|
||||
w2.emit(Event::FirstToken);
|
||||
|
||||
let text = std::fs::read_to_string(dir.path().join("events.jsonl")).unwrap();
|
||||
let lines: Vec<&str> = text.trim().split('\n').collect();
|
||||
assert_eq!(lines.len(), 2, "both writes should go to the same file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_server_failed_serializes_enum_error_type() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let w = EventWriter::open(dir.path());
|
||||
|
||||
w.emit(Event::McpServerFailed {
|
||||
server_name: "confluence".into(),
|
||||
transport: Some("http".into()),
|
||||
target: Some("https://mcp.confluence.example.com".into()),
|
||||
error_type: crate::events::types::McpErrorCategory::Timeout,
|
||||
error_message: "timed out after 10s".into(),
|
||||
duration_ms: Some(10002),
|
||||
timeout_sec: Some(10),
|
||||
});
|
||||
|
||||
let text = std::fs::read_to_string(dir.path().join("events.jsonl")).unwrap();
|
||||
let val: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
|
||||
assert_eq!(val["type"], "mcp_server_failed");
|
||||
assert_eq!(val["error_type"], "timeout");
|
||||
assert_eq!(val["server_name"], "confluence");
|
||||
assert_eq!(val["duration_ms"], 10002);
|
||||
}
|
||||
}
|
||||
12
crates/codegen/xai-file-utils/src/events/mod.rs
Normal file
12
crates/codegen/xai-file-utils/src/events/mod.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
//! Per-session event log (`events.jsonl`).
|
||||
|
||||
pub mod log;
|
||||
pub mod tracker;
|
||||
pub mod types;
|
||||
|
||||
pub use log::EventWriter;
|
||||
pub use tracker::EventTracker;
|
||||
pub use types::{
|
||||
CancellationCategory, EVENT_SCHEMA_VERSION, Event, McpConfigServer, McpErrorCategory,
|
||||
PermissionDecision, Phase, SessionRelationship, ToolOutcome, TurnOutcomeLabel,
|
||||
};
|
||||
261
crates/codegen/xai-file-utils/src/events/tracker.rs
Normal file
261
crates/codegen/xai-file-utils/src/events/tracker.rs
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
use std::cell::{Cell, RefCell};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
use super::log::EventWriter;
|
||||
use super::types::{CancellationCategory, Event, RedirectKind, TurnOutcomeLabel};
|
||||
|
||||
/// Per-session event state. `!Send` — lives on the session actor.
|
||||
/// Background tasks use `tracker.writer()` to get a `Clone + Send + Sync` handle.
|
||||
pub struct EventTracker {
|
||||
writer: EventWriter,
|
||||
turn_ended_emitted: Cell<bool>,
|
||||
active_tool: RefCell<Option<(String, Instant)>>,
|
||||
turn_tool_count: Cell<u32>,
|
||||
/// Cross-turn one-shot: the *fatal* user-interrupt cause that cancelled the
|
||||
/// most recent turn (set by the cancel paths), consumed by the *next* real
|
||||
/// user prompt to tag `UserItem::prior_turn_interrupt`. Deliberately NOT
|
||||
/// reset by `begin_turn` — it must survive into the next turn; the consumer
|
||||
/// clears it via `take_prior_interrupt_category`. Interjections are NOT
|
||||
/// recorded here (they don't cancel the turn; see `Event::Interjected`).
|
||||
prior_interrupt_category: Cell<Option<CancellationCategory>>,
|
||||
/// Cross-turn one-shot: the redirect mechanism for the NEXT turn after a
|
||||
/// mid-turn abort — `CancelThenSend` (nothing was queued) or
|
||||
/// `QueuedAfterCancel` (a prompt sat queued behind the aborted turn). Set
|
||||
/// by `cancel_running_task`, consumed by the next user `turn_started` to
|
||||
/// stamp `Event::TurnStarted::redirect_kind`. Like `prior_interrupt_category`
|
||||
/// it deliberately survives `begin_turn` so it reaches the next real turn.
|
||||
prior_redirect_kind: Cell<Option<RedirectKind>>,
|
||||
/// Cross-turn one-shot: armed by the cancel path only when a turn was aborted
|
||||
/// mid-stream with NO tool in flight, so neither the dangling-tool-call
|
||||
/// repair nor a permission tool-result will tell the model it was
|
||||
/// interrupted. Consumed by the next *real* user prompt to inject an
|
||||
/// interrupt `<system-reminder>`. Like the markers above it deliberately
|
||||
/// survives `begin_turn` so it reaches the next real turn.
|
||||
pending_interrupt_reminder: Cell<bool>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for EventTracker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let active_tool = self.active_tool.borrow();
|
||||
f.debug_struct("EventTracker")
|
||||
.field("writer", &self.writer)
|
||||
.field("turn_ended_emitted", &self.turn_ended_emitted.get())
|
||||
.field("turn_tool_count", &self.turn_tool_count.get())
|
||||
.field("active_tool", &active_tool.as_ref().map(|(name, _)| name))
|
||||
.field(
|
||||
"prior_interrupt_category",
|
||||
&self.prior_interrupt_category.get(),
|
||||
)
|
||||
.field("prior_redirect_kind", &self.prior_redirect_kind.get())
|
||||
.field(
|
||||
"pending_interrupt_reminder",
|
||||
&self.pending_interrupt_reminder.get(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventTracker {
|
||||
pub fn new(session_dir: &Path) -> Self {
|
||||
Self {
|
||||
writer: EventWriter::open(session_dir),
|
||||
turn_ended_emitted: Cell::new(false),
|
||||
active_tool: RefCell::new(None),
|
||||
turn_tool_count: Cell::new(0),
|
||||
prior_interrupt_category: Cell::new(None),
|
||||
prior_redirect_kind: Cell::new(None),
|
||||
pending_interrupt_reminder: Cell::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone the writer for background tasks.
|
||||
pub fn writer(&self) -> EventWriter {
|
||||
self.writer.clone()
|
||||
}
|
||||
|
||||
pub fn emit(&self, event: Event) {
|
||||
self.writer.emit(event);
|
||||
}
|
||||
|
||||
/// Reset per-turn state. Called at the start of each turn.
|
||||
pub fn begin_turn(&self) {
|
||||
self.turn_ended_emitted.set(false);
|
||||
self.turn_tool_count.set(0);
|
||||
}
|
||||
|
||||
/// Emit `turn_ended` with a double-emission guard.
|
||||
pub fn emit_turn_ended(
|
||||
&self,
|
||||
outcome: TurnOutcomeLabel,
|
||||
category: Option<CancellationCategory>,
|
||||
context: Option<serde_json::Value>,
|
||||
) {
|
||||
if self.turn_ended_emitted.replace(true) {
|
||||
return;
|
||||
}
|
||||
self.emit(Event::TurnEnded {
|
||||
outcome,
|
||||
cancellation_category: category,
|
||||
cancellation_context: context,
|
||||
});
|
||||
}
|
||||
|
||||
/// Set the active tool for cancellation tracking and return the start instant.
|
||||
pub fn tool_started(&self, tool_name: String) -> Instant {
|
||||
let now = Instant::now();
|
||||
*self.active_tool.borrow_mut() = Some((tool_name, now));
|
||||
self.turn_tool_count.set(self.turn_tool_count.get() + 1);
|
||||
now
|
||||
}
|
||||
|
||||
pub fn tool_count_this_turn(&self) -> u32 {
|
||||
self.turn_tool_count.get()
|
||||
}
|
||||
|
||||
pub fn has_active_tool(&self) -> bool {
|
||||
self.active_tool.borrow().is_some()
|
||||
}
|
||||
|
||||
pub fn tool_finished(&self) {
|
||||
*self.active_tool.borrow_mut() = None;
|
||||
}
|
||||
|
||||
/// Cancel in-flight tool and emit `ToolCompleted(cancelled)`.
|
||||
/// Called from `cancel_running_task()` before `turn_ended`.
|
||||
pub fn cancel_active_tool(&self) {
|
||||
if let Some((tool_name, start)) = self.active_tool.borrow_mut().take() {
|
||||
self.emit(Event::ToolCompleted {
|
||||
tool_name,
|
||||
duration_ms: start.elapsed().as_millis() as u64,
|
||||
outcome: super::types::ToolOutcome::Cancelled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the *fatal* user-interrupt cause that cancelled this turn so the
|
||||
/// *next* real user prompt can be tagged. Overwrites any prior value (latest
|
||||
/// cause wins).
|
||||
pub fn set_prior_interrupt_category(&self, category: CancellationCategory) {
|
||||
self.prior_interrupt_category.set(Some(category));
|
||||
}
|
||||
|
||||
/// Take (and clear) the recorded prior-turn interrupt cause.
|
||||
pub fn take_prior_interrupt_category(&self) -> Option<CancellationCategory> {
|
||||
self.prior_interrupt_category.take()
|
||||
}
|
||||
|
||||
/// Record the redirect mechanism (`CancelThenSend` / `QueuedAfterCancel`)
|
||||
/// for the next turn after a mid-turn abort. Overwrites any prior value
|
||||
/// (latest abort wins).
|
||||
pub fn set_prior_redirect_kind(&self, kind: RedirectKind) {
|
||||
self.prior_redirect_kind.set(Some(kind));
|
||||
}
|
||||
|
||||
/// Take (and clear) the recorded prior-turn redirect kind.
|
||||
pub fn take_prior_redirect_kind(&self) -> Option<RedirectKind> {
|
||||
self.prior_redirect_kind.take()
|
||||
}
|
||||
|
||||
/// Arm the one-shot interrupt reminder for the next real user prompt. Set
|
||||
/// only on the cancel path when no tool was in flight (the case where the
|
||||
/// model would otherwise get no signal that it was interrupted).
|
||||
pub fn set_pending_interrupt_reminder(&self) {
|
||||
self.pending_interrupt_reminder.set(true);
|
||||
}
|
||||
|
||||
/// Take (and clear) the pending interrupt-reminder flag.
|
||||
pub fn take_pending_interrupt_reminder(&self) -> bool {
|
||||
self.pending_interrupt_reminder.replace(false)
|
||||
}
|
||||
|
||||
/// Emit PhaseChanged(PermissionPrompt) → PermissionRequested.
|
||||
/// Returns the Instant for `permission_resolved()` to compute wait_ms.
|
||||
pub fn permission_requested(&self, tool_name: &str) -> Instant {
|
||||
self.emit(Event::PhaseChanged {
|
||||
phase: super::types::Phase::PermissionPrompt,
|
||||
});
|
||||
self.emit(Event::PermissionRequested {
|
||||
tool_name: tool_name.to_string(),
|
||||
});
|
||||
Instant::now()
|
||||
}
|
||||
|
||||
pub fn permission_resolved(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
decision: super::types::PermissionDecision,
|
||||
start: Instant,
|
||||
) {
|
||||
self.emit(Event::PermissionResolved {
|
||||
tool_name: tool_name.to_string(),
|
||||
decision,
|
||||
wait_ms: start.elapsed().as_millis() as u64,
|
||||
});
|
||||
self.emit(Event::PhaseChanged {
|
||||
phase: super::types::Phase::ToolExecution,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prior_interrupt_markers_are_one_shot_and_survive_begin_turn() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let t = EventTracker::new(dir.path());
|
||||
|
||||
// Defaults: nothing recorded.
|
||||
assert_eq!(t.take_prior_interrupt_category(), None);
|
||||
assert!(t.take_prior_redirect_kind().is_none());
|
||||
assert!(!t.take_pending_interrupt_reminder());
|
||||
|
||||
// Cancel cause is consumed exactly once.
|
||||
t.set_prior_interrupt_category(CancellationCategory::MidTurnAbort);
|
||||
assert_eq!(
|
||||
t.take_prior_interrupt_category(),
|
||||
Some(CancellationCategory::MidTurnAbort)
|
||||
);
|
||||
assert_eq!(t.take_prior_interrupt_category(), None);
|
||||
|
||||
// Interrupt-reminder flag is consumed exactly once.
|
||||
t.set_pending_interrupt_reminder();
|
||||
assert!(t.take_pending_interrupt_reminder());
|
||||
assert!(!t.take_pending_interrupt_reminder());
|
||||
|
||||
// Redirect kind is consumed exactly once.
|
||||
t.set_prior_redirect_kind(RedirectKind::QueuedAfterCancel);
|
||||
assert!(matches!(
|
||||
t.take_prior_redirect_kind(),
|
||||
Some(RedirectKind::QueuedAfterCancel)
|
||||
));
|
||||
assert!(t.take_prior_redirect_kind().is_none());
|
||||
|
||||
// `begin_turn` runs at the START of a turn — BEFORE the next real user
|
||||
// prompt consumes the markers — so it must NOT clear these cross-turn
|
||||
// markers (it only resets per-turn counters). A regression here would
|
||||
// silently drop the `prior_turn_interrupt` tag / `redirect_kind`.
|
||||
t.set_prior_interrupt_category(CancellationCategory::PermissionRejected);
|
||||
t.set_prior_redirect_kind(RedirectKind::CancelThenSend);
|
||||
t.set_pending_interrupt_reminder();
|
||||
t.begin_turn();
|
||||
assert_eq!(
|
||||
t.take_prior_interrupt_category(),
|
||||
Some(CancellationCategory::PermissionRejected),
|
||||
"begin_turn must preserve the cross-turn interrupt cause"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
t.take_prior_redirect_kind(),
|
||||
Some(RedirectKind::CancelThenSend)
|
||||
),
|
||||
"begin_turn must preserve the cross-turn redirect kind"
|
||||
);
|
||||
assert!(
|
||||
t.take_pending_interrupt_reminder(),
|
||||
"begin_turn must preserve the pending interrupt reminder"
|
||||
);
|
||||
}
|
||||
}
|
||||
855
crates/codegen/xai-file-utils/src/events/types.rs
Normal file
855
crates/codegen/xai-file-utils/src/events/types.rs
Normal file
|
|
@ -0,0 +1,855 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Schema version for the event log format. Bumped on breaking changes.
|
||||
pub const EVENT_SCHEMA_VERSION: &str = "1.0";
|
||||
|
||||
/// A single event in the per-turn event log.
|
||||
///
|
||||
/// Each variant maps to a line in `events.jsonl`. The `type` field is the
|
||||
/// snake_case variant name (via `#[serde(tag = "type")]`). The `ts` field
|
||||
/// is added by [`super::log::EventWriter::emit`] at recording time.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Event {
|
||||
TurnStarted {
|
||||
session_id: String,
|
||||
turn_number: u64,
|
||||
model_id: String,
|
||||
yolo_mode: bool,
|
||||
conversation_message_count: usize,
|
||||
session_relationship: SessionRelationship,
|
||||
schema_version: String,
|
||||
/// Set when this turn is the user's redirect after a Ctrl+C / Esc abort
|
||||
/// of the previous turn: `cancel_then_send` (the user typed a fresh
|
||||
/// prompt) or `queued_after_cancel` (a prompt sat queued behind the
|
||||
/// aborted turn and was promoted). `None` for normal turns. Pairs with
|
||||
/// the `interjected` event's `redirect_kind` so the trace pipeline can
|
||||
/// query every user redirect through one shared field.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
redirect_kind: Option<RedirectKind>,
|
||||
},
|
||||
PhaseChanged {
|
||||
phase: Phase,
|
||||
},
|
||||
FirstToken,
|
||||
LoopStarted {
|
||||
loop_index: u32,
|
||||
},
|
||||
ToolStarted {
|
||||
tool_name: String,
|
||||
},
|
||||
ToolCompleted {
|
||||
tool_name: String,
|
||||
duration_ms: u64,
|
||||
outcome: ToolOutcome,
|
||||
},
|
||||
PermissionRequested {
|
||||
tool_name: String,
|
||||
},
|
||||
PermissionResolved {
|
||||
tool_name: String,
|
||||
decision: PermissionDecision,
|
||||
wait_ms: u64,
|
||||
},
|
||||
TurnEnded {
|
||||
outcome: TurnOutcomeLabel,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cancellation_category: Option<CancellationCategory>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cancellation_context: Option<serde_json::Value>,
|
||||
},
|
||||
/// A mid-turn user interjection was merged into the running turn. Unlike
|
||||
/// `TurnEnded`, an interjection never ends the turn — the user steered
|
||||
/// in-flight (Ctrl+Enter) or promoted a queued prompt into the running
|
||||
/// turn. `source` distinguishes those two paths; `image_count` is how
|
||||
/// many images rode along (0 for text-only). Emitted at enqueue time,
|
||||
/// once per interjection.
|
||||
Interjected {
|
||||
source: InterjectionSource,
|
||||
image_count: u32,
|
||||
/// Always [`RedirectKind::Interjection`]. Carried so the shared
|
||||
/// `redirect_kind` field is queryable uniformly across every redirect
|
||||
/// event (`interjected` + the next-turn-after-abort `turn_started`).
|
||||
redirect_kind: RedirectKind,
|
||||
},
|
||||
YoloToggled {
|
||||
enabled: bool,
|
||||
},
|
||||
/// Emitted when goal mode auto-pauses an active goal. The `reason`
|
||||
/// records which automatic trigger fired: user cancel, infra-classified
|
||||
/// turn error, consecutive-failed-turn back-off, or verification block.
|
||||
GoalAutoPaused {
|
||||
reason: GoalPauseReasonTelemetry,
|
||||
},
|
||||
/// Runtime TodoGate nudged the model because a content-only turn ended
|
||||
/// with pending or unbacked in_progress todos. `reason` is the
|
||||
/// `TODO_GATE_*` discriminator constant in `xai-grok-shell::session::events`.
|
||||
TodoGateFired {
|
||||
fires: u32,
|
||||
pending: usize,
|
||||
in_progress: usize,
|
||||
reason: &'static str,
|
||||
},
|
||||
/// TodoGate hit its per-prompt fire cap. Distinct event so cap-exhaustion
|
||||
/// is not conflated with a normal fire in the dashboards.
|
||||
TodoGateExhausted {
|
||||
pending: usize,
|
||||
},
|
||||
/// Layer-3 LazinessDetector classifier completed and produced a verdict.
|
||||
/// Fires even in observation-only mode (`max_nudges_per_session = 0`)
|
||||
/// so dashboards can validate classification quality before any nudges
|
||||
/// are injected. `category` is one of the `LAZINESS_*` discriminator
|
||||
/// constants in `xai-grok-shell::session::events`.
|
||||
LazinessClassifierFired {
|
||||
model_id: String,
|
||||
category: &'static str,
|
||||
confidence: f32,
|
||||
},
|
||||
/// Layer-3 LazinessDetector injected a system-reminder nudge into the
|
||||
/// session. Always preceded by a `LazinessClassifierFired` for the
|
||||
/// same classification. Suppressed when the per-session cap is 0.
|
||||
LazinessNudgeFired {
|
||||
model_id: String,
|
||||
category: &'static str,
|
||||
nudges_remaining: u32,
|
||||
},
|
||||
/// Layer-3 LazinessDetector terminated without producing a verdict.
|
||||
/// `reason` is one of the `LAZINESS_ABORT_*` discriminator constants
|
||||
/// in `xai-grok-shell::session::events`.
|
||||
LazinessClassifierAborted {
|
||||
reason: &'static str,
|
||||
},
|
||||
/// Goal-achievement classifier subagent was invoked. Fires once per
|
||||
/// classifier attempt regardless of outcome; pairs with exactly one
|
||||
/// of `GoalClassifierVerdict`, `GoalClassifierFailOpen`, or
|
||||
/// `GoalClassifierFailClosed` once the run terminates.
|
||||
GoalClassifierFired {
|
||||
attempt: u32,
|
||||
max_runs: u32,
|
||||
model_id: String,
|
||||
},
|
||||
/// Goal-achievement classifier returned a parsed verdict (Achieved or
|
||||
/// NotAchieved). `latency_ms` is the spawn-to-parse wall clock.
|
||||
GoalClassifierVerdict {
|
||||
verdict: GoalClassifierVerdictTelemetry,
|
||||
attempt: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// Goal-achievement classifier could not produce a usable verdict due
|
||||
/// to an INFRA-class failure (timeout, sampler error, abort, file IO).
|
||||
/// Caller fails OPEN — treats as Achieved — and records the reason.
|
||||
GoalClassifierFailOpen {
|
||||
reason: &'static str,
|
||||
attempt: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// Goal-achievement classifier could not produce a usable verdict due
|
||||
/// to a PARSE-class failure (malformed terminal token, missing details
|
||||
/// file). Caller fails CLOSED — treats as NotAchieved.
|
||||
GoalClassifierFailClosed {
|
||||
reason: &'static str,
|
||||
attempt: u32,
|
||||
},
|
||||
/// Goal-achievement classifier hit the per-goal run cap. Distinct event
|
||||
/// so cap exhaustion is not conflated with a normal verdict.
|
||||
GoalClassifierCapReached {
|
||||
attempt: u32,
|
||||
},
|
||||
/// Mid-turn `update_goal(completed: true)` was deferred to the next
|
||||
/// turn-end drain (Guard 2). `pending_depth` is the queue length
|
||||
/// AFTER the push so dashboards can spot accumulation in real time.
|
||||
GoalClassifierMidTurnDeferred {
|
||||
pending_depth: u32,
|
||||
},
|
||||
/// `update_goal(completed: true)` arrived AFTER the classifier
|
||||
/// cap had already auto-paused the goal. `attempts_seen` is the
|
||||
/// real `classifier_runs_attempted` snapshot (typically the cap),
|
||||
/// never `0`.
|
||||
GoalClassifierDroppedAfterCap {
|
||||
attempts_seen: u32,
|
||||
},
|
||||
/// A cap-pause cleared the pending-classifier-completions queue.
|
||||
/// One summary event per pause, not per-entry — `dropped` is the
|
||||
/// total entry count.
|
||||
GoalClassifierPendingQueueCleared {
|
||||
dropped: u32,
|
||||
},
|
||||
/// Goal planner subagent was invoked. Fires once per attempt;
|
||||
/// pairs with exactly one of `GoalPlannerCompleted` or
|
||||
/// `GoalPlannerFailClosed` once the run terminates. `max_runs`
|
||||
/// mirrors the classifier event for dashboard symmetry — the
|
||||
/// planner cap is always `1` today.
|
||||
GoalPlannerFired {
|
||||
attempt: u32,
|
||||
max_runs: u32,
|
||||
model_id: String,
|
||||
},
|
||||
/// Planner subagent wrote a plan file successfully.
|
||||
/// `latency_ms` is the spawn-to-write wall clock.
|
||||
GoalPlannerCompleted {
|
||||
attempt: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// Planner subagent failed and the harness paused the goal
|
||||
/// fail-closed. `reason` is one of the `GOAL_PLANNER_FAIL_CLOSED_*`
|
||||
/// discriminator constants in `xai-grok-shell::session::events`.
|
||||
GoalPlannerFailClosed {
|
||||
reason: &'static str,
|
||||
attempt: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// Stall-triggered strategist subagent was invoked after
|
||||
/// `consecutive_failures` consecutive `NotAchieved` verifications.
|
||||
/// Fires once per trigger (at N, 2N, …); pairs with exactly one of
|
||||
/// `GoalStrategistCompleted` or `GoalStrategistFailed`. Unlike the
|
||||
/// planner the strategist is fail-OPEN — a failure never pauses the
|
||||
/// goal. `attempt` is the verifier attempt that triggered it. `every`
|
||||
/// is the resolved cadence N, so a configured override is observable.
|
||||
GoalStrategistFired {
|
||||
attempt: u32,
|
||||
consecutive_failures: u32,
|
||||
every: u32,
|
||||
model_id: String,
|
||||
},
|
||||
/// Strategist subagent wrote a strategy note successfully.
|
||||
/// `latency_ms` is the spawn-to-write wall clock.
|
||||
GoalStrategistCompleted {
|
||||
attempt: u32,
|
||||
consecutive_failures: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// Strategist subagent failed; the harness logged it and continued
|
||||
/// the normal loop (fail-OPEN — the goal is NOT paused). `reason` is
|
||||
/// one of the `GOAL_STRATEGIST_FAILED_*` discriminator constants in
|
||||
/// `xai-grok-shell::session::events`.
|
||||
GoalStrategistFailed {
|
||||
reason: &'static str,
|
||||
attempt: u32,
|
||||
consecutive_failures: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// The plan.md-safety guard could not restore the verifier-judged
|
||||
/// contract to its pre-strategist bytes (a write/remove failed, or a
|
||||
/// symlink was planted at the path). The contract may be corrupted —
|
||||
/// surfaced so it is observable rather than a silent `warn!`. `reason`
|
||||
/// is one of the `GOAL_STRATEGIST_RESTORE_*` discriminator constants in
|
||||
/// `xai-grok-shell::session::events`.
|
||||
GoalStrategistContractRestoreFailed {
|
||||
reason: &'static str,
|
||||
attempt: u32,
|
||||
},
|
||||
/// Goal summarizer subagent was invoked ONCE after the goal was
|
||||
/// verified-achieved (real `Achieved`, not the infra fail-open), to
|
||||
/// generate the closing user-facing summary. Pairs with exactly one of
|
||||
/// `GoalSummarizerCompleted` or `GoalSummarizerFailOpen`. Fail-OPEN — a
|
||||
/// failure never blocks completion. `attempt` is the achieving verifier
|
||||
/// attempt; `model_id` is the inherited session model.
|
||||
GoalSummarizerFired {
|
||||
attempt: u32,
|
||||
model_id: String,
|
||||
},
|
||||
/// Summarizer returned a non-empty summary; the harness surfaced it as the
|
||||
/// goal turn's closing message. `latency_ms` is the spawn-to-summary wall
|
||||
/// clock.
|
||||
GoalSummarizerCompleted {
|
||||
attempt: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// Summarizer failed (transport / runtime / cancel / empty output); the
|
||||
/// harness skipped the closing summary and completed the goal normally
|
||||
/// (fail-OPEN — completion is never blocked). `reason` is one of the
|
||||
/// `GOAL_SUMMARIZER_FAIL_OPEN_*` discriminator constants in
|
||||
/// `xai-grok-shell::session::events`.
|
||||
GoalSummarizerFailOpen {
|
||||
reason: &'static str,
|
||||
attempt: u32,
|
||||
latency_ms: u64,
|
||||
},
|
||||
|
||||
/// A `/goal` subagent role (planner, strategist, or a skeptic index)
|
||||
/// committed to an explicit model+toolset selection. `role` is one of
|
||||
/// `planner|strategist|skeptic`; `skeptic_idx` is set only for the
|
||||
/// skeptic panel. `source` is the resolution provenance: a
|
||||
/// committed explicit pair is always `remote` (the only non-inherit
|
||||
/// source); `default`/kill-switch resolutions inherit the current
|
||||
/// model and do not emit this event. Emitted once per role/skeptic-
|
||||
/// index when an explicit selection is committed.
|
||||
GoalRoleModelResolved {
|
||||
role: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
skeptic_idx: Option<u32>,
|
||||
model_id: String,
|
||||
agent_type: String,
|
||||
source: &'static str,
|
||||
},
|
||||
/// A `/goal` subagent role fell open to the current model because its
|
||||
/// configured pair was unusable. `role` is one of
|
||||
/// `planner|strategist|skeptic`; `skeptic_idx` is set only for the
|
||||
/// skeptic panel. `reason` is one of the
|
||||
/// `GOAL_ROLE_MODEL_FAIL_OPEN_*` discriminator constants in
|
||||
/// `xai-grok-shell::session::events`. Fail-open never pauses the goal.
|
||||
GoalRoleModelFailOpen {
|
||||
role: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
skeptic_idx: Option<u32>,
|
||||
reason: &'static str,
|
||||
},
|
||||
|
||||
/// One skeptic in the adversarial panel returned a verdict. Fires
|
||||
/// `N` times per verification stage (where N is
|
||||
/// `goal_verifier_count`). `confidence` is the JSON `confidence`
|
||||
/// field; the wire vocabulary is `high|medium|low|unknown`.
|
||||
/// `latency_ms` is the per-skeptic spawn-to-verdict wall clock —
|
||||
/// dashboards can surface slow outliers even though the panel-
|
||||
/// level emission is batched via `join_all`.
|
||||
GoalVerifierSkepticVerdict {
|
||||
attempt: u32,
|
||||
skeptic_idx: u32,
|
||||
refuted: bool,
|
||||
confidence: &'static str,
|
||||
latency_ms: u64,
|
||||
},
|
||||
/// Aggregate verdict across all N skeptics. `refuted_count` /
|
||||
/// `total` is the majority-refute fraction; `achieved` is the
|
||||
/// stage's final verdict (true ⇒ survives, false ⇒ majority-refute).
|
||||
GoalVerifierAggregateVerdict {
|
||||
attempt: u32,
|
||||
refuted_count: u32,
|
||||
total: u32,
|
||||
achieved: bool,
|
||||
},
|
||||
/// The stop-detector matched a known bail/hand-off/verdict
|
||||
/// pattern in the LAST paragraph of the assistant's turn-final
|
||||
/// text while the goal stayed `Active` with pending todos. The
|
||||
/// harness defeated the premature stop by queuing the bail-specific
|
||||
/// continuation reminder; this event records the matched pattern
|
||||
/// label so dashboards can audit precision/recall of the regex
|
||||
/// panel. `pattern` is one of the stable labels
|
||||
/// enumerated by
|
||||
/// `xai-grok-shell::session::goal_stop_detector::PATTERN_LABELS`;
|
||||
/// the source-string provenance for each label is pinned by the
|
||||
/// adjacent `STOP_REGEX_SOURCES` table.
|
||||
///
|
||||
/// Under-counts by design: fires only when a fresh bail continuation
|
||||
/// is queued. If a classifier-rejection nudge is already pending, the
|
||||
/// shared idempotency gate suppresses both the duplicate push and
|
||||
/// JSON-RPC message and was skipped instead of tearing down the
|
||||
/// this event, so dashboards see a lower bound.
|
||||
GoalPrematureStopDetected {
|
||||
pattern: &'static str,
|
||||
},
|
||||
|
||||
// ── MCP Diagnostics ──────────────────────────────────────────
|
||||
McpConfigResolved {
|
||||
servers: Vec<McpConfigServer>,
|
||||
disabled: Vec<String>,
|
||||
},
|
||||
McpManagedConfigResult {
|
||||
server_count: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
#[serde(rename = "mcp_oauth_discovery_timeout")]
|
||||
McpOAuthDiscoveryTimeout {
|
||||
server_name: String,
|
||||
url: String,
|
||||
},
|
||||
McpServerStarting {
|
||||
server_name: String,
|
||||
transport: String,
|
||||
target: String,
|
||||
timeout_sec: u64,
|
||||
},
|
||||
McpServerConnected {
|
||||
server_name: String,
|
||||
transport: String,
|
||||
tool_count: u32,
|
||||
duration_ms: u64,
|
||||
tools: Vec<String>,
|
||||
},
|
||||
McpServerFailed {
|
||||
server_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
transport: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
target: Option<String>,
|
||||
error_type: McpErrorCategory,
|
||||
error_message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
timeout_sec: Option<u64>,
|
||||
},
|
||||
McpToolRegistrationFailed {
|
||||
server_name: String,
|
||||
tool_name: String,
|
||||
error: String,
|
||||
},
|
||||
McpInitCompleted {
|
||||
total_servers: u32,
|
||||
succeeded: u32,
|
||||
failed: u32,
|
||||
auth_required: u32,
|
||||
total_tools: u32,
|
||||
duration_ms: u64,
|
||||
is_reinit: bool,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
failed_servers: Vec<String>,
|
||||
},
|
||||
McpInitCancelled {
|
||||
reason: String,
|
||||
},
|
||||
McpToolCallStarted {
|
||||
server_name: String,
|
||||
tool_name: String,
|
||||
call_id: String,
|
||||
timeout_sec: u64,
|
||||
},
|
||||
McpToolCallCompleted {
|
||||
server_name: String,
|
||||
tool_name: String,
|
||||
call_id: String,
|
||||
duration_ms: u64,
|
||||
success: bool,
|
||||
is_timeout: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
reconnect_attempted: bool,
|
||||
auth_retry_attempted: bool,
|
||||
},
|
||||
McpTransportError {
|
||||
server_name: String,
|
||||
tool_name: String,
|
||||
error: String,
|
||||
},
|
||||
/// A line on an MCP stdio server's stdout could not be decoded as a
|
||||
/// transport. Surfaces the otherwise-invisible "connector shows but
|
||||
/// doesn't work" case (a server logging to stdout, a JSON-RPC batch
|
||||
/// array, or an off-spec response). Distinct from `McpTransportError`,
|
||||
/// environment; either the orchestrator called
|
||||
/// which is a per-tool-call transport failure.
|
||||
McpTransportDecodeError {
|
||||
server_name: String,
|
||||
error: String,
|
||||
/// Truncated copy of the offending line, for diagnosis.
|
||||
sample: String,
|
||||
},
|
||||
McpTransportReconnect {
|
||||
server_name: String,
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
McpAuthRetry {
|
||||
server_name: String,
|
||||
trigger: String,
|
||||
success: bool,
|
||||
},
|
||||
McpHealthCheck {
|
||||
server_name: String,
|
||||
healthy: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
client_state: Option<String>,
|
||||
},
|
||||
McpServerToggled {
|
||||
server_name: String,
|
||||
enabled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Where a mid-turn interjection originated. Drives the `source` field on
|
||||
/// [`Event::Interjected`].
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum InterjectionSource {
|
||||
/// Direct `x.ai/interject` while a turn was running (Ctrl+Enter).
|
||||
Direct,
|
||||
/// A queued (not-yet-running) prompt promoted into the running turn via
|
||||
/// `InterjectQueuedPrompt` (queue "send now").
|
||||
Queue,
|
||||
}
|
||||
|
||||
/// The user-redirect mechanism behind an event — the shared discriminator that
|
||||
/// lets the trace pipeline query every user steer through one field. Present on
|
||||
/// [`Event::Interjected`] (always [`RedirectKind::Interjection`]) and, for the
|
||||
/// next turn after a Ctrl+C / Esc abort, on [`Event::TurnStarted`]
|
||||
/// ([`RedirectKind::CancelThenSend`] / [`RedirectKind::QueuedAfterCancel`]).
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RedirectKind {
|
||||
/// Mid-turn interjection — Ctrl+O / `x.ai/interject`, or "Send now" on a
|
||||
/// queued row. The turn keeps running; nothing is cancelled.
|
||||
Interjection,
|
||||
/// The turn was aborted (Ctrl+C / Esc) and the user then typed and sent a
|
||||
/// fresh prompt as the next turn.
|
||||
CancelThenSend,
|
||||
/// The turn was aborted (Ctrl+C / Esc) while a prompt sat queued behind it;
|
||||
/// that queued prompt was promoted as the next turn.
|
||||
QueuedAfterCancel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum McpErrorCategory {
|
||||
SpawnFailed,
|
||||
Timeout,
|
||||
HandshakeFailed,
|
||||
AuthRequired,
|
||||
ClientError,
|
||||
}
|
||||
|
||||
/// Server entry in `McpConfigResolved`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct McpConfigServer {
|
||||
pub name: String,
|
||||
pub transport: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
/// Telemetry mirror of `xai-grok-shell`'s `GoalClassifierVerdict`. Two
|
||||
/// crates due to the orphan rule; the conversion lives in
|
||||
/// `xai-grok-shell/src/session/events.rs`.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GoalClassifierVerdictTelemetry {
|
||||
Achieved,
|
||||
NotAchieved,
|
||||
}
|
||||
|
||||
/// Telemetry mirror of `xai-grok-shell`'s `GoalPauseReason`. The two types
|
||||
/// live in separate crates (orphan rule); the conversion lives in
|
||||
/// `xai-grok-shell/src/session/events.rs`.
|
||||
///
|
||||
/// **Invariant:** when adding a new variant to either side, add the
|
||||
/// matching variant here so the compiler-enforced `From` impl on the
|
||||
/// shell side catches the drift at build time.
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GoalPauseReasonTelemetry {
|
||||
User,
|
||||
BackOff,
|
||||
/// Verification stage saw no fingerprint change in the flagged gaps
|
||||
/// across consecutive attempts and auto-paused before the run cap.
|
||||
NoProgress,
|
||||
/// Verification determined the goal is not achievable in this
|
||||
/// `update_goal(blocked_reason: ...)`, or every refuter classified
|
||||
/// `update_goal(blocked_reason: ...)`, or every refuter classified
|
||||
/// its gap as a contradiction / unverifiable blocker.
|
||||
Verification,
|
||||
/// Turn finished with `PromptTurnResult::Err` (infrastructure failure).
|
||||
Infra,
|
||||
}
|
||||
|
||||
/// Outcome of a single tool call. More granular than a boolean -- distinguishes
|
||||
/// between tools that executed vs tools that were never run.
|
||||
#[derive(Debug, Clone, Copy, Serialize, strum::IntoStaticStr)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum ToolOutcome {
|
||||
/// Tool executed and returned a result.
|
||||
Success,
|
||||
/// Tool executed but returned an error.
|
||||
Error,
|
||||
/// User rejected the permission prompt.
|
||||
PermissionRejected,
|
||||
/// User cancelled the permission prompt (Cmd+C).
|
||||
PermissionCancelled,
|
||||
/// User provided a followup message instead of approving.
|
||||
Followup,
|
||||
/// A user-configured hook blocked execution.
|
||||
HookDenied,
|
||||
/// Tool not found or arguments couldn't be parsed.
|
||||
InvalidTool,
|
||||
/// Tool was running when the turn was cancelled (Cmd+C).
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Phase {
|
||||
WaitingForModel,
|
||||
StreamingText,
|
||||
StreamingReasoning,
|
||||
ToolExecution,
|
||||
PermissionPrompt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionRelationship {
|
||||
Primary,
|
||||
#[allow(dead_code)]
|
||||
Subagent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TurnOutcomeLabel {
|
||||
Completed,
|
||||
Cancelled,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PermissionDecision {
|
||||
Allow,
|
||||
Deny,
|
||||
Cancelled,
|
||||
Followup,
|
||||
}
|
||||
|
||||
// `Deserialize`/`PartialEq`/`Eq`/`Hash` let the workspace decode
|
||||
// `cancellation_category` strings back into this enum. `snake_case` keeps the
|
||||
// wire form identical, so adding `Deserialize` doesn't change serialization.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CancellationCategory {
|
||||
HookDenied,
|
||||
PermissionRejected,
|
||||
PermissionCancelled,
|
||||
MidTurnAbort,
|
||||
}
|
||||
|
||||
// Note: `From<&permission::Decision> for PermissionDecision` crosses the
|
||||
// crate boundary (orphan rule) and lives in
|
||||
// `xai-grok-shell/src/session/events.rs`.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every variant must survive a `to_value` -> `from_value` round-trip.
|
||||
#[test]
|
||||
fn cancellation_category_round_trips_every_variant() {
|
||||
for variant in [
|
||||
CancellationCategory::HookDenied,
|
||||
CancellationCategory::PermissionRejected,
|
||||
CancellationCategory::PermissionCancelled,
|
||||
CancellationCategory::MidTurnAbort,
|
||||
] {
|
||||
let value = serde_json::to_value(variant).unwrap();
|
||||
let decoded: CancellationCategory = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(decoded, variant, "{variant:?} must round-trip");
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialization is unchanged by the added derives (bare snake_case strings).
|
||||
#[test]
|
||||
fn cancellation_category_serializes_snake_case() {
|
||||
for (variant, expected) in [
|
||||
(CancellationCategory::HookDenied, "\"hook_denied\""),
|
||||
(
|
||||
CancellationCategory::PermissionRejected,
|
||||
"\"permission_rejected\"",
|
||||
),
|
||||
(
|
||||
CancellationCategory::PermissionCancelled,
|
||||
"\"permission_cancelled\"",
|
||||
),
|
||||
(CancellationCategory::MidTurnAbort, "\"mid_turn_abort\""),
|
||||
] {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(json, expected, "{variant:?} must serialize to {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interjected_event_serializes_tag_source_and_count() {
|
||||
let ev = Event::Interjected {
|
||||
source: InterjectionSource::Direct,
|
||||
image_count: 2,
|
||||
redirect_kind: RedirectKind::Interjection,
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v["type"], "interjected");
|
||||
assert_eq!(v["source"], "direct");
|
||||
assert_eq!(v["image_count"], 2);
|
||||
// Shared discriminator: always present on interjected events.
|
||||
assert_eq!(v["redirect_kind"], "interjection");
|
||||
|
||||
let queue = serde_json::to_value(Event::Interjected {
|
||||
source: InterjectionSource::Queue,
|
||||
image_count: 0,
|
||||
redirect_kind: RedirectKind::Interjection,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(queue["source"], "queue");
|
||||
assert_eq!(queue["image_count"], 0);
|
||||
assert_eq!(queue["redirect_kind"], "interjection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_kind_serializes_snake_case() {
|
||||
for (variant, expected) in [
|
||||
(RedirectKind::Interjection, "\"interjection\""),
|
||||
(RedirectKind::CancelThenSend, "\"cancel_then_send\""),
|
||||
(RedirectKind::QueuedAfterCancel, "\"queued_after_cancel\""),
|
||||
] {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(json, expected, "{variant:?} must serialize to {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_started_redirect_kind_present_when_set_omitted_when_none() {
|
||||
let with_kind = serde_json::to_value(Event::TurnStarted {
|
||||
session_id: "s".into(),
|
||||
turn_number: 2,
|
||||
model_id: "grok-4".into(),
|
||||
yolo_mode: false,
|
||||
conversation_message_count: 3,
|
||||
session_relationship: SessionRelationship::Primary,
|
||||
schema_version: EVENT_SCHEMA_VERSION.into(),
|
||||
redirect_kind: Some(RedirectKind::QueuedAfterCancel),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(with_kind["type"], "turn_started");
|
||||
assert_eq!(with_kind["redirect_kind"], "queued_after_cancel");
|
||||
|
||||
let normal = serde_json::to_value(Event::TurnStarted {
|
||||
session_id: "s".into(),
|
||||
turn_number: 1,
|
||||
model_id: "grok-4".into(),
|
||||
yolo_mode: false,
|
||||
conversation_message_count: 0,
|
||||
session_relationship: SessionRelationship::Primary,
|
||||
schema_version: EVENT_SCHEMA_VERSION.into(),
|
||||
redirect_kind: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
normal.get("redirect_kind").is_none(),
|
||||
"redirect_kind must be omitted on a normal turn, got {normal}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_pause_reason_telemetry_serializes_snake_case() {
|
||||
for (variant, expected) in [
|
||||
(GoalPauseReasonTelemetry::User, "\"user\""),
|
||||
(GoalPauseReasonTelemetry::BackOff, "\"back_off\""),
|
||||
(GoalPauseReasonTelemetry::NoProgress, "\"no_progress\""),
|
||||
(GoalPauseReasonTelemetry::Verification, "\"verification\""),
|
||||
(GoalPauseReasonTelemetry::Infra, "\"infra\""),
|
||||
] {
|
||||
let json = serde_json::to_string(&variant).unwrap();
|
||||
assert_eq!(json, expected, "{variant:?} must serialize to {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_strategist_fired_serializes_cadence_field() {
|
||||
// `every` must serialize as a plain number on the wire.
|
||||
let ev = Event::GoalStrategistFired {
|
||||
attempt: 2,
|
||||
consecutive_failures: 6,
|
||||
every: 3,
|
||||
model_id: "grok-4".to_string(),
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v["type"], "goal_strategist_fired");
|
||||
assert_eq!(v["attempt"], 2);
|
||||
assert_eq!(v["consecutive_failures"], 6);
|
||||
assert_eq!(v["every"], 3);
|
||||
assert_eq!(v["model_id"], "grok-4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_summarizer_events_serialize_tag_and_fields() {
|
||||
let fired = Event::GoalSummarizerFired {
|
||||
attempt: 2,
|
||||
model_id: "grok-4".to_string(),
|
||||
};
|
||||
let v = serde_json::to_value(&fired).unwrap();
|
||||
assert_eq!(v["type"], "goal_summarizer_fired");
|
||||
assert_eq!(v["attempt"], 2);
|
||||
assert_eq!(v["model_id"], "grok-4");
|
||||
|
||||
let completed = Event::GoalSummarizerCompleted {
|
||||
attempt: 2,
|
||||
latency_ms: 42,
|
||||
};
|
||||
let v = serde_json::to_value(&completed).unwrap();
|
||||
assert_eq!(v["type"], "goal_summarizer_completed");
|
||||
assert_eq!(v["attempt"], 2);
|
||||
assert_eq!(v["latency_ms"], 42);
|
||||
|
||||
let failed = Event::GoalSummarizerFailOpen {
|
||||
reason: "transport",
|
||||
attempt: 2,
|
||||
latency_ms: 7,
|
||||
};
|
||||
let v = serde_json::to_value(&failed).unwrap();
|
||||
assert_eq!(v["type"], "goal_summarizer_fail_open");
|
||||
assert_eq!(v["reason"], "transport");
|
||||
assert_eq!(v["attempt"], 2);
|
||||
assert_eq!(v["latency_ms"], 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_role_model_resolved_serializes_tag_and_fields() {
|
||||
let ev = Event::GoalRoleModelResolved {
|
||||
role: "skeptic",
|
||||
skeptic_idx: Some(2),
|
||||
model_id: "grok-4".to_string(),
|
||||
agent_type: "general-purpose".to_string(),
|
||||
source: "remote",
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v["type"], "goal_role_model_resolved");
|
||||
assert_eq!(v["role"], "skeptic");
|
||||
assert_eq!(v["skeptic_idx"], 2);
|
||||
assert_eq!(v["model_id"], "grok-4");
|
||||
assert_eq!(v["agent_type"], "general-purpose");
|
||||
assert_eq!(v["source"], "remote");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_role_model_resolved_omits_skeptic_idx_when_none() {
|
||||
let ev = Event::GoalRoleModelResolved {
|
||||
role: "planner",
|
||||
skeptic_idx: None,
|
||||
model_id: "grok-4".to_string(),
|
||||
agent_type: "general-purpose".to_string(),
|
||||
source: "remote",
|
||||
};
|
||||
let obj = serde_json::to_value(&ev).unwrap();
|
||||
assert!(
|
||||
obj.get("skeptic_idx").is_none(),
|
||||
"skeptic_idx must be omitted when None, got {obj}"
|
||||
);
|
||||
assert_eq!(obj["role"], "planner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_role_model_fail_open_serializes_tag_and_fields() {
|
||||
let ev = Event::GoalRoleModelFailOpen {
|
||||
role: "skeptic",
|
||||
skeptic_idx: Some(1),
|
||||
reason: "toolset_unavailable",
|
||||
};
|
||||
let v = serde_json::to_value(&ev).unwrap();
|
||||
assert_eq!(v["type"], "goal_role_model_fail_open");
|
||||
assert_eq!(v["role"], "skeptic");
|
||||
assert_eq!(v["skeptic_idx"], 1);
|
||||
assert_eq!(v["reason"], "toolset_unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_role_model_fail_open_omits_skeptic_idx_when_none() {
|
||||
let ev = Event::GoalRoleModelFailOpen {
|
||||
role: "strategist",
|
||||
skeptic_idx: None,
|
||||
reason: "model_unauthorized",
|
||||
};
|
||||
let obj = serde_json::to_value(&ev).unwrap();
|
||||
assert!(
|
||||
obj.get("skeptic_idx").is_none(),
|
||||
"skeptic_idx must be omitted when None, got {obj}"
|
||||
);
|
||||
assert_eq!(obj["type"], "goal_role_model_fail_open");
|
||||
assert_eq!(obj["role"], "strategist");
|
||||
assert_eq!(obj["reason"], "model_unauthorized");
|
||||
}
|
||||
}
|
||||
893
crates/codegen/xai-file-utils/src/gcs.rs
Normal file
893
crates/codegen/xai-file-utils/src/gcs.rs
Normal file
|
|
@ -0,0 +1,893 @@
|
|||
//! Shared upload utilities for session persistence and agent telemetry.
|
||||
//!
|
||||
//! This module provides a unified interface for uploading bytes to cloud storage,
|
||||
//! supporting direct upload (via service account), proxy upload (via cli-chat-proxy),
|
||||
//! and S3-compatible backends.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
use crate::UploadMethod;
|
||||
use xai_grok_auth::{AuthCredentialProvider, StaticAuthCredentialProvider};
|
||||
|
||||
use crate::storage_client::{Auth401AttributionCallback, StaticGrokAuth, StorageClient};
|
||||
|
||||
/// Threshold for switching to multipart upload (50 MB).
|
||||
///
|
||||
/// Files larger than this use `StorageClient::upload_multipart()` (signed URLs,
|
||||
/// parts uploaded directly to cloud storage) instead of streaming through the proxy.
|
||||
pub const MULTIPART_UPLOAD_THRESHOLD: u64 = 50 * 1024 * 1024;
|
||||
|
||||
/// Construct a `StorageClient` for proxy-mode uploads. Uses the caller-provided
|
||||
/// refresh-aware credentials when present, otherwise falls back to a
|
||||
/// `StaticGrokAuth` carrying the inline user / deployment keys from
|
||||
/// `UploadMethod::Proxy`. The optional `http_client` lets the caller pass a
|
||||
/// shell-tuned client (HTTP/2 keep-alive, conn pool tuning); when `None` we
|
||||
/// fall back to `reqwest::Client::new()`.
|
||||
fn build_proxy_client_with_fallback(
|
||||
proxy_base_url: &str,
|
||||
user_token: &str,
|
||||
deployment_key: Option<String>,
|
||||
credentials: Option<Arc<dyn AuthCredentialProvider>>,
|
||||
attribution: Option<Arc<dyn Auth401AttributionCallback>>,
|
||||
http_client: Option<reqwest::Client>,
|
||||
) -> StorageClient {
|
||||
let provider = credentials.unwrap_or_else(|| {
|
||||
let mut creds = StaticGrokAuth::new(Some(user_token.to_owned()));
|
||||
creds.deployment_key = deployment_key;
|
||||
let bearer = creds.wire_bearer();
|
||||
Arc::new(StaticAuthCredentialProvider::new(Box::new(creds), bearer))
|
||||
});
|
||||
let http_client = http_client.unwrap_or_default();
|
||||
let mut client = StorageClient::with_provider(proxy_base_url, http_client, provider);
|
||||
if let Some(cb) = attribution {
|
||||
client = client.with_attribution(cb);
|
||||
}
|
||||
client
|
||||
}
|
||||
|
||||
/// Implement `StorageConfig` for `TraceExportConfig`. Lives here (alongside the
|
||||
/// trait + upload helpers) so callers can use the shared upload helpers without
|
||||
/// a foreign-trait impl. Refresh-aware callers still get credential /
|
||||
/// attribution wiring via `TraceExportConfigWithAuth` (in shell).
|
||||
impl StorageConfig for crate::TraceExportConfig {
|
||||
fn bucket_url(&self) -> &str {
|
||||
// For proxy mode, bucket_url may be None (proxy determines it from ACLs).
|
||||
// Return a placeholder that won't be used.
|
||||
self.bucket_url.as_deref().unwrap_or("gs://placeholder")
|
||||
}
|
||||
|
||||
fn upload_method(&self) -> &UploadMethod {
|
||||
&self.upload_method
|
||||
}
|
||||
}
|
||||
|
||||
/// A trait for storage configuration that provides bucket URL and upload method.
|
||||
/// This allows different config types (TraceExportConfig, etc.) to share upload logic.
|
||||
pub trait StorageConfig {
|
||||
fn bucket_url(&self) -> &str;
|
||||
fn upload_method(&self) -> &UploadMethod;
|
||||
/// Optional refresh-aware credentials for proxy-mode uploads. When
|
||||
/// `Some(_)`, `upload_*_via_proxy` helpers construct a `StorageClient`
|
||||
/// via `StorageClient::with_provider(...)` so 401 retries can request
|
||||
/// a token refresh. Default `None` for configs that ship a static
|
||||
/// user-token only.
|
||||
fn proxy_credentials(&self) -> Option<Arc<dyn AuthCredentialProvider>> {
|
||||
None
|
||||
}
|
||||
/// Optional 401-attribution callback. When `Some(_)`, the constructed
|
||||
/// `StorageClient` also calls `with_attribution(...)` so the embedding
|
||||
/// application records auth-attribution telemetry for proxy 401s.
|
||||
fn proxy_attribution(&self) -> Option<Arc<dyn Auth401AttributionCallback>> {
|
||||
None
|
||||
}
|
||||
/// Optional HTTP client for proxy-mode uploads. `None` falls back to
|
||||
/// `reqwest::Client::new()` (used by bins/tests). Production callers
|
||||
/// should return shell's tuned `shared_upload_client()` -- HTTP/2
|
||||
/// keep-alive + aggressive connection pool eviction. The trace upload
|
||||
/// queue, feedback uploads, share uploads, and subagent metadata
|
||||
/// uploads all rely on this tuning to avoid stale-connection retries
|
||||
/// during backoff loops.
|
||||
fn proxy_http_client(&self) -> Option<reqwest::Client> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads bytes to cloud storage at the specified path.
|
||||
/// Returns the full storage URL on success.
|
||||
/// Dispatches to direct, proxy, or S3 backend based on config.
|
||||
pub async fn upload_bytes<C: StorageConfig>(
|
||||
config: &C,
|
||||
object_path: &str,
|
||||
content: &[u8],
|
||||
content_type: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
match config.upload_method() {
|
||||
UploadMethod::Direct {
|
||||
service_account_key,
|
||||
} => {
|
||||
// Parse the bucket URL to extract bucket name (required for direct mode)
|
||||
let url = url::Url::parse(config.bucket_url())
|
||||
.with_context(|| format!("Invalid GCS URL: {}", config.bucket_url()))?;
|
||||
|
||||
if url.scheme() != "gs" {
|
||||
anyhow::bail!(
|
||||
"Invalid GCS URL scheme: expected 'gs', got '{}'",
|
||||
url.scheme()
|
||||
);
|
||||
}
|
||||
|
||||
let bucket = url
|
||||
.host_str()
|
||||
.context("GCS URL must have a bucket name")?
|
||||
.to_string();
|
||||
|
||||
upload_bytes_direct(
|
||||
&bucket,
|
||||
object_path,
|
||||
content,
|
||||
content_type,
|
||||
service_account_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
UploadMethod::Proxy {
|
||||
proxy_base_url,
|
||||
user_token,
|
||||
deployment_key,
|
||||
alpha_test_key: _,
|
||||
} => {
|
||||
// For proxy mode, bucket is determined by proxy from user ACLs
|
||||
tracing::debug!(
|
||||
proxy_base_url = %proxy_base_url,
|
||||
object_path = %object_path,
|
||||
"Uploading bytes to GCS via proxy (bucket determined by proxy from ACLs)"
|
||||
);
|
||||
upload_bytes_via_proxy(
|
||||
proxy_base_url,
|
||||
user_token,
|
||||
deployment_key.as_deref(),
|
||||
object_path,
|
||||
content,
|
||||
content_type,
|
||||
config.proxy_credentials(),
|
||||
config.proxy_attribution(),
|
||||
config.proxy_http_client(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
UploadMethod::S3 {
|
||||
bucket,
|
||||
region,
|
||||
credentials_file,
|
||||
credentials_content,
|
||||
endpoint_url,
|
||||
} => {
|
||||
crate::s3::upload_bytes(
|
||||
bucket,
|
||||
object_path,
|
||||
content,
|
||||
content_type,
|
||||
region,
|
||||
credentials_content.as_deref(),
|
||||
credentials_file.as_deref(),
|
||||
endpoint_url.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`upload_bytes`], but in proxy mode uses a pre-signed PUT URL
|
||||
/// so the data goes directly to storage instead of through the proxy.
|
||||
///
|
||||
/// This avoids the nginx `proxy-body-size: 4m` limit on the HTTP ingress and
|
||||
/// the Cloudflare 100 MB limit, making it safe for arbitrarily large payloads
|
||||
/// (e.g. session share data).
|
||||
///
|
||||
/// In direct mode this is identical to `upload_bytes` (the service
|
||||
/// account already talks to storage directly).
|
||||
pub async fn upload_bytes_signed<C: StorageConfig>(
|
||||
config: &C,
|
||||
object_path: &str,
|
||||
content: &[u8],
|
||||
content_type: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
match config.upload_method() {
|
||||
UploadMethod::Direct { .. } => {
|
||||
// Direct mode already bypasses the proxy — reuse the existing path.
|
||||
upload_bytes(config, object_path, content, content_type).await
|
||||
}
|
||||
UploadMethod::Proxy {
|
||||
proxy_base_url,
|
||||
user_token,
|
||||
deployment_key,
|
||||
alpha_test_key: _,
|
||||
} => {
|
||||
tracing::debug!(
|
||||
proxy_base_url = %proxy_base_url,
|
||||
object_path = %object_path,
|
||||
bytes = content.len(),
|
||||
"Uploading bytes to GCS via signed URL (bypasses proxy body limits)"
|
||||
);
|
||||
upload_bytes_via_signed_url(
|
||||
proxy_base_url,
|
||||
user_token,
|
||||
deployment_key.as_deref(),
|
||||
object_path,
|
||||
content,
|
||||
content_type,
|
||||
config.proxy_credentials(),
|
||||
config.proxy_attribution(),
|
||||
config.proxy_http_client(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
UploadMethod::S3 { .. } => upload_bytes(config, object_path, content, content_type).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads a file to cloud storage by streaming from disk.
|
||||
///
|
||||
/// Preferred over `upload_bytes` for the background upload queue because:
|
||||
/// - Never loads the full file into memory (critical for multi-GB dedup blobs)
|
||||
/// - For Proxy mode with large files (>50 MB), uses signed-URL multipart upload
|
||||
/// so data travels directly to storage, bypassing the proxy's body size limits
|
||||
/// - For Proxy mode with small files, uses `StorageClient::upload_file()` (streaming)
|
||||
/// - For Direct mode, streams via the gcloud-storage crate
|
||||
pub async fn upload_file<C: StorageConfig>(
|
||||
config: &C,
|
||||
object_path: &str,
|
||||
file_path: &Path,
|
||||
content_type: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
match config.upload_method() {
|
||||
UploadMethod::Direct {
|
||||
service_account_key,
|
||||
} => {
|
||||
let bucket_url = config.bucket_url();
|
||||
let url = url::Url::parse(bucket_url)
|
||||
.with_context(|| format!("Invalid GCS URL: {}", bucket_url))?;
|
||||
if url.scheme() != "gs" {
|
||||
anyhow::bail!(
|
||||
"Invalid GCS URL scheme: expected 'gs', got '{}'",
|
||||
url.scheme()
|
||||
);
|
||||
}
|
||||
let bucket = url
|
||||
.host_str()
|
||||
.context("GCS URL must have a bucket name")?
|
||||
.to_string();
|
||||
upload_file_direct(
|
||||
&bucket,
|
||||
object_path,
|
||||
file_path,
|
||||
content_type,
|
||||
service_account_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
UploadMethod::Proxy {
|
||||
proxy_base_url,
|
||||
user_token,
|
||||
deployment_key,
|
||||
alpha_test_key: _,
|
||||
} => {
|
||||
upload_file_via_proxy(
|
||||
proxy_base_url,
|
||||
user_token,
|
||||
deployment_key.as_deref(),
|
||||
object_path,
|
||||
file_path,
|
||||
content_type,
|
||||
config.proxy_credentials(),
|
||||
config.proxy_attribution(),
|
||||
config.proxy_http_client(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
UploadMethod::S3 {
|
||||
bucket,
|
||||
region,
|
||||
credentials_file,
|
||||
credentials_content,
|
||||
endpoint_url,
|
||||
} => {
|
||||
crate::s3::upload_file(
|
||||
bucket,
|
||||
object_path,
|
||||
file_path,
|
||||
content_type,
|
||||
region,
|
||||
credentials_content.as_deref(),
|
||||
credentials_file.as_deref(),
|
||||
endpoint_url.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads an async reader to cloud storage, dispatching to the appropriate backend.
|
||||
///
|
||||
/// Used for streaming compressed uploads where the reader is consumed once per attempt.
|
||||
/// Callers handle retries by recreating the reader.
|
||||
pub async fn upload_stream<C: StorageConfig, R>(
|
||||
config: &C,
|
||||
object_path: &str,
|
||||
reader: R,
|
||||
content_type: &str,
|
||||
) -> anyhow::Result<String>
|
||||
where
|
||||
R: tokio::io::AsyncRead + Send + Sync + 'static,
|
||||
{
|
||||
match config.upload_method() {
|
||||
UploadMethod::Direct {
|
||||
service_account_key,
|
||||
} => {
|
||||
let bucket_url = config.bucket_url();
|
||||
let url = url::Url::parse(bucket_url)
|
||||
.with_context(|| format!("Invalid GCS URL: {}", bucket_url))?;
|
||||
if url.scheme() != "gs" {
|
||||
anyhow::bail!(
|
||||
"Invalid GCS URL scheme: expected 'gs', got '{}'",
|
||||
url.scheme()
|
||||
);
|
||||
}
|
||||
let bucket = url
|
||||
.host_str()
|
||||
.context("GCS URL must have a bucket name")?
|
||||
.to_string();
|
||||
upload_stream_direct(
|
||||
&bucket,
|
||||
object_path,
|
||||
reader,
|
||||
content_type,
|
||||
service_account_key.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
UploadMethod::Proxy {
|
||||
proxy_base_url,
|
||||
user_token,
|
||||
deployment_key,
|
||||
alpha_test_key: _,
|
||||
} => {
|
||||
let storage_client = build_proxy_client_with_fallback(
|
||||
proxy_base_url,
|
||||
user_token,
|
||||
deployment_key.as_deref().map(|s| s.to_owned()),
|
||||
config.proxy_credentials(),
|
||||
config.proxy_attribution(),
|
||||
config.proxy_http_client(),
|
||||
);
|
||||
let response = storage_client
|
||||
.upload_stream(object_path, reader, content_type)
|
||||
.await
|
||||
.with_context(|| format!("Streaming upload failed for {}", object_path))?;
|
||||
Ok(format!("gs://{}/{}", response.bucket, response.path))
|
||||
}
|
||||
UploadMethod::S3 {
|
||||
bucket,
|
||||
region,
|
||||
credentials_file,
|
||||
credentials_content,
|
||||
endpoint_url,
|
||||
} => {
|
||||
crate::s3::upload_stream(
|
||||
bucket,
|
||||
object_path,
|
||||
reader,
|
||||
content_type,
|
||||
region,
|
||||
credentials_content.as_deref(),
|
||||
credentials_file.as_deref(),
|
||||
endpoint_url.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream an async reader directly to GCS via the gcloud-storage client.
|
||||
async fn upload_stream_direct<R: tokio::io::AsyncRead + Send + Sync + 'static>(
|
||||
bucket: &str,
|
||||
object_path: &str,
|
||||
reader: R,
|
||||
content_type: &str,
|
||||
service_account_key: Option<&str>,
|
||||
) -> anyhow::Result<String> {
|
||||
use gcloud_storage::http::objects::upload::{Media, UploadObjectRequest, UploadType};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
let client = build_gcs_client(service_account_key).await?;
|
||||
let stream = ReaderStream::new(reader);
|
||||
|
||||
let mut media = Media::new(object_path.to_string());
|
||||
media.content_type = content_type.to_owned().into();
|
||||
let upload_type = UploadType::Simple(media);
|
||||
let request = UploadObjectRequest {
|
||||
bucket: bucket.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
client
|
||||
.upload_streamed_object(&request, stream, &upload_type)
|
||||
.await
|
||||
.with_context(|| format!("Failed to upload to gs://{}/{}", bucket, object_path))?;
|
||||
|
||||
Ok(format!("gs://{}/{}", bucket, object_path))
|
||||
}
|
||||
|
||||
/// Upload a file through the cli-chat-proxy, choosing multipart vs streaming based on size.
|
||||
///
|
||||
/// Files > `MULTIPART_UPLOAD_THRESHOLD` use signed-URL multipart upload (parts go
|
||||
/// directly to cloud storage, not through the proxy HTTP body). This avoids the proxy's request
|
||||
/// body size limit and the timeout issues that cause 55% of upload failures for large
|
||||
/// dedup blobs.
|
||||
async fn upload_file_via_proxy(
|
||||
proxy_base_url: &str,
|
||||
user_token: &str,
|
||||
deployment_key: Option<&str>,
|
||||
object_path: &str,
|
||||
file_path: &Path,
|
||||
content_type: &str,
|
||||
credentials: Option<Arc<dyn AuthCredentialProvider>>,
|
||||
attribution: Option<Arc<dyn Auth401AttributionCallback>>,
|
||||
http_client: Option<reqwest::Client>,
|
||||
) -> anyhow::Result<String> {
|
||||
use crate::storage_client::{MultipartUploadOptions, RetryConfig};
|
||||
|
||||
let storage_client = build_proxy_client_with_fallback(
|
||||
proxy_base_url,
|
||||
user_token,
|
||||
deployment_key.map(|s| s.to_owned()),
|
||||
credentials,
|
||||
attribution,
|
||||
http_client,
|
||||
)
|
||||
.with_retry_config(RetryConfig::conservative());
|
||||
|
||||
let file_size = tokio::fs::metadata(file_path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to get file metadata: {}", file_path.display()))?
|
||||
.len();
|
||||
|
||||
if file_size > MULTIPART_UPLOAD_THRESHOLD {
|
||||
// Large file: upload directly to cloud storage via signed URLs (bypasses proxy body)
|
||||
tracing::info!(
|
||||
file_size,
|
||||
threshold = MULTIPART_UPLOAD_THRESHOLD,
|
||||
upload_method = "multipart",
|
||||
path = %file_path.display(),
|
||||
"Upload queue: using multipart for large file"
|
||||
);
|
||||
let options = MultipartUploadOptions::new().with_max_concurrent(4);
|
||||
let response = storage_client
|
||||
.upload_multipart(object_path, file_path, content_type, Some(options))
|
||||
.await
|
||||
.with_context(|| format!("Multipart upload failed for {}", object_path))?;
|
||||
Ok(response.gcs_url)
|
||||
} else {
|
||||
// Small file: stream through proxy (no memory copy)
|
||||
tracing::debug!(
|
||||
file_size,
|
||||
upload_method = "streaming",
|
||||
path = %file_path.display(),
|
||||
"Upload queue: using streaming for small file"
|
||||
);
|
||||
let response = storage_client
|
||||
.upload_file(object_path, file_path, content_type)
|
||||
.await
|
||||
.with_context(|| format!("Streaming upload failed for {}", object_path))?;
|
||||
Ok(format!("gs://{}/{}", response.bucket, response.path))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a GCS client with optional service account key, or default ADC.
|
||||
async fn build_gcs_client(
|
||||
service_account_key: Option<&str>,
|
||||
) -> anyhow::Result<gcloud_storage::client::Client> {
|
||||
use gcloud_storage::client::{Client as GcsClient, ClientConfig as GcsClientConfig};
|
||||
|
||||
let gcs_config = if let Some(key_json) = service_account_key {
|
||||
GcsClientConfig::default()
|
||||
.with_credentials(
|
||||
gcloud_storage::client::google_cloud_auth::credentials::CredentialsFile::new_from_str(key_json)
|
||||
.await
|
||||
.context("Failed to parse service account key")?,
|
||||
)
|
||||
.await
|
||||
.context("Failed to configure GCS client with service account")?
|
||||
} else {
|
||||
GcsClientConfig::default()
|
||||
.with_auth()
|
||||
.await
|
||||
.context("Failed to authenticate GCS client")?
|
||||
};
|
||||
|
||||
Ok(GcsClient::new(gcs_config))
|
||||
}
|
||||
|
||||
/// Upload a file directly to GCS by streaming from disk.
|
||||
async fn upload_file_direct(
|
||||
bucket: &str,
|
||||
object_path: &str,
|
||||
file_path: &Path,
|
||||
content_type: &str,
|
||||
service_account_key: Option<&str>,
|
||||
) -> anyhow::Result<String> {
|
||||
use gcloud_storage::http::objects::upload::{Media, UploadObjectRequest, UploadType};
|
||||
use tokio::fs::File as TokioFile;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
let client = build_gcs_client(service_account_key).await?;
|
||||
|
||||
let file = TokioFile::open(file_path)
|
||||
.await
|
||||
.with_context(|| format!("Failed to open file: {}", file_path.display()))?;
|
||||
// ReaderStream<TokioFile> yields io::Result<Bytes>; io::Error satisfies
|
||||
// upload_streamed_object's S::Error: Into<Box<dyn Error + Send + Sync>> bound directly.
|
||||
let stream = ReaderStream::new(file);
|
||||
|
||||
let mut media = Media::new(object_path.to_string());
|
||||
media.content_type = content_type.to_owned().into();
|
||||
let upload_type = UploadType::Simple(media);
|
||||
let request = UploadObjectRequest {
|
||||
bucket: bucket.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
client
|
||||
.upload_streamed_object(&request, stream, &upload_type)
|
||||
.await
|
||||
.with_context(|| format!("Failed to upload to gs://{}/{}", bucket, object_path))?;
|
||||
|
||||
Ok(format!("gs://{}/{}", bucket, object_path))
|
||||
}
|
||||
|
||||
/// Uploads bytes directly to GCS using the gcloud-storage client.
|
||||
async fn upload_bytes_direct(
|
||||
bucket: &str,
|
||||
object_path: &str,
|
||||
content: &[u8],
|
||||
content_type: &str,
|
||||
service_account_key: Option<&str>,
|
||||
) -> anyhow::Result<String> {
|
||||
use gcloud_storage::http::objects::upload::{Media, UploadObjectRequest, UploadType};
|
||||
|
||||
let client = build_gcs_client(service_account_key).await?;
|
||||
|
||||
let mut media = Media::new(object_path.to_string());
|
||||
media.content_type = content_type.to_owned().into();
|
||||
let upload_type = UploadType::Simple(media);
|
||||
let request = UploadObjectRequest {
|
||||
bucket: bucket.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
client
|
||||
.upload_object(&request, content.to_vec(), &upload_type)
|
||||
.await
|
||||
.with_context(|| format!("Failed to upload to gs://{}/{}", bucket, object_path))?;
|
||||
|
||||
// Return the full GCS URL
|
||||
Ok(format!("gs://{}/{}", bucket, object_path))
|
||||
}
|
||||
|
||||
/// Uploads bytes via the cli-chat-proxy storage proxy API.
|
||||
/// The bucket is determined by the proxy based on the user's ACLs.
|
||||
async fn upload_bytes_via_proxy(
|
||||
proxy_base_url: &str,
|
||||
user_token: &str,
|
||||
deployment_key: Option<&str>,
|
||||
object_path: &str,
|
||||
content: &[u8],
|
||||
content_type: &str,
|
||||
credentials: Option<Arc<dyn AuthCredentialProvider>>,
|
||||
attribution: Option<Arc<dyn Auth401AttributionCallback>>,
|
||||
http_client: Option<reqwest::Client>,
|
||||
) -> anyhow::Result<String> {
|
||||
use crate::storage_client::RetryConfig;
|
||||
|
||||
// Conservative retry config handles storage-backend 429 errors during autoscaling.
|
||||
let storage_client = build_proxy_client_with_fallback(
|
||||
proxy_base_url,
|
||||
user_token,
|
||||
deployment_key.map(|s| s.to_owned()),
|
||||
credentials,
|
||||
attribution,
|
||||
http_client,
|
||||
)
|
||||
.with_retry_config(RetryConfig::conservative());
|
||||
|
||||
let response = storage_client
|
||||
.upload(object_path, content, content_type)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to upload to storage proxy: {} (path: {})",
|
||||
proxy_base_url, object_path
|
||||
)
|
||||
})?;
|
||||
|
||||
// Return the full GCS URL
|
||||
Ok(format!("gs://{}/{}", response.bucket, response.path))
|
||||
}
|
||||
|
||||
/// Uploads bytes to cloud storage via a pre-signed PUT URL obtained from the proxy.
|
||||
///
|
||||
/// This completely bypasses the proxy for the data transfer, avoiding
|
||||
/// nginx / Cloudflare body-size limits. The proxy is only contacted
|
||||
/// once (to generate the signed URL), after which the bytes go straight
|
||||
/// to cloud storage.
|
||||
///
|
||||
/// Use this when the payload may exceed 4 MB (the nginx `proxy-body-size`
|
||||
/// on the HTTP ingress) — e.g. session share data.
|
||||
pub async fn upload_bytes_via_signed_url(
|
||||
proxy_base_url: &str,
|
||||
user_token: &str,
|
||||
deployment_key: Option<&str>,
|
||||
object_path: &str,
|
||||
content: &[u8],
|
||||
content_type: &str,
|
||||
credentials: Option<Arc<dyn AuthCredentialProvider>>,
|
||||
attribution: Option<Arc<dyn Auth401AttributionCallback>>,
|
||||
http_client: Option<reqwest::Client>,
|
||||
) -> anyhow::Result<String> {
|
||||
let storage_client = build_proxy_client_with_fallback(
|
||||
proxy_base_url,
|
||||
user_token,
|
||||
deployment_key.map(|s| s.to_owned()),
|
||||
credentials,
|
||||
attribution,
|
||||
http_client,
|
||||
);
|
||||
|
||||
let signed = storage_client
|
||||
.upload_bytes_signed(object_path, content, content_type)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to upload via signed URL: {} (path: {})",
|
||||
proxy_base_url, object_path
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(format!("gs://{}/{}", signed.bucket, signed.path))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{TraceExportConfig, UploadMethod};
|
||||
|
||||
fn proxy_config() -> TraceExportConfig {
|
||||
proxy_config_with_url("https://proxy.example.com/v1".to_string())
|
||||
}
|
||||
|
||||
fn proxy_config_with_url(base_url: String) -> TraceExportConfig {
|
||||
TraceExportConfig {
|
||||
bucket_url: None,
|
||||
service_account_key: None,
|
||||
upload_method: UploadMethod::Proxy {
|
||||
proxy_base_url: base_url,
|
||||
user_token: "tok".to_string(),
|
||||
deployment_key: None,
|
||||
alpha_test_key: None,
|
||||
},
|
||||
prefix_dir: None,
|
||||
gcs_prefix: None,
|
||||
absolute_paths: false,
|
||||
archive_name_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_config() -> TraceExportConfig {
|
||||
TraceExportConfig {
|
||||
bucket_url: Some("gs://test-bucket".to_string()),
|
||||
service_account_key: None,
|
||||
upload_method: UploadMethod::Direct {
|
||||
service_account_key: None,
|
||||
},
|
||||
prefix_dir: None,
|
||||
gcs_prefix: None,
|
||||
absolute_paths: false,
|
||||
archive_name_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_threshold_is_50mb() {
|
||||
assert_eq!(
|
||||
MULTIPART_UPLOAD_THRESHOLD,
|
||||
50 * 1024 * 1024,
|
||||
"Multipart threshold must be 50 MB to match the plan and repo_changes.rs"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_file_proxy_missing_file_returns_error() {
|
||||
// upload_file_via_proxy checks metadata before connecting — should fail
|
||||
// fast with a descriptive error if the temp file was deleted mid-flight.
|
||||
let config = proxy_config();
|
||||
let result = upload_file(
|
||||
&config,
|
||||
"session/turn_0/test.bin",
|
||||
std::path::Path::new("/tmp/nonexistent_upload_queue_test_file"),
|
||||
"application/octet-stream",
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err(), "Should error for missing file");
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("metadata") || err.contains("No such file"),
|
||||
"Error should mention file metadata: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_file_direct_missing_file_returns_error() {
|
||||
// Direct mode tries to authenticate first — bucket URL parse should succeed,
|
||||
// but the file open will fail later. We only care it returns an error, not panics.
|
||||
let config = direct_config();
|
||||
let result = upload_file(
|
||||
&config,
|
||||
"session/turn_0/test.bin",
|
||||
std::path::Path::new("/tmp/nonexistent_upload_queue_test_file"),
|
||||
"application/octet-stream",
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err(), "Should error for missing file");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_file_direct_invalid_scheme_returns_error() {
|
||||
// Verify that a non-gs:// URL is rejected before any I/O.
|
||||
let config = TraceExportConfig {
|
||||
bucket_url: Some("https://not-a-gcs-url.example.com".to_string()),
|
||||
service_account_key: None,
|
||||
upload_method: UploadMethod::Direct {
|
||||
service_account_key: None,
|
||||
},
|
||||
prefix_dir: None,
|
||||
gcs_prefix: None,
|
||||
absolute_paths: false,
|
||||
archive_name_override: None,
|
||||
};
|
||||
let result = upload_file(
|
||||
&config,
|
||||
"path/test.bin",
|
||||
std::path::Path::new("/tmp/file"),
|
||||
"application/octet-stream",
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().to_string().contains("gs"),
|
||||
"Error should mention expected scheme"
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared state for the dispatch test server, tracking which endpoints were hit.
|
||||
#[derive(Clone, Default)]
|
||||
struct DispatchState {
|
||||
multipart_called: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
storage_called: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
/// Start a minimal axum server (with proper State extractors) that records
|
||||
/// which upload routes were hit. Uses the same State extractor pattern as
|
||||
/// storage_client_tests.rs to ensure reliable flag updates in Bazel CI.
|
||||
///
|
||||
/// Returns (addr, state) where state.multipart_called / state.storage_called
|
||||
/// are set to true when the respective route is hit.
|
||||
async fn start_dispatch_test_server() -> (std::net::SocketAddr, DispatchState) {
|
||||
use axum::{
|
||||
Router, body::Body, extract::State, http::StatusCode, response::IntoResponse,
|
||||
routing::post,
|
||||
};
|
||||
use std::sync::atomic::Ordering;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let state = DispatchState::default();
|
||||
|
||||
async fn multipart_handler(
|
||||
State(s): State<DispatchState>,
|
||||
_body: Body,
|
||||
) -> impl IntoResponse {
|
||||
s.multipart_called.store(true, Ordering::SeqCst);
|
||||
// 400 = non-retryable: client fails fast without backoff delays
|
||||
(StatusCode::BAD_REQUEST, r#"{"error":"test"}"#)
|
||||
}
|
||||
|
||||
async fn storage_handler(State(s): State<DispatchState>, _body: Body) -> impl IntoResponse {
|
||||
s.storage_called.store(true, Ordering::SeqCst);
|
||||
(StatusCode::BAD_REQUEST, r#"{"error":"test"}"#)
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route("/v1/storage/multipart/init", post(multipart_handler))
|
||||
.route("/v1/storage", post(storage_handler))
|
||||
.with_state(state.clone());
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
// Give the server 50ms to bind and accept — more headroom for Bazel CI sandboxing.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
|
||||
(addr, state)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_file_via_proxy_uses_multipart_for_large_files() {
|
||||
// Large file (just over 50 MB threshold) should hit the multipart init endpoint.
|
||||
// Uses set_len() to create a sparse file — no actual disk write.
|
||||
let (addr, state) = start_dispatch_test_server().await;
|
||||
let config = proxy_config_with_url(format!("http://{}/v1", addr));
|
||||
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let large_file = temp.path().join("large.bin");
|
||||
let f = std::fs::File::create(&large_file).unwrap();
|
||||
f.set_len(MULTIPART_UPLOAD_THRESHOLD + 1).unwrap(); // sparse file, no actual disk write
|
||||
|
||||
let _ = upload_file(
|
||||
&config,
|
||||
"session/turn_0/large.bin",
|
||||
&large_file,
|
||||
"application/octet-stream",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
state
|
||||
.multipart_called
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
"File > 50MB should use multipart upload"
|
||||
);
|
||||
assert!(
|
||||
!state
|
||||
.storage_called
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
"File > 50MB should NOT use the simple storage endpoint"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_file_via_proxy_uses_streaming_for_small_files() {
|
||||
// Small file (1 KB) should hit the simple storage endpoint, not multipart.
|
||||
let (addr, state) = start_dispatch_test_server().await;
|
||||
let config = proxy_config_with_url(format!("http://{}/v1", addr));
|
||||
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let small_file = temp.path().join("small.bin");
|
||||
std::fs::write(&small_file, vec![0u8; 1024]).unwrap();
|
||||
|
||||
let _ = upload_file(
|
||||
&config,
|
||||
"session/turn_0/small.bin",
|
||||
&small_file,
|
||||
"application/octet-stream",
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
!state
|
||||
.multipart_called
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
"File < 50MB should NOT use multipart upload"
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.storage_called
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
"File < 50MB should use the simple storage endpoint"
|
||||
);
|
||||
}
|
||||
}
|
||||
60
crates/codegen/xai-file-utils/src/lib.rs
Normal file
60
crates/codegen/xai-file-utils/src/lib.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
#![allow(
|
||||
unused_imports,
|
||||
unused_variables,
|
||||
unused_mut,
|
||||
unreachable_code,
|
||||
dead_code
|
||||
)]
|
||||
//! Local data collection: per-turn event tracking, upload queueing, and
|
||||
//! S3-compatible blob storage.
|
||||
pub(crate) mod circuit_breaker_observer;
|
||||
/// Wrap a raw client with [`xai_grok_auth::AuthRetryMiddleware`] for automatic 401 retry.
|
||||
pub fn with_auth_retry(
|
||||
client: reqwest::Client,
|
||||
credentials: std::sync::Arc<dyn xai_grok_auth::AuthCredentialProvider>,
|
||||
) -> reqwest_middleware::ClientWithMiddleware {
|
||||
reqwest_middleware::ClientBuilder::new(client)
|
||||
.with(xai_grok_auth::AuthRetryMiddleware::new(credentials, 1))
|
||||
.build()
|
||||
}
|
||||
pub mod events;
|
||||
pub mod gcs;
|
||||
pub mod queue;
|
||||
pub mod s3;
|
||||
pub mod storage_client;
|
||||
pub mod trace_context;
|
||||
pub mod upload_config;
|
||||
pub mod workspace_classifier;
|
||||
pub use upload_config::*;
|
||||
/// Compute SHA256 hash of content as a hex string.
|
||||
pub fn sha256_hex(content: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(content);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
/// Compute SHA256 hash of a file by streaming, without loading entire file into memory.
|
||||
/// If `max_bytes` is set (> 0), only hash up to that many bytes.
|
||||
pub fn sha256_hex_from_file(
|
||||
path: &std::path::Path,
|
||||
max_bytes: Option<u64>,
|
||||
) -> std::io::Result<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
let file = std::fs::File::open(path)?;
|
||||
let mut reader: Box<dyn Read> = if let Some(limit) = max_bytes {
|
||||
Box::new(file.take(limit))
|
||||
} else {
|
||||
Box::new(file)
|
||||
};
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 8192];
|
||||
loop {
|
||||
let bytes_read = reader.read(&mut buffer)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..bytes_read]);
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
6433
crates/codegen/xai-file-utils/src/queue.rs
Normal file
6433
crates/codegen/xai-file-utils/src/queue.rs
Normal file
File diff suppressed because it is too large
Load diff
1540
crates/codegen/xai-file-utils/src/s3.rs
Normal file
1540
crates/codegen/xai-file-utils/src/s3.rs
Normal file
File diff suppressed because it is too large
Load diff
3462
crates/codegen/xai-file-utils/src/storage_client.rs
Normal file
3462
crates/codegen/xai-file-utils/src/storage_client.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,427 @@
|
|||
//! Axum-mock integration tests for [`crate::storage_client::StorageClient`]'s circuit breaker integration.
|
||||
|
||||
use super::{HttpUploadError, StorageClient, storage_breaker_config};
|
||||
use axum::{Router, response::IntoResponse, routing::post};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use xai_circuit_breaker::{BreakerState, Observer, Outcome};
|
||||
|
||||
/// Read the threshold straight from the preset so the test stays
|
||||
/// in lock-step with `BreakerConfig::client()` if it ever changes.
|
||||
fn storage_breaker_min_samples() -> u32 {
|
||||
storage_breaker_config().min_samples as u32
|
||||
}
|
||||
|
||||
// 200 ms margin between cool-down and sleep keeps the timing
|
||||
// tests stable on contended CI.
|
||||
const TEST_OPEN_DURATION: Duration = Duration::from_millis(50);
|
||||
const SLEEP_PAST_OPEN_DURATION: Duration = Duration::from_millis(250);
|
||||
|
||||
async fn start_server(router: Router) -> (SocketAddr, tokio::task::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let handle = tokio::spawn(async move {
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
});
|
||||
(addr, handle)
|
||||
}
|
||||
|
||||
fn counted_handler(
|
||||
status: axum::http::StatusCode,
|
||||
counter: Arc<AtomicU32>,
|
||||
) -> impl Fn() -> futures::future::Ready<axum::response::Response> + Clone {
|
||||
move || {
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
futures::future::ready((status, "").into_response())
|
||||
}
|
||||
}
|
||||
|
||||
fn client_short_open_duration(addr: SocketAddr, open_duration: Duration) -> StorageClient {
|
||||
StorageClient::new(&format!("http://{addr}/v1"), "test-token")
|
||||
.with_breaker_open_duration(open_duration)
|
||||
}
|
||||
|
||||
async fn trip_breaker(client: &StorageClient) {
|
||||
let min_samples = storage_breaker_min_samples();
|
||||
for _ in 0..min_samples {
|
||||
let _ = client.upload("p", b"d", "text/plain").await;
|
||||
}
|
||||
assert!(
|
||||
client.storage_breaker_is_open(),
|
||||
"breaker must open after {min_samples} 401s",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn breaker_opens_after_threshold_401s() {
|
||||
let hits = Arc::new(AtomicU32::new(0));
|
||||
let router = Router::new().route(
|
||||
"/v1/storage",
|
||||
post(counted_handler(
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
hits.clone(),
|
||||
)),
|
||||
);
|
||||
let (addr, _) = start_server(router).await;
|
||||
let client = client_short_open_duration(addr, Duration::from_secs(60));
|
||||
|
||||
for _ in 0..storage_breaker_min_samples() {
|
||||
let err = client
|
||||
.upload("p", b"d", "text/plain")
|
||||
.await
|
||||
.expect_err("401 must surface as Err");
|
||||
let http_err = err
|
||||
.downcast_ref::<HttpUploadError>()
|
||||
.expect("err must be HttpUploadError");
|
||||
assert_eq!(http_err.status_code, 401);
|
||||
}
|
||||
assert_eq!(hits.load(Ordering::Relaxed), storage_breaker_min_samples());
|
||||
assert!(client.storage_breaker_is_open());
|
||||
|
||||
// Subsequent calls must short-circuit.
|
||||
for _ in 0..10 {
|
||||
let err = client
|
||||
.upload("p", b"d", "text/plain")
|
||||
.await
|
||||
.expect_err("breaker-open must short-circuit as Err");
|
||||
let http_err = err
|
||||
.downcast_ref::<HttpUploadError>()
|
||||
.expect("short-circuit err must be HttpUploadError");
|
||||
assert_eq!(http_err.status_code, 503);
|
||||
assert!(
|
||||
http_err.message.contains("circuit breaker open"),
|
||||
"short-circuit message must mention circuit breaker, got: {}",
|
||||
http_err.message
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
hits.load(Ordering::Relaxed),
|
||||
storage_breaker_min_samples(),
|
||||
"server must not see any post-trip requests"
|
||||
);
|
||||
}
|
||||
|
||||
/// Sliding-window sanity: a 200/401 mix below the failure-rate
|
||||
/// threshold must NOT trip, even with enough samples to satisfy
|
||||
/// `min_samples`. With `client()` preset (min_samples=5,
|
||||
/// error_rate_threshold=0.5), 6 × 200 + 4 × 401 = 10 samples,
|
||||
/// rate = 0.4 < 0.5 → still closed. The successes lead so the
|
||||
/// partial rate never crosses 0.5 once `min_samples` is reached.
|
||||
#[tokio::test]
|
||||
async fn sliding_window_below_threshold_does_not_trip() {
|
||||
let hits = Arc::new(AtomicU32::new(0));
|
||||
let hits_handler = hits.clone();
|
||||
let router = Router::new().route(
|
||||
"/v1/storage",
|
||||
post(move || {
|
||||
let n = hits_handler.fetch_add(1, Ordering::Relaxed);
|
||||
async move {
|
||||
if n < 6 {
|
||||
axum::Json(serde_json::json!({
|
||||
"bucket": "b",
|
||||
"path": "p",
|
||||
"size": 1,
|
||||
"content_type": "text/plain",
|
||||
"generation": 1
|
||||
}))
|
||||
.into_response()
|
||||
} else {
|
||||
(axum::http::StatusCode::UNAUTHORIZED, "").into_response()
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
let (addr, _) = start_server(router).await;
|
||||
let client = client_short_open_duration(addr, Duration::from_secs(60));
|
||||
|
||||
for _ in 0..10 {
|
||||
let _ = client.upload("p", b"d", "text/plain").await;
|
||||
}
|
||||
assert!(!client.storage_breaker_is_open());
|
||||
assert_eq!(hits.load(Ordering::Relaxed), 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn breaker_half_open_after_cool_down_success() {
|
||||
// Server: first N requests 401 (trip), rest 200 (probe).
|
||||
let hits = Arc::new(AtomicU32::new(0));
|
||||
let hits_handler = hits.clone();
|
||||
let router = Router::new().route(
|
||||
"/v1/storage",
|
||||
post(move || {
|
||||
let n = hits_handler.fetch_add(1, Ordering::Relaxed);
|
||||
async move {
|
||||
if n < storage_breaker_min_samples() {
|
||||
(axum::http::StatusCode::UNAUTHORIZED, "").into_response()
|
||||
} else {
|
||||
axum::Json(serde_json::json!({
|
||||
"bucket": "b",
|
||||
"path": "p",
|
||||
"size": 1,
|
||||
"content_type": "text/plain",
|
||||
"generation": 1
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
let (addr, _) = start_server(router).await;
|
||||
let client = client_short_open_duration(addr, TEST_OPEN_DURATION);
|
||||
|
||||
trip_breaker(&client).await;
|
||||
|
||||
// Within cool-down: short-circuit.
|
||||
let _ = client.upload("p", b"d", "text/plain").await;
|
||||
assert_eq!(hits.load(Ordering::Relaxed), storage_breaker_min_samples());
|
||||
|
||||
// Past cool-down: exactly one probe reaches the server.
|
||||
tokio::time::sleep(SLEEP_PAST_OPEN_DURATION).await;
|
||||
let probe = client.upload("p", b"d", "text/plain").await;
|
||||
assert!(probe.is_ok());
|
||||
assert_eq!(
|
||||
hits.load(Ordering::Relaxed),
|
||||
storage_breaker_min_samples() + 1
|
||||
);
|
||||
assert!(!client.storage_breaker_is_open());
|
||||
|
||||
// Closed: subsequent calls go through normally.
|
||||
let _ = client.upload("p", b"d", "text/plain").await;
|
||||
assert_eq!(
|
||||
hits.load(Ordering::Relaxed),
|
||||
storage_breaker_min_samples() + 2
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn breaker_half_open_after_cool_down_failure_reopens() {
|
||||
let hits = Arc::new(AtomicU32::new(0));
|
||||
let router = Router::new().route(
|
||||
"/v1/storage",
|
||||
post(counted_handler(
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
hits.clone(),
|
||||
)),
|
||||
);
|
||||
let (addr, _) = start_server(router).await;
|
||||
let client = client_short_open_duration(addr, TEST_OPEN_DURATION);
|
||||
|
||||
trip_breaker(&client).await;
|
||||
let baseline = hits.load(Ordering::Relaxed);
|
||||
|
||||
// Past cool-down: probe 401s.
|
||||
tokio::time::sleep(SLEEP_PAST_OPEN_DURATION).await;
|
||||
let probe = client.upload("p", b"d", "text/plain").await;
|
||||
assert!(probe.is_err());
|
||||
assert_eq!(hits.load(Ordering::Relaxed), baseline + 1);
|
||||
assert!(client.storage_breaker_is_open());
|
||||
|
||||
// Restarted cool-down: next call short-circuits.
|
||||
let after_probe = hits.load(Ordering::Relaxed);
|
||||
let _ = client.upload("p", b"d", "text/plain").await;
|
||||
assert_eq!(hits.load(Ordering::Relaxed), after_probe);
|
||||
}
|
||||
|
||||
/// Breaker-open short-circuits surface `HttpUploadError { status_code: 503, .. }`
|
||||
/// so they classify as retryable (retry with backoff) rather than as an auth
|
||||
/// 401, keeping them distinct from the wire-401 path.
|
||||
#[tokio::test]
|
||||
async fn breaker_short_circuit_returns_http_upload_error_503() {
|
||||
let hits = Arc::new(AtomicU32::new(0));
|
||||
let router = Router::new().route(
|
||||
"/v1/storage",
|
||||
post(counted_handler(
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
hits.clone(),
|
||||
)),
|
||||
);
|
||||
let (addr, _) = start_server(router).await;
|
||||
let client = client_short_open_duration(addr, Duration::from_secs(60));
|
||||
|
||||
trip_breaker(&client).await;
|
||||
|
||||
let err = client
|
||||
.upload("p", b"d", "text/plain")
|
||||
.await
|
||||
.expect_err("short-circuit must Err");
|
||||
let http_err = err
|
||||
.downcast_ref::<HttpUploadError>()
|
||||
.expect("short-circuit err must be HttpUploadError");
|
||||
assert_eq!(http_err.status_code, 503);
|
||||
assert!(http_err.message.contains("circuit breaker open"));
|
||||
}
|
||||
|
||||
/// Concurrent half-open probes: N simultaneous `upload`s past the
|
||||
/// cool-down must collapse into exactly ONE wire request. The
|
||||
/// probe is held on a `Notify` so the breaker can't close before
|
||||
/// the lagging callers race through `breaker.check()`.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn breaker_half_open_serialises_concurrent_probes() {
|
||||
let hits = Arc::new(AtomicU32::new(0));
|
||||
let probe_gate = Arc::new(tokio::sync::Notify::new());
|
||||
let probe_started = Arc::new(tokio::sync::Notify::new());
|
||||
|
||||
let hits_handler = hits.clone();
|
||||
let probe_gate_handler = probe_gate.clone();
|
||||
let probe_started_handler = probe_started.clone();
|
||||
let router = Router::new().route(
|
||||
"/v1/storage",
|
||||
post(move || {
|
||||
let hits = hits_handler.clone();
|
||||
let gate = probe_gate_handler.clone();
|
||||
let started = probe_started_handler.clone();
|
||||
async move {
|
||||
let n = hits.fetch_add(1, Ordering::Relaxed);
|
||||
if n < storage_breaker_min_samples() {
|
||||
(axum::http::StatusCode::UNAUTHORIZED, "").into_response()
|
||||
} else {
|
||||
// Park the probe until the test releases it.
|
||||
started.notify_one();
|
||||
gate.notified().await;
|
||||
axum::Json(serde_json::json!({
|
||||
"bucket": "b",
|
||||
"path": "p",
|
||||
"size": 1,
|
||||
"content_type": "text/plain",
|
||||
"generation": 1
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
let (addr, _) = start_server(router).await;
|
||||
let client = client_short_open_duration(addr, TEST_OPEN_DURATION);
|
||||
|
||||
trip_breaker(&client).await;
|
||||
let hits_after_trip = hits.load(Ordering::Relaxed);
|
||||
assert_eq!(hits_after_trip, storage_breaker_min_samples());
|
||||
|
||||
tokio::time::sleep(SLEEP_PAST_OPEN_DURATION).await;
|
||||
|
||||
const N: usize = 16;
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(N));
|
||||
// `laggers_done` fires exactly once, when the (N-1) callers
|
||||
// that did NOT win the probe slot have surfaced from
|
||||
// `upload()` with a short-circuited `Err`. The probe task is
|
||||
// still parked in the server handler at that point, so it
|
||||
// has NOT yet been counted. Replacing the previous
|
||||
// 100 ms wall-clock sleep removes the CI-flake window where
|
||||
// a slow lagger could race the probe-gate release.
|
||||
let laggers_done = Arc::new(tokio::sync::Notify::new());
|
||||
let laggers_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let mut tasks = Vec::with_capacity(N);
|
||||
for _ in 0..N {
|
||||
let c = client.clone();
|
||||
let b = barrier.clone();
|
||||
let done = laggers_done.clone();
|
||||
let count = laggers_count.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
b.wait().await;
|
||||
let result = c.upload("p", b"d", "text/plain").await;
|
||||
let prev = count.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
|
||||
if prev + 1 == N - 1 {
|
||||
done.notify_one();
|
||||
}
|
||||
result
|
||||
}));
|
||||
}
|
||||
|
||||
// Hold the probe until (a) the probe has reached the server,
|
||||
// and (b) all N-1 lagging callers have raced through
|
||||
// `breaker.check()` and short-circuited. Only then release
|
||||
// the gate so the probe can return 200 and close the breaker.
|
||||
probe_started.notified().await;
|
||||
laggers_done.notified().await;
|
||||
probe_gate.notify_one();
|
||||
|
||||
for t in tasks {
|
||||
let _ = t.await.unwrap();
|
||||
}
|
||||
|
||||
let probes = hits.load(Ordering::Relaxed) - hits_after_trip;
|
||||
assert_eq!(probes, 1, "exactly one probe must escape, saw {probes}");
|
||||
assert!(!client.storage_breaker_is_open());
|
||||
}
|
||||
|
||||
/// Recording observer used to verify exactly one Open transition
|
||||
/// and one Open→Closed close-via-probe transition.
|
||||
#[derive(Default)]
|
||||
struct RecordingObserver {
|
||||
transitions: Mutex<Vec<(BreakerState, BreakerState)>>,
|
||||
}
|
||||
|
||||
impl Observer for RecordingObserver {
|
||||
fn on_state_change(&self, old: BreakerState, new: BreakerState, _reason: &str) {
|
||||
self.transitions
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push((old, new));
|
||||
}
|
||||
fn on_probe_admission(&self, _allowed: bool) {}
|
||||
fn on_outcome(&self, _outcome: Outcome, _state: BreakerState) {}
|
||||
}
|
||||
|
||||
/// Exactly one open transition per open and one close-via-probe
|
||||
/// transition per close, even when many wire-401s arrive while the
|
||||
/// breaker is already open.
|
||||
#[tokio::test]
|
||||
async fn breaker_emits_exactly_one_warn_on_open_and_one_info_on_close() {
|
||||
let hits = Arc::new(AtomicU32::new(0));
|
||||
let hits_handler = hits.clone();
|
||||
let router = Router::new().route(
|
||||
"/v1/storage",
|
||||
post(move || {
|
||||
let n = hits_handler.fetch_add(1, Ordering::Relaxed);
|
||||
async move {
|
||||
if n < storage_breaker_min_samples() {
|
||||
(axum::http::StatusCode::UNAUTHORIZED, "").into_response()
|
||||
} else {
|
||||
axum::Json(serde_json::json!({
|
||||
"bucket": "b",
|
||||
"path": "p",
|
||||
"size": 1,
|
||||
"content_type": "text/plain",
|
||||
"generation": 1
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
let (addr, _) = start_server(router).await;
|
||||
let observer = Arc::new(RecordingObserver::default());
|
||||
let client = StorageClient::new(&format!("http://{addr}/v1"), "test-token")
|
||||
.with_breaker_for_testing(TEST_OPEN_DURATION, observer.clone());
|
||||
|
||||
// 10 attempts: 5 wire 401s open the breaker, 5 short-circuit.
|
||||
// Only ONE Closed→Open transition must fire.
|
||||
for _ in 0..10 {
|
||||
let _ = client.upload("p", b"d", "text/plain").await;
|
||||
}
|
||||
assert!(client.storage_breaker_is_open());
|
||||
|
||||
// Half-open probe → success → close → one HalfOpen→Closed transition.
|
||||
tokio::time::sleep(SLEEP_PAST_OPEN_DURATION).await;
|
||||
let probe = client.upload("p", b"d", "text/plain").await;
|
||||
assert!(probe.is_ok());
|
||||
|
||||
let transitions = observer.transitions.lock().unwrap();
|
||||
let to_open = transitions
|
||||
.iter()
|
||||
.filter(|(_, to)| *to == BreakerState::Open)
|
||||
.count();
|
||||
let to_closed = transitions
|
||||
.iter()
|
||||
.filter(|(from, to)| *from == BreakerState::HalfOpen && *to == BreakerState::Closed)
|
||||
.count();
|
||||
assert_eq!(to_open, 1, "exactly one open transition, saw {to_open}");
|
||||
assert_eq!(
|
||||
to_closed, 1,
|
||||
"exactly one close-via-probe transition, saw {to_closed}"
|
||||
);
|
||||
}
|
||||
316
crates/codegen/xai-file-utils/src/trace_context.rs
Normal file
316
crates/codegen/xai-file-utils/src/trace_context.rs
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
use opentelemetry::global;
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
/// Extract the current span's W3C `traceparent` string for propagation
|
||||
/// across channel/task boundaries where span context is lost.
|
||||
pub fn current_traceparent() -> Option<String> {
|
||||
let current_span = tracing::Span::current();
|
||||
if current_span.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let cx = current_span.context();
|
||||
let mut carrier = std::collections::HashMap::new();
|
||||
global::get_text_map_propagator(|p| {
|
||||
p.inject_context(&cx, &mut carrier);
|
||||
});
|
||||
|
||||
carrier.remove("traceparent")
|
||||
}
|
||||
|
||||
pub fn inject_trace_context_into_request(
|
||||
mut builder: reqwest::RequestBuilder,
|
||||
) -> reqwest::RequestBuilder {
|
||||
let mut headers = HeaderMap::new();
|
||||
inject_trace_context(&mut headers);
|
||||
|
||||
// Insert new headers into the request builder
|
||||
for (name, value) in headers.iter() {
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
|
||||
builder
|
||||
}
|
||||
|
||||
/// Return trace-context headers (traceparent, tracestate) for the current
|
||||
/// span. Used by callers that hold a `reqwest_middleware::RequestBuilder`
|
||||
/// (which is a different type from `reqwest::RequestBuilder`).
|
||||
pub(crate) fn trace_context_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
inject_trace_context(&mut headers);
|
||||
headers
|
||||
}
|
||||
|
||||
pub(crate) fn inject_trace_context(headers: &mut HeaderMap) {
|
||||
// Prefer the context from the current tracing span (set by OpenTelemetryLayer).
|
||||
// Fall back to opentelemetry::Context::current() (thread-local) for code paths
|
||||
// that run outside a tracing span but on a thread that has an attached OTel context
|
||||
// (e.g. tasks created via spawn_local that inherit the thread-local context).
|
||||
let current_span = tracing::Span::current();
|
||||
let cx = if current_span.is_none() {
|
||||
opentelemetry::Context::current()
|
||||
} else {
|
||||
current_span.context()
|
||||
};
|
||||
|
||||
global::get_text_map_propagator(|propagator| {
|
||||
propagator.inject_context(&cx, &mut HeaderMapInjector(headers));
|
||||
});
|
||||
}
|
||||
|
||||
struct HeaderMapInjector<'a>(&'a mut HeaderMap);
|
||||
|
||||
impl opentelemetry::propagation::Injector for HeaderMapInjector<'_> {
|
||||
fn set(&mut self, key: &str, value: String) {
|
||||
match (HeaderName::try_from(key), HeaderValue::try_from(&value)) {
|
||||
(Ok(name), Ok(val)) => {
|
||||
self.0.insert(name, val);
|
||||
}
|
||||
(Err(e), _) => {
|
||||
tracing::debug!("Invalid header name '{}': {}", key, e);
|
||||
}
|
||||
(_, Err(e)) => {
|
||||
tracing::debug!("Invalid header value for '{}': {}", key, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a tracing span parented to `_meta.traceparent`.
|
||||
/// Used as a callback for `with_on_meta` in ACP session/server builders.
|
||||
pub fn span_from_meta_traceparent(
|
||||
meta: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> tracing::Span {
|
||||
let span = tracing::info_span!("acp_dispatch");
|
||||
if let Some(ctx) = meta
|
||||
.get("traceparent")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(extract_context)
|
||||
{
|
||||
let _ = span.set_parent(ctx);
|
||||
}
|
||||
span
|
||||
}
|
||||
|
||||
/// Link the current span to a W3C `traceparent` carried inside a JSON `_meta`
|
||||
/// (or top-level) object. Call this at the top of a `#[tracing::instrument]`
|
||||
/// function so the span created by the macro becomes a child of the client's
|
||||
/// distributed trace.
|
||||
pub fn link_current_span_to_meta(meta: &serde_json::Value) {
|
||||
if let Some(ctx) = meta
|
||||
.get("traceparent")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(extract_context)
|
||||
{
|
||||
let _ = tracing::Span::current().set_parent(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_context(traceparent: &str) -> Option<opentelemetry::Context> {
|
||||
use opentelemetry::trace::TraceContextExt;
|
||||
|
||||
let mut carrier = std::collections::HashMap::new();
|
||||
carrier.insert("traceparent".to_string(), traceparent.to_string());
|
||||
|
||||
let ctx = opentelemetry::global::get_text_map_propagator(|p| p.extract(&carrier));
|
||||
ctx.span().span_context().is_valid().then_some(ctx)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_inject_trace_context_no_active_span() {
|
||||
// When there's no active span, no headers should be added
|
||||
let mut headers = HeaderMap::new();
|
||||
inject_trace_context(&mut headers);
|
||||
|
||||
// Without an active OpenTelemetry span, no traceparent header is added
|
||||
// (the propagator only injects if there's a valid span context)
|
||||
assert!(headers.get("traceparent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_map_injector_valid_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
{
|
||||
let mut injector = HeaderMapInjector(&mut headers);
|
||||
opentelemetry::propagation::Injector::set(
|
||||
&mut injector,
|
||||
"traceparent",
|
||||
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
headers.get("traceparent").map(|v| v.to_str().unwrap()),
|
||||
Some("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_map_injector_invalid_header_name() {
|
||||
let mut headers = HeaderMap::new();
|
||||
{
|
||||
let mut injector = HeaderMapInjector(&mut headers);
|
||||
// Invalid header name (contains space) should be silently ignored
|
||||
opentelemetry::propagation::Injector::set(
|
||||
&mut injector,
|
||||
"invalid header",
|
||||
"value".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
assert!(headers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_header_map_injector_invalid_header_value() {
|
||||
let mut headers = HeaderMap::new();
|
||||
{
|
||||
let mut injector = HeaderMapInjector(&mut headers);
|
||||
// Invalid header value (contains non-visible ASCII) should be silently ignored
|
||||
opentelemetry::propagation::Injector::set(
|
||||
&mut injector,
|
||||
"traceparent",
|
||||
"invalid\x00value".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
assert!(headers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_context_rejects_invalid_traceparent() {
|
||||
assert!(extract_context("not-a-valid-traceparent").is_none());
|
||||
assert!(extract_context("").is_none());
|
||||
}
|
||||
|
||||
/// E2E: _meta.traceparent -> link_current_span_to_meta -> current span
|
||||
/// -> inject_trace_context_into_request -> outbound HTTP header carries same traceId.
|
||||
#[test]
|
||||
fn test_link_meta_then_inject_propagates_trace_id() {
|
||||
use opentelemetry::trace::TracerProvider as _;
|
||||
use opentelemetry_sdk::propagation::TraceContextPropagator;
|
||||
use opentelemetry_sdk::trace::SdkTracerProvider;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
|
||||
|
||||
let provider = SdkTracerProvider::builder().build();
|
||||
let tracer = provider.tracer("test");
|
||||
let otel_layer = tracing_opentelemetry::layer()
|
||||
.with_tracer(tracer)
|
||||
.with_context_activation(false);
|
||||
let subscriber = tracing_subscriber::Registry::default().with(otel_layer);
|
||||
let _subscriber_guard = tracing::subscriber::set_default(subscriber);
|
||||
|
||||
let browser_trace_id = "0af7651916cd43dd8448eb211c80319c";
|
||||
let meta = serde_json::json!({
|
||||
"traceparent": format!("00-{browser_trace_id}-b7ad6b7169203331-01"),
|
||||
});
|
||||
|
||||
let span = tracing::info_span!("test_span");
|
||||
let _entered = span.enter();
|
||||
link_current_span_to_meta(&meta);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let builder = client.get("https://cli-chat-proxy.example.com/v1/chat/completions");
|
||||
let builder = inject_trace_context_into_request(builder);
|
||||
let request = builder.build().expect("Failed to build request");
|
||||
|
||||
let traceparent = request
|
||||
.headers()
|
||||
.get("traceparent")
|
||||
.expect("traceparent header missing")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
traceparent.starts_with(&format!("00-{browser_trace_id}-")),
|
||||
"outbound traceId should match browser's. got: {traceparent}"
|
||||
);
|
||||
assert!(
|
||||
traceparent.ends_with("-01"),
|
||||
"sampled flag should be set. got: {traceparent}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_trace_context_into_request_preserves_existing_headers() {
|
||||
use opentelemetry::trace::{
|
||||
SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState,
|
||||
};
|
||||
use opentelemetry_sdk::propagation::TraceContextPropagator;
|
||||
|
||||
// Initialize the global text map propagator for this test
|
||||
// This is necessary because by default the global propagator is a no-op
|
||||
opentelemetry::global::set_text_map_propagator(TraceContextPropagator::new());
|
||||
|
||||
// Create a valid span context with known trace_id and span_id
|
||||
let trace_id = TraceId::from_hex("0af7651916cd43dd8448eb211c80319c").unwrap();
|
||||
let span_id = SpanId::from_hex("b7ad6b7169203331").unwrap();
|
||||
let span_context = SpanContext::new(
|
||||
trace_id,
|
||||
span_id,
|
||||
TraceFlags::SAMPLED,
|
||||
true, // is_remote
|
||||
TraceState::default(),
|
||||
);
|
||||
|
||||
// Create a context with this span context attached
|
||||
let cx = opentelemetry::Context::current().with_remote_span_context(span_context);
|
||||
let _guard = cx.attach();
|
||||
|
||||
// Create a client and request builder with existing headers
|
||||
let client = reqwest::Client::new();
|
||||
let builder = client
|
||||
.get("https://example.com")
|
||||
.header("x-custom-header", "custom-value")
|
||||
.header("authorization", "Bearer token123");
|
||||
|
||||
// Inject trace context into the request
|
||||
let builder = inject_trace_context_into_request(builder);
|
||||
let request = builder.build().expect("Failed to build request");
|
||||
let headers = request.headers();
|
||||
|
||||
// Verify existing headers are preserved
|
||||
assert_eq!(
|
||||
headers.get("x-custom-header").map(|v| v.to_str().unwrap()),
|
||||
Some("custom-value"),
|
||||
"Custom header should be preserved after injecting trace context"
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("authorization").map(|v| v.to_str().unwrap()),
|
||||
Some("Bearer token123"),
|
||||
"Authorization header should be preserved after injecting trace context"
|
||||
);
|
||||
|
||||
// Verify the traceparent header was injected with correct trace context
|
||||
let traceparent = headers
|
||||
.get("traceparent")
|
||||
.expect("traceparent header should be present with active span")
|
||||
.to_str()
|
||||
.unwrap();
|
||||
|
||||
// traceparent format: {version}-{trace-id}-{parent-id}-{trace-flags}
|
||||
// e.g., "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
|
||||
assert!(
|
||||
traceparent.starts_with("00-0af7651916cd43dd8448eb211c80319c-"),
|
||||
"traceparent should contain the correct trace_id, got: {}",
|
||||
traceparent
|
||||
);
|
||||
assert!(
|
||||
traceparent.contains("b7ad6b7169203331"),
|
||||
"traceparent should contain the correct span_id, got: {}",
|
||||
traceparent
|
||||
);
|
||||
assert!(
|
||||
traceparent.ends_with("-01"),
|
||||
"traceparent should have sampled flag set, got: {}",
|
||||
traceparent
|
||||
);
|
||||
}
|
||||
}
|
||||
166
crates/codegen/xai-file-utils/src/upload_config.rs
Normal file
166
crates/codegen/xai-file-utils/src/upload_config.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
//! Upload destination config and archive-restore metadata shared by the
|
||||
//! always-on upload queue and session restore paths.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Method for uploading to object storage.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum UploadMethod {
|
||||
Direct {
|
||||
service_account_key: Option<String>,
|
||||
},
|
||||
Proxy {
|
||||
proxy_base_url: String,
|
||||
user_token: String,
|
||||
deployment_key: Option<String>,
|
||||
alpha_test_key: Option<String>,
|
||||
},
|
||||
S3 {
|
||||
bucket: String,
|
||||
region: String,
|
||||
credentials_file: Option<String>,
|
||||
credentials_content: Option<String>,
|
||||
endpoint_url: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Configuration for object-storage export.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TraceExportConfig {
|
||||
pub bucket_url: Option<String>,
|
||||
pub service_account_key: Option<String>,
|
||||
pub upload_method: UploadMethod,
|
||||
pub prefix_dir: Option<String>,
|
||||
pub gcs_prefix: Option<String>,
|
||||
pub absolute_paths: bool,
|
||||
pub archive_name_override: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BlobCompression {
|
||||
#[default]
|
||||
None,
|
||||
Zstd,
|
||||
}
|
||||
|
||||
pub const SKIP_DIR_NAMES: &[&str] = &[
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
"env",
|
||||
".env",
|
||||
"target",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
".next",
|
||||
".nuxt",
|
||||
".output",
|
||||
".cache",
|
||||
".parcel-cache",
|
||||
".turbo",
|
||||
"vendor",
|
||||
"bower_components",
|
||||
".tox",
|
||||
".nox",
|
||||
".eggs",
|
||||
".idea",
|
||||
".vscode",
|
||||
".gradle",
|
||||
".dart_tool",
|
||||
"coverage",
|
||||
".nyc_output",
|
||||
"htmlcov",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
".ruff_cache",
|
||||
];
|
||||
|
||||
pub fn skip_dir_set() -> &'static std::collections::HashSet<&'static str> {
|
||||
use std::collections::HashSet;
|
||||
use std::sync::LazyLock;
|
||||
static SET: LazyLock<HashSet<&str>> =
|
||||
LazyLock::new(|| SKIP_DIR_NAMES.iter().copied().collect());
|
||||
&SET
|
||||
}
|
||||
|
||||
pub const SKIP_FILE_PATTERNS: &[&str] = &[
|
||||
"*.egg-info",
|
||||
"*.pyc",
|
||||
"*.pyo",
|
||||
"*.o",
|
||||
"*.so",
|
||||
"*.dylib",
|
||||
"*.class",
|
||||
"*.jar",
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
"*.swp",
|
||||
"*.swo",
|
||||
"*~",
|
||||
"*.iml",
|
||||
];
|
||||
|
||||
pub fn default_untracked_exclude_globs() -> Vec<String> {
|
||||
let mut globs: Vec<String> = SKIP_DIR_NAMES.iter().map(|d| format!("{d}/")).collect();
|
||||
globs.extend(SKIP_FILE_PATTERNS.iter().map(|p| p.to_string()));
|
||||
globs
|
||||
}
|
||||
|
||||
pub fn default_excludes_as_gitignore() -> String {
|
||||
default_untracked_exclude_globs().join("\n")
|
||||
}
|
||||
|
||||
pub const ARCHIVE_SCHEMA_VERSION: &str = "v2";
|
||||
pub const ARCHIVE_SCHEMA_VERSION_V3: &str = "v3";
|
||||
pub const DEDUP_GCS_PREFIX: &str = "repo_changes_dedup";
|
||||
pub const DEDUP_PATCH_SUBDIR: &str = "patches";
|
||||
pub const DEDUP_BLOB_SUBDIR: &str = "blobs";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PatchReference {
|
||||
#[serde(rename = "type")]
|
||||
pub ref_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
pub sha256: String,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileReference {
|
||||
#[serde(rename = "type")]
|
||||
pub ref_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
pub sha256: String,
|
||||
pub size_bytes: u64,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExcludedContent {
|
||||
pub path: String,
|
||||
pub reason: String,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DedupMetadata {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_archive_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub patch_references: HashMap<String, PatchReference>,
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub file_references: HashMap<String, FileReference>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub excluded: Vec<ExcludedContent>,
|
||||
}
|
||||
375
crates/codegen/xai-file-utils/src/workspace_classifier.rs
Normal file
375
crates/codegen/xai-file-utils/src/workspace_classifier.rs
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
const EXCLUDED_DIR_NAMES: &[&str] = &[
|
||||
".grok", ".cache", ".daemon", ".config", ".npm", ".cargo", ".rustup", ".vscode", ".gemini",
|
||||
".hermes", ".claude",
|
||||
];
|
||||
|
||||
fn known_os_dirs() -> Vec<PathBuf> {
|
||||
[
|
||||
dirs::desktop_dir(),
|
||||
dirs::download_dir(),
|
||||
dirs::document_dir(),
|
||||
dirs::audio_dir(),
|
||||
dirs::video_dir(),
|
||||
dirs::picture_dir(),
|
||||
dirs::public_dir(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn is_project_dir(cwd: &Path) -> bool {
|
||||
if cwd.as_os_str().is_empty() || cwd.parent().is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if cwd.ancestors().any(|p| p.join(".git").exists()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if has_excluded_component(cwd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if is_platform_system_dir(cwd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if cwd == home {
|
||||
return false;
|
||||
}
|
||||
|
||||
if is_platform_home_excluded(cwd, &home) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if known_os_dirs().iter().any(|d| cwd == d) {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn is_platform_system_dir(cwd: &Path) -> bool {
|
||||
if cwd == Path::new("/tmp")
|
||||
|| cwd.starts_with("/tmp/")
|
||||
|| cwd == Path::new("/var/tmp")
|
||||
|| cwd.starts_with("/var/tmp/")
|
||||
|| cwd.starts_with("/var/folders/")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if cwd == Path::new("/private/tmp")
|
||||
|| cwd.starts_with("/private/tmp/")
|
||||
|| cwd == Path::new("/private/var/tmp")
|
||||
|| cwd.starts_with("/private/var/tmp/")
|
||||
|| cwd.starts_with("/private/var/folders/")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
if cwd == Path::new("/root") {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_platform_system_dir(cwd: &Path) -> bool {
|
||||
if let Ok(temp) = std::env::var("TEMP").or_else(|_| std::env::var("TMP")) {
|
||||
if cwd.starts_with(&temp) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let path_lower = cwd.to_string_lossy().to_lowercase();
|
||||
if path_lower.contains("\\windows\\")
|
||||
|| path_lower.ends_with("\\windows")
|
||||
|| path_lower.contains("\\program files")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if cwd.parent().map_or(false, |p| p.parent().is_none()) && cwd.to_string_lossy().len() <= 3 {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn is_platform_home_excluded(cwd: &Path, home: &Path) -> bool {
|
||||
if cwd.starts_with(home.join("Library"))
|
||||
&& !cwd.starts_with(home.join("Library/Mobile Documents"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn is_platform_home_excluded(cwd: &Path, home: &Path) -> bool {
|
||||
let Ok(relative) = cwd.strip_prefix(home) else {
|
||||
return false;
|
||||
};
|
||||
if relative.components().count() != 1 {
|
||||
return false;
|
||||
}
|
||||
let Some(std::path::Component::Normal(name)) = relative.components().next() else {
|
||||
return false;
|
||||
};
|
||||
let name = name.to_string_lossy().to_lowercase();
|
||||
[
|
||||
"desktop",
|
||||
"downloads",
|
||||
"documents",
|
||||
"pictures",
|
||||
"music",
|
||||
"videos",
|
||||
]
|
||||
.contains(&name.as_str())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_platform_home_excluded(_cwd: &Path, _home: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn has_excluded_component(path: &Path) -> bool {
|
||||
for component in path.components() {
|
||||
if let std::path::Component::Normal(name) = component {
|
||||
let name_lower = name.to_string_lossy().to_lowercase();
|
||||
|
||||
if EXCLUDED_DIR_NAMES.contains(&name_lower.as_str()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if name_lower.starts_with(".grok-") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod posix {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn root_is_unsafe() {
|
||||
assert!(!is_project_dir(Path::new("/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmp_is_unsafe() {
|
||||
assert!(!is_project_dir(Path::new("/tmp")));
|
||||
assert!(!is_project_dir(Path::new("/tmp/scratch")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tmp_prefix_not_greedy() {
|
||||
assert!(is_project_dir(Path::new("/tmpdata/foo")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn var_folders_is_unsafe() {
|
||||
assert!(!is_project_dir(Path::new("/var/folders/ab/cd")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_project_is_safe() {
|
||||
assert!(is_project_dir(Path::new("/Users/someone/my-project/src")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_subdir_is_safe() {
|
||||
assert!(is_project_dir(Path::new("/Users/someone/my-project")));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn private_tmp_is_unsafe() {
|
||||
assert!(!is_project_dir(Path::new("/private/tmp")));
|
||||
assert!(!is_project_dir(Path::new("/private/tmp/scratch")));
|
||||
assert!(!is_project_dir(Path::new("/private/var/folders/ab/cd")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_is_unsafe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(!is_project_dir(&home.join("Library")));
|
||||
assert!(!is_project_dir(&home.join("Library/Caches")));
|
||||
assert!(!is_project_dir(&home.join("Library/Application Support")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icloud_drive_projects_are_safe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(is_project_dir(&home.join(
|
||||
"Library/Mobile Documents/com~apple~CloudDocs/Projects/my-app"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bare_root_is_unsafe() {
|
||||
assert!(!is_project_dir(Path::new("/root")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_project_is_safe() {
|
||||
assert!(is_project_dir(Path::new("/root/my-project")));
|
||||
}
|
||||
}
|
||||
|
||||
mod config_and_cache {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grok_dirs_are_unsafe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(!is_project_dir(&home.join(".grok")));
|
||||
assert!(!is_project_dir(&home.join(".grok/bin")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_prefixed_dirs_are_unsafe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(!is_project_dir(&home.join(".grok-proxy-work")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_dirs_are_unsafe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(!is_project_dir(&home.join(".cache/zoe-proc")));
|
||||
assert!(!is_project_dir(&home.join(".config/nvim")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_ai_tool_dirs_are_unsafe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(!is_project_dir(&home.join(".gemini/antigravity")));
|
||||
assert!(!is_project_dir(&home.join(".hermes/kanban")));
|
||||
assert!(!is_project_dir(&home.join(".claude/projects")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod home_and_os_dirs {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn home_is_unsafe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(!is_project_dir(&home));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_project_is_safe() {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert!(is_project_dir(&home.join("my-project")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_desktop_is_unsafe() {
|
||||
if let Some(d) = dirs::desktop_dir() {
|
||||
assert!(!is_project_dir(&d));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_project_is_safe() {
|
||||
if let Some(d) = dirs::desktop_dir() {
|
||||
assert!(is_project_dir(&d.join("my-project")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_downloads_is_unsafe() {
|
||||
if let Some(d) = dirs::download_dir() {
|
||||
assert!(!is_project_dir(&d));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_documents_is_unsafe() {
|
||||
if let Some(d) = dirs::document_dir() {
|
||||
assert!(!is_project_dir(&d));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod edge_cases {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_path_is_unsafe() {
|
||||
assert!(!is_project_dir(Path::new("")));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn unicode_paths_work() {
|
||||
assert!(is_project_dir(Path::new(
|
||||
"/Users/me/code/\u{D3F4}\u{B9AC}\u{B9C8}\u{CF13}"
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn spaces_work() {
|
||||
assert!(is_project_dir(Path::new("/Users/me/My Projects/cool app")));
|
||||
}
|
||||
}
|
||||
|
||||
mod git_detection {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn inside_git_repo_is_safe() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(tmp.path().join(".git")).unwrap();
|
||||
assert!(is_project_dir(tmp.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subdirectory_of_git_repo_is_safe() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(tmp.path().join(".git")).unwrap();
|
||||
let sub = tmp.path().join("deep/sub/dir");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
assert!(is_project_dir(&sub));
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue