use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use terraphim_automata::matcher::find_matches;
use terraphim_types::{Concept, NormalizedTerm, NormalizedTermValue, Thesaurus};
use tracing::{debug, warn};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExitClass {
Success,
EmptySuccess,
Timeout,
RateLimit,
CompilationError,
TestFailure,
ModelError,
NetworkError,
ResourceExhaustion,
PermissionDenied,
Crash,
Unknown,
}
impl fmt::Display for ExitClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ExitClass::Success => write!(f, "success"),
ExitClass::EmptySuccess => write!(f, "empty_success"),
ExitClass::Timeout => write!(f, "timeout"),
ExitClass::RateLimit => write!(f, "rate_limit"),
ExitClass::CompilationError => write!(f, "compilation_error"),
ExitClass::TestFailure => write!(f, "test_failure"),
ExitClass::ModelError => write!(f, "model_error"),
ExitClass::NetworkError => write!(f, "network_error"),
ExitClass::ResourceExhaustion => write!(f, "resource_exhaustion"),
ExitClass::PermissionDenied => write!(f, "permission_denied"),
ExitClass::Crash => write!(f, "crash"),
ExitClass::Unknown => write!(f, "unknown"),
}
}
}
impl ExitClass {
fn from_concept_name(name: &str) -> Option<Self> {
match name {
"timeout" => Some(ExitClass::Timeout),
"ratelimit" => Some(ExitClass::RateLimit),
"compilationerror" => Some(ExitClass::CompilationError),
"testfailure" => Some(ExitClass::TestFailure),
"modelerror" => Some(ExitClass::ModelError),
"networkerror" => Some(ExitClass::NetworkError),
"resourceexhaustion" => Some(ExitClass::ResourceExhaustion),
"permissiondenied" => Some(ExitClass::PermissionDenied),
"crash" => Some(ExitClass::Crash),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunTrigger {
Cron,
Mention,
Flow,
Manual,
}
impl fmt::Display for RunTrigger {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RunTrigger::Cron => write!(f, "cron"),
RunTrigger::Mention => write!(f, "mention"),
RunTrigger::Flow => write!(f, "flow"),
RunTrigger::Manual => write!(f, "manual"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentRunRecord {
pub run_id: Uuid,
pub agent_name: String,
pub started_at: DateTime<Utc>,
pub ended_at: DateTime<Utc>,
pub exit_code: Option<i32>,
pub exit_class: ExitClass,
pub model_used: Option<String>,
pub was_fallback: bool,
pub wall_time_secs: f64,
pub output_summary: String,
pub error_summary: String,
pub trigger: RunTrigger,
pub matched_patterns: Vec<String>,
pub confidence: f64,
}
impl AgentRunRecord {
fn truncate(text: &str, max_len: usize) -> String {
if text.len() <= max_len {
text.to_string()
} else {
let mut boundary = max_len;
while boundary > 0 && !text.is_char_boundary(boundary) {
boundary -= 1;
}
format!("{}...", &text[..boundary])
}
}
pub fn summarise_output(lines: &[String]) -> String {
let combined = lines.join("\n");
Self::truncate(&combined, 500)
}
pub fn summarise_errors(lines: &[String]) -> String {
let combined = lines.join("\n");
Self::truncate(&combined, 500)
}
}
pub struct ExitClassifier {
thesaurus: Thesaurus,
}
struct PatternDef {
concept_name: &'static str,
patterns: &'static [&'static str],
}
const EXIT_CLASS_PATTERNS: &[PatternDef] = &[
PatternDef {
concept_name: "timeout",
patterns: &[
"timed out",
"deadline exceeded",
"wall-clock kill",
"context deadline exceeded",
"operation timed out",
"execution expired",
],
},
PatternDef {
concept_name: "ratelimit",
patterns: &[
"429",
"rate limit",
"too many requests",
"quota exceeded",
"rate_limit_exceeded",
"throttled",
],
},
PatternDef {
concept_name: "compilationerror",
patterns: &[
"error[E",
"cannot find",
"unresolved import",
"cargo build failed",
"failed to compile",
"aborting due to",
"could not compile",
],
},
PatternDef {
concept_name: "testfailure",
patterns: &[
"test result: FAILED",
"failures:",
"panicked at",
"assertion failed",
"thread 'main' panicked",
"cargo test failed",
],
},
PatternDef {
concept_name: "modelerror",
patterns: &[
"model not found",
"context length exceeded",
"invalid api key",
"invalid_api_key",
"model_not_found",
"insufficient_quota",
"content_policy_violation",
],
},
PatternDef {
concept_name: "networkerror",
patterns: &[
"connection refused",
"dns resolution",
"ECONNRESET",
"ssl handshake",
"network unreachable",
"connection reset",
"ENOTFOUND",
"ETIMEDOUT",
],
},
PatternDef {
concept_name: "resourceexhaustion",
patterns: &[
"out of memory",
"OOM",
"no space left",
"disk full",
"cannot allocate memory",
"memory allocation failed",
],
},
PatternDef {
concept_name: "permissiondenied",
patterns: &[
"permission denied",
"EACCES",
"403 Forbidden",
"access denied",
"insufficient permissions",
"not authorized",
],
},
PatternDef {
concept_name: "crash",
patterns: &[
"SIGSEGV",
"SIGKILL",
"stack overflow",
"SIGABRT",
"segmentation fault",
"bus error",
"SIGBUS",
],
},
];
impl ExitClassifier {
pub fn new() -> Self {
Self {
thesaurus: Self::build_thesaurus(),
}
}
fn build_thesaurus() -> Thesaurus {
let mut thesaurus = Thesaurus::new("exit_classes".to_string());
for def in EXIT_CLASS_PATTERNS {
let concept = Concept::from(def.concept_name.to_string());
let nterm = NormalizedTerm::new(concept.id, concept.value.clone());
thesaurus.insert(concept.value.clone(), nterm.clone());
for pattern in def.patterns {
thesaurus.insert(NormalizedTermValue::new(pattern.to_string()), nterm.clone());
}
}
thesaurus
}
pub fn classify(
&self,
exit_code: Option<i32>,
stdout_lines: &[String],
stderr_lines: &[String],
) -> ExitClassification {
let combined = format!("{}\n{}", stdout_lines.join("\n"), stderr_lines.join("\n"));
if exit_code == Some(0) {
let has_output = stdout_lines.iter().any(|l| !l.trim().is_empty());
if !has_output {
return ExitClassification {
exit_class: ExitClass::EmptySuccess,
matched_patterns: vec![],
confidence: 0.8,
};
}
let classification = self.match_patterns(&combined);
if classification.exit_class != ExitClass::Unknown {
return ExitClassification {
confidence: classification.confidence * 0.5,
..classification
};
}
return ExitClassification {
exit_class: ExitClass::Success,
matched_patterns: vec![],
confidence: 1.0,
};
}
let classification = self.match_patterns(&combined);
if classification.exit_class != ExitClass::Unknown {
return classification;
}
ExitClassification {
exit_class: ExitClass::Unknown,
matched_patterns: vec![],
confidence: 0.0,
}
}
fn match_patterns(&self, text: &str) -> ExitClassification {
let matches = match find_matches(text, self.thesaurus.clone(), false) {
Ok(m) => m,
Err(e) => {
warn!(error = %e, "exit class pattern matching failed");
return ExitClassification {
exit_class: ExitClass::Unknown,
matched_patterns: vec![],
confidence: 0.0,
};
}
};
if matches.is_empty() {
return ExitClassification {
exit_class: ExitClass::Unknown,
matched_patterns: vec![],
confidence: 0.0,
};
}
let mut class_counts: HashMap<String, (usize, Vec<String>)> = HashMap::new();
for m in &matches {
let concept_name = m.normalized_term.value.as_str().to_string();
let entry = class_counts
.entry(concept_name)
.or_insert_with(|| (0, Vec::new()));
entry.0 += 1;
let pattern = m.term.clone();
if !entry.1.contains(&pattern) {
entry.1.push(pattern);
}
}
debug!(
matched_classes = ?class_counts.keys().collect::<Vec<_>>(),
total_matches = matches.len(),
"exit class pattern matches"
);
let (best_concept, (count, matched_patterns)) = class_counts
.into_iter()
.max_by_key(|(_, (count, _))| *count)
.expect("non-empty matches guaranteed above");
let exit_class = ExitClass::from_concept_name(&best_concept).unwrap_or(ExitClass::Unknown);
let confidence = if matches.is_empty() {
0.0
} else {
(count as f64) / (matches.len() as f64)
};
ExitClassification {
exit_class,
matched_patterns,
confidence,
}
}
}
impl Default for ExitClassifier {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct ExitClassification {
pub exit_class: ExitClass,
pub matched_patterns: Vec<String>,
pub confidence: f64,
}
#[async_trait::async_trait]
pub trait RunRecordPersistence: Send + Sync {
async fn insert(&self, record: &AgentRunRecord) -> Result<(), RunRecordError>;
async fn query_by_agent(&self, agent_name: &str)
-> Result<Vec<AgentRunRecord>, RunRecordError>;
async fn query_by_exit_class(
&self,
exit_class: ExitClass,
) -> Result<Vec<AgentRunRecord>, RunRecordError>;
async fn count_by_class_since(
&self,
since: DateTime<Utc>,
) -> Result<HashMap<ExitClass, usize>, RunRecordError>;
}
#[derive(Debug, thiserror::Error)]
pub enum RunRecordError {
#[error("storage error: {0}")]
Storage(String),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Default)]
pub struct InMemoryRunRecordStore {
records: std::sync::Mutex<Vec<AgentRunRecord>>,
}
#[async_trait::async_trait]
impl RunRecordPersistence for InMemoryRunRecordStore {
async fn insert(&self, record: &AgentRunRecord) -> Result<(), RunRecordError> {
let mut records = self
.records
.lock()
.map_err(|e| RunRecordError::Storage(e.to_string()))?;
records.push(record.clone());
Ok(())
}
async fn query_by_agent(
&self,
agent_name: &str,
) -> Result<Vec<AgentRunRecord>, RunRecordError> {
let records = self
.records
.lock()
.map_err(|e| RunRecordError::Storage(e.to_string()))?;
Ok(records
.iter()
.filter(|r| r.agent_name == agent_name)
.cloned()
.collect())
}
async fn query_by_exit_class(
&self,
exit_class: ExitClass,
) -> Result<Vec<AgentRunRecord>, RunRecordError> {
let records = self
.records
.lock()
.map_err(|e| RunRecordError::Storage(e.to_string()))?;
Ok(records
.iter()
.filter(|r| r.exit_class == exit_class)
.cloned()
.collect())
}
async fn count_by_class_since(
&self,
since: DateTime<Utc>,
) -> Result<HashMap<ExitClass, usize>, RunRecordError> {
let records = self
.records
.lock()
.map_err(|e| RunRecordError::Storage(e.to_string()))?;
let mut counts: HashMap<ExitClass, usize> = HashMap::new();
for record in records.iter().filter(|r| r.ended_at >= since) {
*counts.entry(record.exit_class).or_insert(0) += 1;
}
Ok(counts)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn classifier() -> ExitClassifier {
ExitClassifier::new()
}
#[test]
fn classify_success_with_output() {
let c = classifier();
let result = c.classify(Some(0), &["review complete, 3 findings".to_string()], &[]);
assert_eq!(result.exit_class, ExitClass::Success);
assert!(result.confidence > 0.9);
}
#[test]
fn classify_empty_success() {
let c = classifier();
let result = c.classify(Some(0), &[], &[]);
assert_eq!(result.exit_class, ExitClass::EmptySuccess);
}
#[test]
fn classify_timeout() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&["error: operation timed out after 300s".to_string()],
);
assert_eq!(result.exit_class, ExitClass::Timeout);
assert!(result.confidence > 0.0);
assert!(result
.matched_patterns
.iter()
.any(|p| p.contains("timed out")));
}
#[test]
fn classify_rate_limit() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&[
"HTTP 429 Too Many Requests".to_string(),
"rate limit exceeded, retrying in 60s".to_string(),
],
);
assert_eq!(result.exit_class, ExitClass::RateLimit);
assert!(result.matched_patterns.len() >= 2);
}
#[test]
fn classify_compilation_error() {
let c = classifier();
let result = c.classify(
Some(101),
&[],
&[
"error[E0433]: failed to resolve: use of undeclared crate or module".to_string(),
"error[E0412]: cannot find type `FooBar`".to_string(),
"error: aborting due to 2 previous errors".to_string(),
],
);
assert_eq!(result.exit_class, ExitClass::CompilationError);
}
#[test]
fn classify_test_failure() {
let c = classifier();
let result = c.classify(
Some(101),
&[
"running 5 tests".to_string(),
"test result: FAILED. 3 passed; 2 failed; 0 ignored".to_string(),
],
&["thread 'main' panicked at 'assertion failed'".to_string()],
);
assert_eq!(result.exit_class, ExitClass::TestFailure);
}
#[test]
fn classify_model_error() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&["Error: model not found: gpt-5-turbo".to_string()],
);
assert_eq!(result.exit_class, ExitClass::ModelError);
}
#[test]
fn classify_network_error() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&["Error: connection refused (os error 111)".to_string()],
);
assert_eq!(result.exit_class, ExitClass::NetworkError);
}
#[test]
fn classify_resource_exhaustion() {
let c = classifier();
let result = c.classify(
Some(137),
&[],
&["fatal: out of memory, malloc failed".to_string()],
);
assert_eq!(result.exit_class, ExitClass::ResourceExhaustion);
}
#[test]
fn classify_permission_denied() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&["Error: permission denied (os error 13)".to_string()],
);
assert_eq!(result.exit_class, ExitClass::PermissionDenied);
}
#[test]
fn classify_crash() {
let c = classifier();
let result = c.classify(
Some(139),
&[],
&["fatal runtime error: stack overflow".to_string()],
);
assert_eq!(result.exit_class, ExitClass::Crash);
}
#[test]
fn classify_unknown_exit() {
let c = classifier();
let result = c.classify(
Some(42),
&["some generic output".to_string()],
&["some generic error".to_string()],
);
assert_eq!(result.exit_class, ExitClass::Unknown);
assert_eq!(result.confidence, 0.0);
}
#[test]
fn classify_mixed_patterns_picks_dominant() {
let c = classifier();
let result = c.classify(
Some(1),
&[],
&[
"error: operation timed out".to_string(),
"error[E0433]: cannot find module".to_string(),
"error[E0412]: cannot find type".to_string(),
"error: aborting due to 2 previous errors".to_string(),
],
);
assert_eq!(result.exit_class, ExitClass::CompilationError);
}
#[test]
fn exit_class_display_roundtrip() {
for class in [
ExitClass::Success,
ExitClass::EmptySuccess,
ExitClass::Timeout,
ExitClass::RateLimit,
ExitClass::CompilationError,
ExitClass::TestFailure,
ExitClass::ModelError,
ExitClass::NetworkError,
ExitClass::ResourceExhaustion,
ExitClass::PermissionDenied,
ExitClass::Crash,
ExitClass::Unknown,
] {
let display = class.to_string();
assert!(
!display.is_empty(),
"ExitClass::Display should not be empty"
);
}
}
#[test]
fn exit_class_serialization() {
let class = ExitClass::CompilationError;
let json = serde_json::to_string(&class).unwrap();
assert_eq!(json, r#""compilation_error""#);
let deserialized: ExitClass = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized, class);
}
#[test]
fn agent_run_record_serialization() {
let record = AgentRunRecord {
run_id: Uuid::nil(),
agent_name: "test-agent".to_string(),
started_at: Utc::now(),
ended_at: Utc::now(),
exit_code: Some(1),
exit_class: ExitClass::Timeout,
model_used: Some("kimi-k2.5".to_string()),
was_fallback: false,
wall_time_secs: 42.5,
output_summary: "some output".to_string(),
error_summary: "timed out".to_string(),
trigger: RunTrigger::Cron,
matched_patterns: vec!["timed out".to_string()],
confidence: 0.95,
};
let json = serde_json::to_string(&record).unwrap();
let deserialized: AgentRunRecord = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.exit_class, ExitClass::Timeout);
assert_eq!(deserialized.agent_name, "test-agent");
}
#[test]
fn summarise_truncates_long_output() {
let lines: Vec<String> = (0..100).map(|i| format!("line {}", i)).collect();
let summary = AgentRunRecord::summarise_output(&lines);
assert!(summary.len() <= 504); }
#[test]
fn truncate_does_not_panic_on_multibyte_utf8() {
let emoji_str: String = "😀".repeat(200); let result = AgentRunRecord::truncate(&emoji_str, 500);
assert!(result.ends_with("..."), "result should end with '...'");
assert!(
std::str::from_utf8(result.as_bytes()).is_ok(),
"must be valid UTF-8"
);
}
#[test]
fn truncate_does_not_panic_on_multibyte_utf8_at_boundary() {
let cjk_str: String = "ä¸".repeat(200); let result = AgentRunRecord::truncate(&cjk_str, 500); assert!(result.ends_with("..."));
assert!(std::str::from_utf8(result.as_bytes()).is_ok());
}
#[test]
fn truncate_short_text_unchanged() {
let s = "hello";
let result = AgentRunRecord::truncate(s, 500);
assert_eq!(result, "hello");
}
#[test]
fn summarise_output_with_unicode_does_not_panic() {
let lines: Vec<String> = (0..50).map(|i| format!("emoji {} 🔥", i)).collect();
let summary = AgentRunRecord::summarise_output(&lines);
assert!(std::str::from_utf8(summary.as_bytes()).is_ok());
}
#[tokio::test]
async fn in_memory_store_insert_and_query() {
let store = InMemoryRunRecordStore::default();
let record = AgentRunRecord {
run_id: Uuid::new_v4(),
agent_name: "test-agent".to_string(),
started_at: Utc::now(),
ended_at: Utc::now(),
exit_code: Some(1),
exit_class: ExitClass::Timeout,
model_used: None,
was_fallback: false,
wall_time_secs: 10.0,
output_summary: String::new(),
error_summary: "timed out".to_string(),
trigger: RunTrigger::Cron,
matched_patterns: vec!["timed out".to_string()],
confidence: 0.9,
};
store.insert(&record).await.unwrap();
let by_agent = store.query_by_agent("test-agent").await.unwrap();
assert_eq!(by_agent.len(), 1);
assert_eq!(by_agent[0].exit_class, ExitClass::Timeout);
let by_class = store.query_by_exit_class(ExitClass::Timeout).await.unwrap();
assert_eq!(by_class.len(), 1);
let by_class_empty = store.query_by_exit_class(ExitClass::Crash).await.unwrap();
assert!(by_class_empty.is_empty());
}
}