use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::error::{Error, Result};
use crate::providers::Providers;
use crate::query::{
Cursor, EventMatch, GetTurnsArgs, GetTurnsResponse, ListTurnsArgs, ListTurnsResponse,
SearchEventsArgs, SearchEventsResponse,
};
use crate::types::*;
use crate::watch::WatchBuilder;
#[derive(Default)]
pub struct ClientBuilder {
path: Option<PathBuf>,
}
impl ClientBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn path(mut self, path: impl Into<PathBuf>) -> Self {
self.path = Some(path.into());
self
}
pub async fn connect(self) -> Result<Client> {
let path = self.resolve_path()?;
let runtime = agtrace_runtime::AgTrace::connect_or_create(path)
.await
.map_err(Error::Runtime)?;
Ok(Client {
inner: Arc::new(runtime),
})
}
fn resolve_path(&self) -> Result<PathBuf> {
let explicit_path = self.path.as_ref().and_then(|p| p.to_str());
agtrace_runtime::resolve_workspace_path(explicit_path).map_err(Error::Runtime)
}
}
#[derive(Clone)]
pub struct Client {
inner: Arc<agtrace_runtime::AgTrace>,
}
impl Client {
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
pub async fn connect_default() -> Result<Self> {
Self::builder().connect().await
}
pub async fn connect(path: impl Into<PathBuf>) -> Result<Self> {
Self::builder().path(path).connect().await
}
pub fn sessions(&self) -> SessionClient {
SessionClient {
inner: self.inner.clone(),
}
}
pub fn projects(&self) -> ProjectClient {
ProjectClient {
inner: self.inner.clone(),
}
}
pub fn watch(&self) -> WatchClient {
WatchClient {
inner: self.inner.clone(),
}
}
pub fn insights(&self) -> InsightClient {
InsightClient {
inner: self.inner.clone(),
}
}
pub fn system(&self) -> SystemClient {
SystemClient {
inner: self.inner.clone(),
}
}
pub fn watch_service(&self) -> crate::types::WatchService {
self.inner.watch_service()
}
pub fn providers(&self) -> Providers {
Providers::with_config(self.inner.config().clone())
}
}
pub struct SessionClient {
inner: Arc<agtrace_runtime::AgTrace>,
}
impl SessionClient {
pub fn list(&self, filter: SessionFilter) -> Result<Vec<SessionSummary>> {
self.inner.sessions().list(filter).map_err(Error::Runtime)
}
pub fn list_without_refresh(&self, filter: SessionFilter) -> Result<Vec<SessionSummary>> {
self.inner
.sessions()
.list_without_refresh(filter)
.map_err(Error::Runtime)
}
pub fn pack_context(
&self,
project_hash: Option<&crate::types::ProjectHash>,
limit: usize,
) -> Result<crate::types::PackResult> {
self.inner
.sessions()
.pack_context(project_hash, limit)
.map_err(Error::Runtime)
}
pub fn get(&self, id_or_prefix: &str) -> Result<SessionHandle> {
self.inner
.sessions()
.find(id_or_prefix)
.map_err(|e| Error::NotFound(format!("Session {}: {}", id_or_prefix, e)))?;
Ok(SessionHandle {
source: SessionSource::Workspace {
inner: self.inner.clone(),
id: id_or_prefix.to_string(),
},
})
}
pub fn search_events(&self, args: SearchEventsArgs) -> Result<SearchEventsResponse> {
let limit = args.limit();
let offset = args
.cursor
.as_ref()
.and_then(|c| Cursor::decode(c))
.map(|c| c.offset)
.unwrap_or(0);
let project_hash_filter = if let Some(ref root) = args.project_root {
Some(crate::utils::project_hash_from_root(root))
} else {
args.project_hash.clone().map(|h| h.into())
};
let mut filter = if let Some(hash) = project_hash_filter {
SessionFilter::project(hash).limit(1000)
} else {
SessionFilter::all().limit(1000)
};
if let Some(ref provider) = args.provider {
filter = filter.provider(provider.as_str().to_string());
}
let sessions = if let Some(ref session_id) = args.session_id {
let _handle = self.get(session_id)?;
vec![SessionSummary {
id: session_id.clone(),
provider: String::new(),
project_hash: ProjectHash::from(String::new()),
repository_hash: None,
project_root: None,
start_ts: None,
snippet: None,
parent_session_id: None,
spawned_by: None,
}]
} else {
self.list_without_refresh(filter)?
};
let mut all_matches = Vec::new();
for session_summary in sessions {
let handle = match self.get(&session_summary.id) {
Ok(h) => h,
Err(_) => continue,
};
let session = match handle.assemble() {
Ok(s) => s,
Err(_) => continue,
};
let events = match handle.events() {
Ok(e) => e,
Err(_) => continue,
};
for (event_index, event) in events.iter().enumerate() {
if let Some(ref event_type_filter) = args.event_type
&& !event_type_filter.matches_payload(&event.payload)
{
continue;
}
let event_json = match serde_json::to_string(&event.payload) {
Ok(j) => j,
Err(_) => continue,
};
if event_json.contains(&args.query) {
let (turn_index, step_index) = Self::find_event_location(&session, event_index);
let event_match = EventMatch::new(
session_summary.id.clone(),
event_index,
turn_index,
step_index,
event,
);
all_matches.push(event_match);
}
}
}
let fetch_limit = limit + 1;
let mut matches: Vec<_> = all_matches
.into_iter()
.skip(offset)
.take(fetch_limit)
.collect();
let has_more = matches.len() > limit;
if has_more {
matches.pop();
}
let next_cursor = if has_more {
Some(
Cursor {
offset: offset + limit,
}
.encode(),
)
} else {
None
};
Ok(SearchEventsResponse {
matches,
next_cursor,
})
}
pub fn list_turns(&self, args: ListTurnsArgs) -> Result<ListTurnsResponse> {
let handle = self.get(&args.session_id)?;
let session = handle.assemble()?;
let limit = args.limit();
let offset = args
.cursor
.as_ref()
.and_then(|c| Cursor::decode(c))
.map(|c| c.offset)
.unwrap_or(0);
let total_turns = session.turns.len();
let remaining = total_turns.saturating_sub(offset);
let has_more = remaining > limit;
let next_cursor = if has_more {
Some(
Cursor {
offset: offset + limit,
}
.encode(),
)
} else {
None
};
Ok(ListTurnsResponse::new(session, offset, limit, next_cursor))
}
pub fn get_turns(&self, args: GetTurnsArgs) -> Result<GetTurnsResponse> {
let handle = self.get(&args.session_id)?;
let session = handle.assemble()?;
GetTurnsResponse::new(session, &args).map_err(Error::InvalidInput)
}
fn find_event_location(session: &AgentSession, event_index: usize) -> (usize, usize) {
let mut current_event_idx = 0;
for (turn_idx, turn) in session.turns.iter().enumerate() {
for (step_idx, step) in turn.steps.iter().enumerate() {
let step_event_count = Self::count_step_events(step);
if current_event_idx + step_event_count > event_index {
return (turn_idx, step_idx);
}
current_event_idx += step_event_count;
}
}
(0, 0)
}
fn count_step_events(step: &AgentStep) -> usize {
let mut count = 0;
if step.reasoning.is_some() {
count += 1;
}
count += step.tools.len() * 2;
if step.message.is_some() {
count += 1;
}
count
}
}
pub struct SessionHandle {
source: SessionSource,
}
enum SessionSource {
Workspace {
inner: Arc<agtrace_runtime::AgTrace>,
id: String,
},
Events {
events: Vec<crate::types::AgentEvent>,
},
}
impl SessionHandle {
pub fn from_events(events: Vec<AgentEvent>) -> Self {
Self {
source: SessionSource::Events { events },
}
}
pub fn events(&self) -> Result<Vec<AgentEvent>> {
match &self.source {
SessionSource::Workspace { inner, id } => {
let session_handle = inner
.sessions()
.find(id)
.map_err(|e| Error::NotFound(format!("Session {}: {}", id, e)))?;
session_handle.events().map_err(Error::Runtime)
}
SessionSource::Events { events } => Ok(events.clone()),
}
}
pub fn assemble(&self) -> Result<AgentSession> {
let events = self.events()?;
agtrace_engine::assemble_session(&events).ok_or_else(|| {
Error::InvalidInput(
"Failed to assemble session: insufficient or invalid events".to_string(),
)
})
}
pub fn assemble_all(&self) -> Result<Vec<AgentSession>> {
let events = self.events()?;
let sessions = agtrace_engine::assemble_sessions(&events);
if sessions.is_empty() {
return Err(Error::InvalidInput(
"Failed to assemble session: insufficient or invalid events".to_string(),
));
}
Ok(sessions)
}
pub fn export(&self, strategy: ExportStrategy) -> Result<Vec<AgentEvent>> {
let events = self.events()?;
Ok(agtrace_engine::export::transform(&events, strategy))
}
pub fn metadata(&self) -> Result<Option<crate::types::SessionMetadata>> {
match &self.source {
SessionSource::Workspace { inner, id } => {
let runtime_handle = inner
.sessions()
.find(id)
.map_err(|e| Error::NotFound(format!("Session {}: {}", id, e)))?;
runtime_handle.metadata().map(Some).map_err(Error::Runtime)
}
SessionSource::Events { .. } => Ok(None),
}
}
pub fn raw_files(&self) -> Result<Vec<crate::types::RawFileContent>> {
match &self.source {
SessionSource::Workspace { inner, id } => {
let runtime_handle = inner
.sessions()
.find(id)
.map_err(|e| Error::NotFound(format!("Session {}: {}", id, e)))?;
runtime_handle.raw_files().map_err(Error::Runtime)
}
SessionSource::Events { .. } => Ok(vec![]),
}
}
pub fn summarize(&self) -> Result<agtrace_engine::SessionSummary> {
let session = self.assemble()?;
Ok(agtrace_engine::session::summarize(&session))
}
pub fn analyze(&self) -> Result<crate::analysis::SessionAnalyzer> {
let session = self.assemble()?;
Ok(crate::analysis::SessionAnalyzer::new(session))
}
pub fn child_sessions(&self) -> Result<Vec<ChildSessionInfo>> {
match &self.source {
SessionSource::Workspace { inner, id } => {
let runtime_handle = inner
.sessions()
.find(id)
.map_err(|e| Error::NotFound(format!("Session {}: {}", id, e)))?;
let children = runtime_handle.child_sessions().map_err(Error::Runtime)?;
Ok(children
.into_iter()
.map(|c| ChildSessionInfo {
session_id: c.id,
provider: c.provider,
spawned_by: c.spawned_by,
snippet: c.snippet,
})
.collect())
}
SessionSource::Events { .. } => Ok(vec![]),
}
}
}
#[derive(Debug, Clone)]
pub struct ChildSessionInfo {
pub session_id: String,
pub provider: String,
pub spawned_by: Option<agtrace_types::SpawnContext>,
pub snippet: Option<String>,
}
pub struct ProjectClient {
inner: Arc<agtrace_runtime::AgTrace>,
}
impl ProjectClient {
pub fn list(&self) -> Result<Vec<ProjectInfo>> {
self.inner.projects().list().map_err(Error::Runtime)
}
}
pub struct WatchClient {
inner: Arc<agtrace_runtime::AgTrace>,
}
impl WatchClient {
pub fn builder(&self) -> WatchBuilder {
WatchBuilder::new(self.inner.clone())
}
pub fn all_providers(&self) -> WatchBuilder {
WatchBuilder::new(self.inner.clone()).all_providers()
}
pub fn provider(&self, name: &str) -> WatchBuilder {
WatchBuilder::new(self.inner.clone()).provider(name)
}
pub fn session(&self, _id: &str) -> WatchBuilder {
WatchBuilder::new(self.inner.clone())
}
}
pub struct InsightClient {
inner: Arc<agtrace_runtime::AgTrace>,
}
impl InsightClient {
pub fn corpus_stats(
&self,
project_hash: Option<&agtrace_types::ProjectHash>,
limit: usize,
) -> Result<CorpusStats> {
self.inner
.insights()
.corpus_stats(project_hash, limit)
.map_err(Error::Runtime)
}
pub fn tool_usage(
&self,
limit: Option<usize>,
provider: Option<String>,
) -> Result<agtrace_runtime::StatsResult> {
self.inner
.insights()
.tool_usage(limit, provider)
.map_err(Error::Runtime)
}
pub fn pack(&self, _limit: usize) -> Result<PackResult> {
Err(Error::InvalidInput(
"Pack operation not yet implemented in runtime".to_string(),
))
}
pub fn grep(
&self,
_pattern: &str,
_filter: &SessionFilter,
_limit: usize,
) -> Result<Vec<AgentEvent>> {
Err(Error::InvalidInput(
"Grep operation not yet implemented in runtime".to_string(),
))
}
}
pub struct SystemClient {
inner: Arc<agtrace_runtime::AgTrace>,
}
impl SystemClient {
pub fn initialize<F>(config: InitConfig, on_progress: Option<F>) -> Result<InitResult>
where
F: FnMut(InitProgress),
{
agtrace_runtime::AgTrace::setup(config, on_progress).map_err(Error::Runtime)
}
pub fn diagnose(&self) -> Result<Vec<DiagnoseResult>> {
self.inner.diagnose().map_err(Error::Runtime)
}
pub fn check_file(&self, path: &Path, provider: Option<&str>) -> Result<CheckResult> {
let path_str = path
.to_str()
.ok_or_else(|| Error::InvalidInput("Path contains invalid UTF-8".to_string()))?;
let (adapter, provider_name) = if let Some(name) = provider {
let adapter = agtrace_providers::create_adapter(name)
.map_err(|_| Error::NotFound(format!("Provider: {}", name)))?;
(adapter, name.to_string())
} else {
let adapter = agtrace_providers::detect_adapter_from_path(path_str)
.map_err(|_| Error::NotFound("No suitable provider detected".to_string()))?;
let name = format!("{} (auto-detected)", adapter.id());
(adapter, name)
};
agtrace_runtime::AgTrace::check_file(path_str, &adapter, &provider_name)
.map_err(Error::Runtime)
}
pub fn inspect_file(path: &Path, lines: usize, json_format: bool) -> Result<InspectResult> {
let path_str = path
.to_str()
.ok_or_else(|| Error::InvalidInput("Path contains invalid UTF-8".to_string()))?;
agtrace_runtime::AgTrace::inspect_file(path_str, lines, json_format).map_err(Error::Runtime)
}
pub fn reindex<F>(
&self,
scope: agtrace_types::ProjectScope,
force: bool,
provider_filter: Option<&str>,
on_progress: F,
) -> Result<()>
where
F: FnMut(IndexProgress),
{
self.inner
.projects()
.scan(scope, force, provider_filter, on_progress)
.map(|_| ()) .map_err(Error::Runtime)
}
pub fn vacuum(&self) -> Result<()> {
let db = self.inner.database();
let db = db.lock().unwrap();
db.vacuum().map_err(|e| Error::Runtime(e.into()))
}
pub fn list_providers(&self) -> Result<Vec<ProviderConfig>> {
Ok(self.inner.config().providers.values().cloned().collect())
}
pub fn detect_providers() -> Result<Config> {
agtrace_runtime::Config::detect_providers().map_err(Error::Runtime)
}
pub fn config(&self) -> Config {
self.inner.config().clone()
}
}