Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View 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

View file

@ -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;

View file

@ -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")
);
}

View 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);
}
}

View 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,
};

View 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"
);
}
}

View 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");
}
}

View 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"
);
}
}

View 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()))
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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}"
);
}

View 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
);
}
}

View 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>,
}

View 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));
}
}
}