From 500e19a20c546b2f69403485834adbba7fa0b9c8 Mon Sep 17 00:00:00 2001 From: Terraphim CI Date: Sun, 5 Apr 2026 23:36:07 +0100 Subject: [PATCH 1/3] feat(orchestrator): add ExitClassifier with KG-boosted exit classification Implement structured agent exit classification using terraphim-automata Aho-Corasick pattern matching on stderr/stdout. Each agent exit now produces an AgentRunRecord with classified ExitClass, matched patterns, and confidence score. - Add ExitClass enum (12 variants) and ExitClassifier in agent_run_record.rs - Build exit class thesaurus programmatically from KG patterns - Integrate classification into poll_agent_exits() reconciliation loop - Inject exit_class into Quickwit LogDocument.extra for observability - Add heading extraction to MarkdownDirectives (replaces raw file read) - Use markdown_directives::extract_heading_from_path in builder.rs - Add exit class KG thesaurus at docs/src/kg/exit_classes.md - 18 unit tests for classifier, 4 tests for heading extraction Refs #395 Co-Authored-By: Claude Opus 4.6 --- crates/terraphim_automata/src/builder.rs | 12 +- .../src/markdown_directives.rs | 76 ++ .../src/agent_run_record.rs | 876 ++++++++++++++++++ crates/terraphim_orchestrator/src/lib.rs | 75 +- crates/terraphim_types/src/lib.rs | 3 + docs/src/kg/exit_classes.md | 43 + 6 files changed, 1075 insertions(+), 10 deletions(-) create mode 100644 crates/terraphim_orchestrator/src/agent_run_record.rs create mode 100644 docs/src/kg/exit_classes.md diff --git a/crates/terraphim_automata/src/builder.rs b/crates/terraphim_automata/src/builder.rs index fe41c2877..a01ded087 100644 --- a/crates/terraphim_automata/src/builder.rs +++ b/crates/terraphim_automata/src/builder.rs @@ -196,11 +196,17 @@ fn concept_from_path(path: PathBuf) -> Result { let stem = path.file_stem().ok_or(BuilderError::Indexation(format!( "No file stem in path {path:?}" )))?; - let original_name = stem.to_string_lossy().to_string(); - let concept = Concept::from(original_name.clone()); + let stem_name = stem.to_string_lossy().to_string(); + + // Use heading from markdown directives (parsed when the file is first read). + // Falls back to file stem if directives are unavailable for this path. + let display_name = crate::markdown_directives::extract_heading_from_path(&path) + .unwrap_or_else(|| stem_name.clone()); + + let concept = Concept::from(stem_name); Ok(ConceptWithDisplay { concept, - display_name: original_name, + display_name, }) } diff --git a/crates/terraphim_automata/src/markdown_directives.rs b/crates/terraphim_automata/src/markdown_directives.rs index b4dc82dcc..d7499d99b 100644 --- a/crates/terraphim_automata/src/markdown_directives.rs +++ b/crates/terraphim_automata/src/markdown_directives.rs @@ -84,6 +84,20 @@ pub fn parse_markdown_directives_dir(root: &Path) -> crate::Result Option { + let content = fs::read_to_string(path).ok()?; + content + .lines() + .map(|l| l.trim()) + .find(|l| l.starts_with("# ")) + .map(|l| l.trim_start_matches("# ").trim().to_string()) + .filter(|h| !h.is_empty()) +} + fn parse_markdown_directives_content( path: &Path, content: &str, @@ -95,6 +109,7 @@ fn parse_markdown_directives_content( let mut priority: Option = None; let mut trigger: Option = None; let mut pinned: bool = false; + let mut heading: Option = None; for (idx, line) in content.lines().enumerate() { let trimmed = line.trim(); @@ -102,6 +117,15 @@ fn parse_markdown_directives_content( continue; } + // Extract first `# Heading` (H1 only), preserving original case. + if heading.is_none() && trimmed.starts_with("# ") { + let h = trimmed.trim_start_matches("# ").trim(); + if !h.is_empty() { + heading = Some(h.to_string()); + } + continue; + } + let lower = trimmed.to_ascii_lowercase(); if lower.starts_with("type:::") { if doc_type.is_some() { @@ -216,6 +240,7 @@ fn parse_markdown_directives_content( priority, trigger, pinned, + heading, } } @@ -379,4 +404,55 @@ mod tests { let directives = result.directives.get("test").unwrap(); assert_eq!(directives.trigger, None); } + + #[test] + fn extracts_heading_from_markdown() { + let dir = tempdir().unwrap(); + let path = dir.path().join("bun.md"); + fs::write( + &path, + "# Bun Package Manager\n\nsynonyms:: npm, yarn, pnpm\n", + ) + .unwrap(); + + let result = parse_markdown_directives_dir(dir.path()).unwrap(); + let directives = result.directives.get("bun").unwrap(); + assert_eq!(directives.heading, Some("Bun Package Manager".to_string())); + assert_eq!( + directives.synonyms, + vec!["npm".to_string(), "yarn".to_string(), "pnpm".to_string()] + ); + } + + #[test] + fn heading_none_when_absent() { + let dir = tempdir().unwrap(); + let path = dir.path().join("noheading.md"); + fs::write(&path, "synonyms:: alpha, beta\n").unwrap(); + + let result = parse_markdown_directives_dir(dir.path()).unwrap(); + let directives = result.directives.get("noheading").unwrap(); + assert_eq!(directives.heading, None); + } + + #[test] + fn extract_heading_from_path_works() { + let dir = tempdir().unwrap(); + let path = dir.path().join("test.md"); + fs::write(&path, "# My Heading\n\nSome content\n").unwrap(); + + assert_eq!( + extract_heading_from_path(&path), + Some("My Heading".to_string()) + ); + } + + #[test] + fn extract_heading_from_path_returns_none_without_heading() { + let dir = tempdir().unwrap(); + let path = dir.path().join("test.md"); + fs::write(&path, "Just content\n").unwrap(); + + assert_eq!(extract_heading_from_path(&path), None); + } } diff --git a/crates/terraphim_orchestrator/src/agent_run_record.rs b/crates/terraphim_orchestrator/src/agent_run_record.rs new file mode 100644 index 000000000..4316563b1 --- /dev/null +++ b/crates/terraphim_orchestrator/src/agent_run_record.rs @@ -0,0 +1,876 @@ +//! Structured agent run records with KG-boosted exit classification. +//! +//! Every agent run produces an `AgentRunRecord` with an `ExitClass` classified +//! via terraphim-automata Aho-Corasick matching on stderr/stdout patterns. +//! +//! The `ExitClassifier` builds a thesaurus from known exit patterns (see +//! `docs/src/kg/exit_classes.md`) and uses `find_matches()` to classify agent +//! output. When multiple exit classes match, the one with the highest match +//! count wins. +//! +//! # Architecture +//! +//! ```text +//! Agent exits (poll_agent_exits) +//! | +//! v +//! ExitClassifier::classify(exit_code, stdout, stderr) +//! |-- build thesaurus (Concept per ExitClass, patterns as synonyms) +//! |-- find_matches(combined_text, thesaurus) +//! |-- count matches per ExitClass +//! |-- pick highest count (or fallback to exit code) +//! v +//! AgentRunRecord { exit_class, matched_patterns, confidence, ... } +//! | +//! +-> terraphim_persistence (Persistable) +//! +-> quickwit LogDocument.extra +//! +-> SharedLearningStore (learning generation) +//! ``` + +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; + +// --------------------------------------------------------------------------- +// ExitClass enum +// --------------------------------------------------------------------------- + +/// Classified exit type for an agent run. +/// +/// Determined by Aho-Corasick pattern matching on agent stdout/stderr, +/// using the exit class thesaurus from `docs/src/kg/exit_classes.md`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExitClass { + /// Exit code 0 with meaningful output + Success, + /// Exit code 0 but no output (suspicious) + EmptySuccess, + /// Timed out, deadline exceeded, wall-clock kill + Timeout, + /// HTTP 429, rate limit, quota exceeded + RateLimit, + /// Compiler errors (error[E, unresolved import, etc.) + CompilationError, + /// Test failures (test result: FAILED, panicked at, etc.) + TestFailure, + /// LLM errors (model not found, context length, invalid API key) + ModelError, + /// Network failures (connection refused, DNS, ECONNRESET) + NetworkError, + /// OOM, disk full, no space left + ResourceExhaustion, + /// Permission denied, EACCES, 403 + PermissionDenied, + /// SIGSEGV, SIGKILL, panic, stack overflow + Crash, + /// No patterns matched and non-zero exit + 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 { + /// Parse an ExitClass from its concept name in the thesaurus. + fn from_concept_name(name: &str) -> Option { + 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, + } + } +} + +// --------------------------------------------------------------------------- +// RunTrigger +// --------------------------------------------------------------------------- + +/// What triggered an agent run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunTrigger { + /// Scheduled via cron expression + Cron, + /// Triggered by @mention in Gitea issue/comment + Mention, + /// Triggered as part of a Flow DAG + Flow, + /// Manual trigger (CLI, webhook, etc.) + 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"), + } + } +} + +// --------------------------------------------------------------------------- +// AgentRunRecord +// --------------------------------------------------------------------------- + +/// Structured record of a single agent run. +/// +/// Produced by the reconciliation loop after an agent exits. +/// Persisted via `terraphim_persistence` and shipped to Quickwit. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentRunRecord { + /// Unique identifier for this run + pub run_id: Uuid, + /// Name of the agent + pub agent_name: String, + /// When the run started + pub started_at: DateTime, + /// When the run ended + pub ended_at: DateTime, + /// Raw process exit code (None if killed by signal) + pub exit_code: Option, + /// Classified exit type (KG-boosted) + pub exit_class: ExitClass, + /// Model used for this run + pub model_used: Option, + /// Whether a fallback model was used + pub was_fallback: bool, + /// Wall-clock duration in seconds + pub wall_time_secs: f64, + /// First 500 chars of stdout + pub output_summary: String, + /// First 500 chars of stderr + pub error_summary: String, + /// What triggered this run + pub trigger: RunTrigger, + /// Patterns matched during exit classification + pub matched_patterns: Vec, + /// Classification confidence (0.0 - 1.0) + pub confidence: f64, +} + +impl AgentRunRecord { + /// Truncate text to max_len characters. + fn truncate(text: &str, max_len: usize) -> String { + if text.len() <= max_len { + text.to_string() + } else { + format!("{}...", &text[..max_len]) + } + } + + /// Build the output summary from collected stdout lines. + pub fn summarise_output(lines: &[String]) -> String { + let combined = lines.join("\n"); + Self::truncate(&combined, 500) + } + + /// Build the error summary from collected stderr lines. + pub fn summarise_errors(lines: &[String]) -> String { + let combined = lines.join("\n"); + Self::truncate(&combined, 500) + } +} + +// --------------------------------------------------------------------------- +// ExitClassifier +// --------------------------------------------------------------------------- + +/// Classifies agent exits using Aho-Corasick pattern matching on stdout/stderr. +/// +/// Builds a thesaurus where each `ExitClass` is a concept and known error +/// patterns are synonyms. Uses `terraphim_automata::find_matches()` to +/// identify patterns in agent output. +pub struct ExitClassifier { + thesaurus: Thesaurus, +} + +/// A pattern definition: (concept_name, patterns) +struct PatternDef { + concept_name: &'static str, + patterns: &'static [&'static str], +} + +/// Exit class pattern definitions matching `docs/src/kg/exit_classes.md`. +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 { + /// Create a new ExitClassifier with the built-in exit class thesaurus. + pub fn new() -> Self { + Self { + thesaurus: Self::build_thesaurus(), + } + } + + /// Build a thesaurus from the exit class pattern definitions. + /// + /// Each exit class is a Concept, and its known stderr/stdout patterns + /// are inserted as synonyms mapping to that concept. + 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()); + + // Insert the concept itself + thesaurus.insert(concept.value.clone(), nterm.clone()); + + // Insert each pattern as a synonym + for pattern in def.patterns { + thesaurus.insert(NormalizedTermValue::new(pattern.to_string()), nterm.clone()); + } + } + + thesaurus + } + + /// Classify an agent exit based on exit code and captured output. + /// + /// Uses Aho-Corasick matching from `terraphim_automata::find_matches()` + /// against the exit class thesaurus. When multiple classes match, + /// the one with the highest match count wins. + pub fn classify( + &self, + exit_code: Option, + stdout_lines: &[String], + stderr_lines: &[String], + ) -> ExitClassification { + // Combine stdout and stderr for pattern matching + let combined = format!("{}\n{}", stdout_lines.join("\n"), stderr_lines.join("\n")); + + // Handle success cases first + if exit_code == Some(0) { + // Check if output is empty (suspicious) + 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, + }; + } + + // Even for exit code 0, check for error patterns (some tools + // return 0 but print errors to stderr) + let classification = self.match_patterns(&combined); + if classification.exit_class != ExitClass::Unknown { + // Downgrade confidence since exit code was 0 + return ExitClassification { + confidence: classification.confidence * 0.5, + ..classification + }; + } + + return ExitClassification { + exit_class: ExitClass::Success, + matched_patterns: vec![], + confidence: 1.0, + }; + } + + // Non-zero exit: classify by pattern matching + let classification = self.match_patterns(&combined); + if classification.exit_class != ExitClass::Unknown { + return classification; + } + + // No patterns matched, non-zero exit + ExitClassification { + exit_class: ExitClass::Unknown, + matched_patterns: vec![], + confidence: 0.0, + } + } + + /// Run Aho-Corasick matching and group by exit class. + 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, + }; + } + + // Group matches by exit class concept + let mut class_counts: HashMap)> = 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::>(), + total_matches = matches.len(), + "exit class pattern matches" + ); + + // Pick the exit class with the most 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); + + // Confidence: ratio of dominant class matches to total matches + 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() + } +} + +/// Result of exit classification. +#[derive(Debug, Clone)] +pub struct ExitClassification { + /// The classified exit type + pub exit_class: ExitClass, + /// Patterns that were matched + pub matched_patterns: Vec, + /// Confidence score (0.0 - 1.0) + pub confidence: f64, +} + +// --------------------------------------------------------------------------- +// Persistence +// --------------------------------------------------------------------------- + +/// Persistence trait for agent run records. +/// +/// Follows the same pattern as `LearningPersistence` in `learning.rs`. +#[async_trait::async_trait] +pub trait RunRecordPersistence: Send + Sync { + /// Store a run record. + async fn insert(&self, record: &AgentRunRecord) -> Result<(), RunRecordError>; + + /// Query records by agent name. + async fn query_by_agent(&self, agent_name: &str) + -> Result, RunRecordError>; + + /// Query records by exit class. + async fn query_by_exit_class( + &self, + exit_class: ExitClass, + ) -> Result, RunRecordError>; + + /// Count records by exit class in a time range. + async fn count_by_class_since( + &self, + since: DateTime, + ) -> Result, RunRecordError>; +} + +/// Errors for run record persistence. +#[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), +} + +/// In-memory implementation for testing. +#[derive(Default)] +pub struct InMemoryRunRecordStore { + records: std::sync::Mutex>, +} + +#[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, 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, 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, + ) -> Result, RunRecordError> { + let records = self + .records + .lock() + .map_err(|e| RunRecordError::Storage(e.to_string()))?; + let mut counts: HashMap = HashMap::new(); + for record in records.iter().filter(|r| r.ended_at >= since) { + *counts.entry(record.exit_class).or_insert(0) += 1; + } + Ok(counts) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[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(); + // stderr has 1 timeout pattern and 3 compilation error patterns + 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(), + ], + ); + // CompilationError should win because it has more matches + 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 = (0..100).map(|i| format!("line {}", i)).collect(); + let summary = AgentRunRecord::summarise_output(&lines); + assert!(summary.len() <= 504); // 500 + "..." + } + + #[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()); + } +} diff --git a/crates/terraphim_orchestrator/src/lib.rs b/crates/terraphim_orchestrator/src/lib.rs index d72e51d5c..982e5e7da 100644 --- a/crates/terraphim_orchestrator/src/lib.rs +++ b/crates/terraphim_orchestrator/src/lib.rs @@ -29,6 +29,7 @@ //! ``` pub mod adf_commands; +pub mod agent_run_record; pub mod compound; pub mod concurrency; pub mod config; @@ -51,6 +52,9 @@ pub mod scheduler; pub mod scope; pub mod webhook; +pub use agent_run_record::{ + AgentRunRecord, ExitClass, ExitClassification, ExitClassifier, RunTrigger, +}; pub use compound::{CompoundReviewResult, CompoundReviewWorkflow, ReviewGroupDef, SwarmConfig}; pub use concurrency::{ConcurrencyController, FairnessPolicy, ModeQuotas}; #[cfg(feature = "quickwit")] @@ -182,6 +186,8 @@ pub struct AgentOrchestrator { tick_count: u64, #[cfg(feature = "quickwit")] quickwit_sink: Option, + /// Classifier for structured agent exit classification using KG-boosted matching. + exit_classifier: ExitClassifier, } /// Validate agent name for safe use in file paths. @@ -292,6 +298,7 @@ impl AgentOrchestrator { tick_count: 0, #[cfg(feature = "quickwit")] quickwit_sink: None, + exit_classifier: ExitClassifier::new(), }) } @@ -2265,16 +2272,20 @@ impl AgentOrchestrator { // Drain output from exiting agents BEFORE removing them for (name, def, status) in &exited { - // Drain remaining output events + // Drain remaining output events, separating stdout and stderr + let mut stdout_lines: Vec = Vec::new(); + let mut stderr_lines: Vec = Vec::new(); let mut output_lines: Vec = Vec::new(); if let Some(managed) = self.active_agents.get_mut(name) { while let Ok(event) = managed.output_rx.try_recv() { self.nightwatch.observe(name, &event); match &event { crate::OutputEvent::Stdout { line, .. } => { + stdout_lines.push(line.clone()); output_lines.push(line.clone()); } crate::OutputEvent::Stderr { line, .. } => { + stderr_lines.push(line.clone()); output_lines.push(format!("[stderr] {}", line)); } _ => {} @@ -2282,6 +2293,55 @@ impl AgentOrchestrator { } } + // Classify the exit using KG-boosted pattern matching + let classification = + self.exit_classifier + .classify(status.code(), &stdout_lines, &stderr_lines); + + let wall_time_secs = self + .active_agents + .get(name) + .map(|m| m.started_at.elapsed().as_secs_f64()) + .unwrap_or(0.0); + + let trigger = if self + .active_agents + .get(name) + .is_some_and(|m| m.spawned_by_mention) + { + RunTrigger::Mention + } else { + RunTrigger::Cron + }; + + let record = AgentRunRecord { + run_id: uuid::Uuid::new_v4(), + agent_name: name.clone(), + started_at: chrono::Utc::now() + - chrono::Duration::milliseconds((wall_time_secs * 1000.0) as i64), + ended_at: chrono::Utc::now(), + exit_code: status.code(), + exit_class: classification.exit_class, + model_used: def.model.clone(), + was_fallback: false, + wall_time_secs, + output_summary: AgentRunRecord::summarise_output(&stdout_lines), + error_summary: AgentRunRecord::summarise_errors(&stderr_lines), + trigger, + matched_patterns: classification.matched_patterns.clone(), + confidence: classification.confidence, + }; + + info!( + agent = %name, + exit_code = ?status.code(), + exit_class = %record.exit_class, + confidence = record.confidence, + matched_patterns = ?record.matched_patterns, + wall_time_secs = record.wall_time_secs, + "agent exit classified" + ); + // Post output to Gitea if configured if let (Some(poster), Some(issue)) = (&self.output_poster, def.gitea_issue) { let exit_code = status.code(); @@ -2301,20 +2361,21 @@ impl AgentOrchestrator { } else { "WARN" }; - let wall_time_secs = self - .active_agents - .get(name) - .map(|m| m.started_at.elapsed().as_secs_f64()); let doc = quickwit::LogDocument { timestamp: chrono::Utc::now().to_rfc3339(), level: level.into(), agent_name: name.clone(), layer: format!("{:?}", def.layer), source: "orchestrator".into(), - message: "agent exited".into(), + message: format!("agent exited: {}", record.exit_class), model: def.model.clone(), exit_code, - wall_time_secs, + wall_time_secs: Some(record.wall_time_secs), + extra: Some(serde_json::json!({ + "exit_class": record.exit_class.to_string(), + "confidence": record.confidence, + "matched_patterns": record.matched_patterns, + })), ..Default::default() }; let _ = sink.send(doc).await; diff --git a/crates/terraphim_types/src/lib.rs b/crates/terraphim_types/src/lib.rs index ae4930ca0..f6138b8d5 100644 --- a/crates/terraphim_types/src/lib.rs +++ b/crates/terraphim_types/src/lib.rs @@ -412,6 +412,9 @@ pub struct MarkdownDirectives { pub trigger: Option, #[serde(default)] pub pinned: bool, + /// First `# Heading` from the markdown file, preserving original case. + #[serde(default)] + pub heading: Option, } /// The central document type representing indexed and searchable content. diff --git a/docs/src/kg/exit_classes.md b/docs/src/kg/exit_classes.md new file mode 100644 index 000000000..bdcd4b05c --- /dev/null +++ b/docs/src/kg/exit_classes.md @@ -0,0 +1,43 @@ +# Exit Classes + +Knowledge graph thesaurus for ADF agent exit classification. +Each exit class is a concept whose synonyms are the stderr/stdout patterns that identify it. + +Used by ExitClassifier in `crates/terraphim_orchestrator/src/agent_run_record.rs` +to classify agent exits via terraphim-automata Aho-Corasick matching. + +## Timeout + +synonyms:: timed out, deadline exceeded, wall-clock kill, context deadline exceeded, operation timed out, execution expired + +## RateLimit + +synonyms:: 429, rate limit, too many requests, quota exceeded, rate_limit_exceeded, throttled + +## CompilationError + +synonyms:: error[E, cannot find, unresolved import, cargo build failed, failed to compile, aborting due to, could not compile + +## TestFailure + +synonyms:: test result: FAILED, failures:, panicked at, assertion failed, thread 'main' panicked, cargo test failed + +## ModelError + +synonyms:: model not found, context length exceeded, invalid api key, invalid_api_key, model_not_found, insufficient_quota, content_policy_violation + +## NetworkError + +synonyms:: connection refused, dns resolution, ECONNRESET, ssl handshake, network unreachable, connection reset, ENOTFOUND, ETIMEDOUT + +## ResourceExhaustion + +synonyms:: out of memory, OOM, no space left, disk full, cannot allocate memory, memory allocation failed + +## PermissionDenied + +synonyms:: permission denied, EACCES, 403 Forbidden, access denied, insufficient permissions, not authorized + +## Crash + +synonyms:: SIGSEGV, SIGKILL, panic, stack overflow, SIGABRT, segmentation fault, bus error, SIGBUS From ea3ba18cbc2090f69aae41312db71ce82acc35c3 Mon Sep 17 00:00:00 2001 From: Terraphim CI Date: Sun, 5 Apr 2026 23:49:15 +0100 Subject: [PATCH 2/3] refactor(automata): leverage terraphim-markdown-parser for heading extraction Replace naive line-based heading extraction with AST-based parsing from terraphim-markdown-parser. The parser handles inline code, emphasis, and other markdown within headings correctly. - Include terraphim-markdown-parser in workspace (was excluded) - Add extract_first_heading() to terraphim-markdown-parser using AST - Wire markdown_directives to use AST parser for heading extraction - Add 4 tests for heading extraction in markdown parser crate Refs #395 Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 29 ++++++- Cargo.toml | 1 - crates/terraphim-markdown-parser/src/lib.rs | 76 +++++++++++++++++++ crates/terraphim_automata/Cargo.toml | 1 + .../src/markdown_directives.rs | 25 ++---- 5 files changed, 111 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5be8712e2..be9c5bbc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4208,6 +4208,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "unicode-id", +] + [[package]] name = "markup5ever" version = "0.12.1" @@ -6322,7 +6331,6 @@ dependencies = [ "js-sys", "log", "mime", - "mime_guess", "once_cell", "percent-encoding", "pin-project-lite", @@ -7310,7 +7318,7 @@ dependencies = [ "mime_guess", "parking_lot 0.12.5", "percent-encoding", - "reqwest 0.11.27", + "reqwest 0.12.28", "rustc-hash 2.1.2", "secrecy", "serde", @@ -8494,6 +8502,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "terraphim-markdown-parser" +version = "1.0.0" +dependencies = [ + "markdown", + "terraphim_types", + "thiserror 1.0.69", + "ulid", +] + [[package]] name = "terraphim-session-analyzer" version = "1.16.0" @@ -8745,6 +8763,7 @@ dependencies = [ "serde_json", "strsim", "tempfile", + "terraphim-markdown-parser", "terraphim_types", "thiserror 1.0.69", "tokio", @@ -10325,6 +10344,12 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-id" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index 43d91c499..f50957cf3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ exclude = [ "desktop/src-tauri", # Planned future use, not needed for workspace builds "crates/terraphim_build_args", - "crates/terraphim-markdown-parser", # Unused haystack providers (kept for future integration) "crates/haystack_atlassian", "crates/haystack_discourse", diff --git a/crates/terraphim-markdown-parser/src/lib.rs b/crates/terraphim-markdown-parser/src/lib.rs index d87d4ba15..7869b0d8e 100644 --- a/crates/terraphim-markdown-parser/src/lib.rs +++ b/crates/terraphim-markdown-parser/src/lib.rs @@ -10,6 +10,52 @@ use ulid::Ulid; pub const TERRAPHIM_BLOCK_ID_PREFIX: &str = "terraphim:block-id:"; +/// Extract the first H1 heading from markdown content using AST parsing. +/// +/// Returns the heading text with original case preserved, or `None` if no +/// `# Heading` is found. Only matches depth-1 headings (`#`, not `##`). +pub fn extract_first_heading(content: &str) -> Option { + let ast = markdown::to_mdast(content, &ParseOptions::gfm()).ok()?; + find_first_h1(&ast) +} + +/// Walk the AST to find the first depth-1 heading and collect its text content. +fn find_first_h1(node: &Node) -> Option { + match node { + Node::Heading(h) if h.depth == 1 => { + let text = collect_text_content(&h.children); + if text.is_empty() { None } else { Some(text) } + } + _ => { + if let Some(children) = children(node) { + for child in children { + if let Some(heading) = find_first_h1(child) { + return Some(heading); + } + } + } + None + } + } +} + +/// Recursively collect all text content from AST nodes. +fn collect_text_content(nodes: &[Node]) -> String { + let mut text = String::new(); + for node in nodes { + match node { + Node::Text(t) => text.push_str(&t.value), + Node::InlineCode(c) => text.push_str(&c.value), + other => { + if let Some(children) = children(other) { + text.push_str(&collect_text_content(children)); + } + } + } + } + text +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BlockKind { Paragraph, @@ -555,4 +601,34 @@ mod tests { let normalized = normalize_markdown(input).unwrap(); assert!(normalized.blocks.len() >= 2); } + + #[test] + fn extract_first_heading_h1() { + let input = "# Bun Package Manager\n\nsynonyms:: npm, yarn\n"; + assert_eq!( + extract_first_heading(input), + Some("Bun Package Manager".to_string()) + ); + } + + #[test] + fn extract_first_heading_skips_h2() { + let input = "## Not This\n\n# This One\n"; + assert_eq!(extract_first_heading(input), Some("This One".to_string())); + } + + #[test] + fn extract_first_heading_none_when_absent() { + let input = "Just some text\n\n## Only H2\n"; + assert_eq!(extract_first_heading(input), None); + } + + #[test] + fn extract_first_heading_with_inline_code() { + let input = "# The `bun` Runtime\n"; + assert_eq!( + extract_first_heading(input), + Some("The bun Runtime".to_string()) + ); + } } diff --git a/crates/terraphim_automata/Cargo.toml b/crates/terraphim_automata/Cargo.toml index 9e7448637..9b3bc5fc8 100644 --- a/crates/terraphim_automata/Cargo.toml +++ b/crates/terraphim_automata/Cargo.toml @@ -14,6 +14,7 @@ readme = "README.md" [dependencies] terraphim_types = { path = "../terraphim_types", version = "1.0.0" } +terraphim-markdown-parser = { path = "../terraphim-markdown-parser", version = "1.0.0" } ahash = { version = "0.8.6", features = ["serde"] } aho-corasick = "1.0.2" diff --git a/crates/terraphim_automata/src/markdown_directives.rs b/crates/terraphim_automata/src/markdown_directives.rs index d7499d99b..da5a9da31 100644 --- a/crates/terraphim_automata/src/markdown_directives.rs +++ b/crates/terraphim_automata/src/markdown_directives.rs @@ -86,16 +86,12 @@ pub fn parse_markdown_directives_dir(root: &Path) -> crate::Result Option { let content = fs::read_to_string(path).ok()?; - content - .lines() - .map(|l| l.trim()) - .find(|l| l.starts_with("# ")) - .map(|l| l.trim_start_matches("# ").trim().to_string()) - .filter(|h| !h.is_empty()) + terraphim_markdown_parser::extract_first_heading(&content) } fn parse_markdown_directives_content( @@ -109,7 +105,9 @@ fn parse_markdown_directives_content( let mut priority: Option = None; let mut trigger: Option = None; let mut pinned: bool = false; - let mut heading: Option = None; + + // Use AST parser for proper heading extraction (handles inline code, emphasis, etc.) + let heading = terraphim_markdown_parser::extract_first_heading(content); for (idx, line) in content.lines().enumerate() { let trimmed = line.trim(); @@ -117,15 +115,6 @@ fn parse_markdown_directives_content( continue; } - // Extract first `# Heading` (H1 only), preserving original case. - if heading.is_none() && trimmed.starts_with("# ") { - let h = trimmed.trim_start_matches("# ").trim(); - if !h.is_empty() { - heading = Some(h.to_string()); - } - continue; - } - let lower = trimmed.to_ascii_lowercase(); if lower.starts_with("type:::") { if doc_type.is_some() { From 4d2f2b2ca17fa72a614fa009695594a6ed4a3c47 Mon Sep 17 00:00:00 2001 From: Terraphim CI Date: Mon, 6 Apr 2026 00:07:07 +0100 Subject: [PATCH 3/3] fix(ci): resolve pre-existing CI failures blocking PR #758 - cargo fmt: reformat 4 files for edition 2024 style (wiki_sync, store, symbolic_embedding_bench, gitea) - clippy: gate load_restart_counts with #[cfg(not(test))] to match its only call site (was dead code in test builds) - wasm: replace workspace = true with explicit versions in standalone wasm-test/Cargo.toml (standalone workspace cannot inherit) Refs #395 Co-Authored-By: Claude Opus 4.6 --- .../src/shared_learning/store.rs | 14 ++++--- .../src/shared_learning/wiki_sync.rs | 42 +++++++++++-------- .../terraphim_automata/wasm-test/Cargo.toml | 8 ++-- crates/terraphim_orchestrator/src/lib.rs | 1 + .../benches/symbolic_embedding_bench.rs | 2 +- crates/terraphim_tracker/src/gitea.rs | 6 ++- 6 files changed, 44 insertions(+), 29 deletions(-) diff --git a/crates/terraphim_agent/src/shared_learning/store.rs b/crates/terraphim_agent/src/shared_learning/store.rs index 2447c228e..8f46fd2f9 100644 --- a/crates/terraphim_agent/src/shared_learning/store.rs +++ b/crates/terraphim_agent/src/shared_learning/store.rs @@ -12,9 +12,7 @@ use thiserror::Error; use tokio::sync::RwLock; use tracing::{debug, info}; -use crate::shared_learning::types::{ - LearningSource, SharedLearning, TrustLevel, -}; +use crate::shared_learning::types::{LearningSource, SharedLearning, TrustLevel}; #[derive(Error, Debug)] pub enum StoreError { @@ -262,7 +260,10 @@ impl SharedLearningStore { if let Some((existing_id, score)) = best_match { if score >= self.config.similarity_threshold { - debug!("Merging with existing learning {} (score={:.3})", existing_id, score); + debug!( + "Merging with existing learning {} (score={:.3})", + existing_id, score + ); self.merge_learning(&existing_id, &learning).await?; return Ok(StoreResult::Merged(existing_id)); } @@ -626,7 +627,10 @@ mod tests { store.insert(learning).await.unwrap(); - let suggestions = store.suggest("git push problems", "test-agent", 5).await.unwrap(); + let suggestions = store + .suggest("git push problems", "test-agent", 5) + .await + .unwrap(); assert!(!suggestions.is_empty()); assert_eq!(suggestions[0].title, "Git Push Error"); } diff --git a/crates/terraphim_agent/src/shared_learning/wiki_sync.rs b/crates/terraphim_agent/src/shared_learning/wiki_sync.rs index 60fc07687..fe386b72a 100644 --- a/crates/terraphim_agent/src/shared_learning/wiki_sync.rs +++ b/crates/terraphim_agent/src/shared_learning/wiki_sync.rs @@ -130,12 +130,18 @@ impl GiteaWikiClient { if exists { // Update existing page self.update_wiki_page(&page_name, &content).await?; - info!("Updated wiki page for learning {}: {}", learning.id, page_name); + info!( + "Updated wiki page for learning {}: {}", + learning.id, page_name + ); Ok(SyncResult::Updated(page_name)) } else { // Create new page self.create_wiki_page(&page_name, &content).await?; - info!("Created wiki page for learning {}: {}", learning.id, page_name); + info!( + "Created wiki page for learning {}: {}", + learning.id, page_name + ); Ok(SyncResult::Created(page_name)) } } @@ -155,7 +161,9 @@ impl GiteaWikiClient { page_name, ]) .output() - .map_err(|e| WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)))?; + .map_err(|e| { + WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)) + })?; if output.status.success() { Ok(true) @@ -170,11 +178,7 @@ impl GiteaWikiClient { } /// Create a new wiki page - async fn create_wiki_page( - &self, - page_name: &str, - content: &str, - ) -> Result<(), WikiSyncError> { + async fn create_wiki_page(&self, page_name: &str, content: &str) -> Result<(), WikiSyncError> { let output = Command::new(&self.config.robot_path) .env("GITEA_URL", &self.config.gitea_url) .env("GITEA_TOKEN", &self.config.token) @@ -192,7 +196,9 @@ impl GiteaWikiClient { &format!("Add shared learning: {}", page_name), ]) .output() - .map_err(|e| WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)))?; + .map_err(|e| { + WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)) + })?; if output.status.success() { Ok(()) @@ -207,11 +213,7 @@ impl GiteaWikiClient { } /// Update an existing wiki page - async fn update_wiki_page( - &self, - page_name: &str, - content: &str, - ) -> Result<(), WikiSyncError> { + async fn update_wiki_page(&self, page_name: &str, content: &str) -> Result<(), WikiSyncError> { let output = Command::new(&self.config.robot_path) .env("GITEA_URL", &self.config.gitea_url) .env("GITEA_TOKEN", &self.config.token) @@ -229,7 +231,9 @@ impl GiteaWikiClient { &format!("Update shared learning: {}", page_name), ]) .output() - .map_err(|e| WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)))?; + .map_err(|e| { + WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)) + })?; if output.status.success() { Ok(()) @@ -258,7 +262,9 @@ impl GiteaWikiClient { page_name, ]) .output() - .map_err(|e| WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)))?; + .map_err(|e| { + WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)) + })?; if output.status.success() { info!("Deleted wiki page: {}", page_name); @@ -299,7 +305,9 @@ impl GiteaWikiClient { &self.config.repo, ]) .output() - .map_err(|e| WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)))?; + .map_err(|e| { + WikiSyncError::GiteaRobot(format!("Failed to execute gitea-robot: {}", e)) + })?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); diff --git a/crates/terraphim_automata/wasm-test/Cargo.toml b/crates/terraphim_automata/wasm-test/Cargo.toml index 1819a2624..5ce543ddb 100644 --- a/crates/terraphim_automata/wasm-test/Cargo.toml +++ b/crates/terraphim_automata/wasm-test/Cargo.toml @@ -14,14 +14,12 @@ crate-type = ["cdylib", "rlib"] [dependencies] terraphim_automata = { path = "..", features = ["wasm"] } wasm-bindgen = { version = "0.2", features = ["serde-serialize"] } -serde = { workspace = true, features = ["derive"] } - -serde_json = { workspace = true } - +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" serde-wasm-bindgen = "0.6" console_error_panic_hook = "0.1" wasm-logger = "0.2" -log = { workspace = true } +log = "0.4" js-sys = "0.3" web-sys = { version = "0.3", features = ["console"] } diff --git a/crates/terraphim_orchestrator/src/lib.rs b/crates/terraphim_orchestrator/src/lib.rs index 982e5e7da..3bd4409ee 100644 --- a/crates/terraphim_orchestrator/src/lib.rs +++ b/crates/terraphim_orchestrator/src/lib.rs @@ -304,6 +304,7 @@ impl AgentOrchestrator { /// Load persisted restart counts from a JSON file in the working directory. /// Returns empty HashMap if file doesn't exist or can't be parsed. + #[cfg(not(test))] fn load_restart_counts() -> HashMap { let path = std::env::temp_dir().join("adf_restart_counts.json"); match std::fs::read_to_string(&path) { diff --git a/crates/terraphim_rolegraph/benches/symbolic_embedding_bench.rs b/crates/terraphim_rolegraph/benches/symbolic_embedding_bench.rs index e4f00090d..9a64ea762 100644 --- a/crates/terraphim_rolegraph/benches/symbolic_embedding_bench.rs +++ b/crates/terraphim_rolegraph/benches/symbolic_embedding_bench.rs @@ -4,7 +4,7 @@ //! and similarity queries at various scales. use ahash::{AHashMap, AHashSet}; -use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; use terraphim_rolegraph::symbolic_embeddings::SymbolicEmbeddingIndex; use terraphim_types::MedicalNodeType; diff --git a/crates/terraphim_tracker/src/gitea.rs b/crates/terraphim_tracker/src/gitea.rs index 746b36b08..7ac215991 100644 --- a/crates/terraphim_tracker/src/gitea.rs +++ b/crates/terraphim_tracker/src/gitea.rs @@ -79,7 +79,11 @@ impl GiteaTracker { } /// Build request with authentication. - pub(crate) fn build_request(&self, method: reqwest::Method, url: &str) -> reqwest::RequestBuilder { + pub(crate) fn build_request( + &self, + method: reqwest::Method, + url: &str, + ) -> reqwest::RequestBuilder { self.client .request(method, url) .header("Authorization", format!("token {}", self.config.token))