diff --git a/crates/temper-server/src/observe/evolution/insight_generator.rs b/crates/temper-server/src/observe/evolution/insight_generator.rs index c5a83255..71ffd4d5 100644 --- a/crates/temper-server/src/observe/evolution/insight_generator.rs +++ b/crates/temper-server/src/observe/evolution/insight_generator.rs @@ -1,17 +1,22 @@ -//! Trajectory → InsightRecord pipeline. +//! Trajectory -> InsightRecord pipeline. //! Aggregates trajectory log entries by `(entity_type, action)` and generates //! `InsightRecord`s using `temper-evolution` classification and priority scoring. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; + use tracing::instrument; use temper_evolution::insight::{classify_insight, compute_priority_score}; -use temper_evolution::records::{ - FeatureRequestDisposition, FeatureRequestRecord, InsightRecord, InsightSignal, - PlatformGapCategory, RecordHeader, RecordType, -}; +use temper_evolution::records::{InsightRecord, InsightSignal, RecordHeader, RecordType}; + +mod gap_analysis; +mod intent_evidence; +#[cfg(test)] +#[path = "insight_generator/mod_test.rs"] +mod tests; -use crate::state::trajectory::TrajectorySource; +pub(crate) use gap_analysis::{generate_feature_requests, generate_unmet_intents_from_aggregated}; +pub(crate) use intent_evidence::generate_intent_evidence; /// Build an `InsightRecord` from a signal, recommendation, and optional priority override. fn build_insight( @@ -56,7 +61,6 @@ pub(crate) fn generate_insights(entries: &[crate::state::TrajectoryEntry]) -> Ve } tracing::info!(entry_count = entries.len(), "evolution.insight"); - // Phase 1: Aggregate by (entity_type, action). let mut signals: BTreeMap<(String, String), TrajectorySignal> = BTreeMap::new(); for entry in entries { @@ -93,11 +97,10 @@ pub(crate) fn generate_insights(entries: &[crate::state::TrajectoryEntry]) -> Ve } } - // Phase 2: Cross-reference — find entity types with SubmitSpec events. let submitted_types: std::collections::BTreeSet = signals .values() - .filter(|s| s.has_submit_spec) - .map(|s| s.entity_type.clone()) + .filter(|signal| signal.has_submit_spec) + .map(|signal| signal.entity_type.clone()) .collect(); tracing::info!( signal_count = signals.len(), @@ -105,11 +108,9 @@ pub(crate) fn generate_insights(entries: &[crate::state::TrajectoryEntry]) -> Ve "evolution.insight" ); - // Phase 3: Generate insights. let mut insights = Vec::new(); for signal in signals.values() { - // Skip very low-volume signals. if signal.total < 2 { continue; } @@ -120,7 +121,6 @@ pub(crate) fn generate_insights(entries: &[crate::state::TrajectoryEntry]) -> Ve 0.0 }; - // Special handling for EntitySetNotFound patterns. if signal.has_entity_not_found { let resolved = submitted_types.contains(&signal.entity_type); let intent_str = format!( @@ -153,7 +153,6 @@ pub(crate) fn generate_insights(entries: &[crate::state::TrajectoryEntry]) -> Ve trend: temper_evolution::Trend::Stable, growth_rate: None, }; - let priority = if resolved { compute_priority_score(&insight_signal) * 0.5 } else { @@ -167,6 +166,7 @@ pub(crate) fn generate_insights(entries: &[crate::state::TrajectoryEntry]) -> Ve "low" }; let category = classify_insight(&insight_signal); + if resolved { tracing::info!( entity_type = %signal.entity_type, @@ -194,6 +194,7 @@ pub(crate) fn generate_insights(entries: &[crate::state::TrajectoryEntry]) -> Ve "evolution.pattern" ); } + insights.push(build_insight( insight_signal, recommendation, @@ -202,7 +203,6 @@ pub(crate) fn generate_insights(entries: &[crate::state::TrajectoryEntry]) -> Ve continue; } - // Special handling for authz denial patterns. if signal.authz_denials > 0 && signal.authz_denials as f64 / signal.total as f64 > 0.3 { let intent_str = format!( "Action '{}' on '{}' denied {} times", @@ -245,7 +245,6 @@ pub(crate) fn generate_insights(entries: &[crate::state::TrajectoryEntry]) -> Ve continue; } - // General pattern detection. let insight_signal = InsightSignal { intent: format!("{}.{}", signal.entity_type, signal.action), volume: signal.total, @@ -255,7 +254,6 @@ pub(crate) fn generate_insights(entries: &[crate::state::TrajectoryEntry]) -> Ve }; let priority = compute_priority_score(&insight_signal); - // Only emit insights for meaningful signals. if priority < 0.1 { continue; } @@ -320,7 +318,6 @@ pub(crate) fn generate_insights(entries: &[crate::state::TrajectoryEntry]) -> Ve )); } - // Sort by priority (highest first). insights.sort_by(|a, b| { b.priority_score .partial_cmp(&a.priority_score) @@ -331,1493 +328,21 @@ pub(crate) fn generate_insights(entries: &[crate::state::TrajectoryEntry]) -> Ve insights } -/// A grouped unmet intent from trajectory data. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub(crate) struct UnmetIntent { - /// Entity type involved. - pub entity_type: String, - /// Representative action. - pub action: String, - /// Error pattern category. - pub error_pattern: String, - /// Number of failures. - pub failure_count: u64, - /// First occurrence timestamp. - pub first_seen: String, - /// Most recent occurrence timestamp. - pub last_seen: String, - /// "open" or "resolved". - pub status: String, - /// What resolved it (e.g. spec submission timestamp). - pub resolved_by: Option, - /// Recommendation text. - pub recommendation: String, - /// Sample request body from the most recent failure. - #[serde(skip_serializing_if = "Option::is_none")] - pub sample_body: Option, - /// Sample intent from X-Intent header. - #[serde(skip_serializing_if = "Option::is_none")] - pub sample_intent: Option, -} - -/// Accumulator for unmet-intent grouping. -struct UnmetIntentAccum { - entity_type: String, - action: String, - error_pattern: String, - count: u64, - first_seen: String, - last_seen: String, - sample_body: Option, - sample_intent: Option, -} - -/// Richer unmet-intent evidence derived from recent trajectories. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub(crate) struct IntentEvidenceSummary { - pub intent_candidates: Vec, - pub workaround_patterns: Vec, - pub abandonment_patterns: Vec, - pub trajectory_samples: Vec, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub(crate) struct IntentCandidate { - pub intent_key: String, - pub intent_title: String, - pub intent_statement: String, - pub recommended_issue_title: String, - pub symptom_title: String, - pub suggested_kind: String, - pub status: String, - pub entity_types: Vec, - pub attempted_actions: Vec, - pub successful_actions: Vec, - pub failure_patterns: Vec, - pub total_count: u64, - pub failure_count: u64, - pub success_count: u64, - pub authz_denials: u64, - pub workaround_count: u64, - pub abandonment_count: u64, - pub success_after_failure_count: u64, - pub success_rate: f64, - pub first_seen: String, - pub last_seen: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub sample_intent: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub sample_body: Option, - pub sample_agents: Vec, - pub recommendation: String, - pub problem_statement: String, - pub logfire_query_hint: serde_json::Value, - pub evidence_examples: Vec, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub(crate) struct WorkaroundPattern { - pub intent_key: String, - pub intent_title: String, - pub failed_actions: Vec, - pub successful_actions: Vec, - pub occurrences: u64, - pub sample_agents: Vec, - pub last_seen: String, - pub recommendation: String, - pub logfire_query_hint: serde_json::Value, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub(crate) struct AbandonmentPattern { - pub intent_key: String, - pub intent_title: String, - pub failed_actions: Vec, - pub abandonment_count: u64, - pub sample_agents: Vec, - pub first_seen: String, - pub last_seen: String, - pub recommendation: String, - pub logfire_query_hint: serde_json::Value, -} - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub(crate) struct TrajectorySample { - pub timestamp: String, - pub entity_type: String, - pub action: String, - pub success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub error_pattern: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub intent: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub agent_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub session_id: Option, -} - -struct IntentCandidateAccum { - intent_key: String, - intent_title: String, - intent_statement: String, - recommended_issue_title: String, - symptom_title: String, - entity_types: BTreeSet, - attempted_actions: BTreeSet, - successful_actions: BTreeSet, - failure_patterns: BTreeSet, - sample_intent: Option, - sample_body: Option, - sample_agents: BTreeSet, - total_count: u64, - failure_count: u64, - success_count: u64, - authz_denials: u64, - workaround_count: u64, - abandonment_count: u64, - success_after_failure_count: u64, - first_seen: String, - last_seen: String, - evidence_examples: Vec, -} - -struct PendingFailure { - intent_key: String, - failed_actions: BTreeSet, - agent_id: Option, - first_seen: String, - last_seen: String, -} - -struct WorkaroundAccum { - intent_key: String, - intent_title: String, - failed_actions: BTreeSet, - successful_actions: BTreeSet, - sample_agents: BTreeSet, - occurrences: u64, - last_seen: String, -} - -struct AbandonmentAccum { - intent_key: String, - intent_title: String, - failed_actions: BTreeSet, - sample_agents: BTreeSet, - abandonment_count: u64, - first_seen: String, - last_seen: String, -} - -/// Generate unmet intent summaries from trajectory data. -/// -/// Groups failed trajectories by error pattern and cross-references with -/// SubmitSpec events to determine open vs resolved status. -/// -/// This path is superseded in production by [`generate_unmet_intents_from_aggregated`] -/// (SQL GROUP BY). Retained for unit tests that exercise the aggregation logic -/// against in-memory trajectory slices. -#[cfg_attr(not(test), allow(dead_code))] -#[instrument(skip_all, fields(entry_count = entries.len(), intent_count = tracing::field::Empty))] -pub(crate) fn generate_unmet_intents( - entries: &[crate::state::TrajectoryEntry], -) -> Vec { - // Track entity types that have had specs submitted. - let mut submitted_specs: BTreeMap = BTreeMap::new(); - // Track failed patterns by (entity_type, error_pattern). - let mut failures: BTreeMap<(String, String), UnmetIntentAccum> = BTreeMap::new(); - - for entry in entries { - if entry.action == "SubmitSpec" && entry.success { - submitted_specs.insert(entry.entity_type.clone(), entry.timestamp.clone()); - continue; - } - - if !entry.success { - let error_pattern = categorize_error(entry.error.as_deref()); - - // AuthzDenied = governance decision, not a capability gap. - // These belong in the Decisions view, not Unmet Intents. - if error_pattern == "AuthzDenied" || entry.authz_denied == Some(true) { - continue; - } - - let key = (entry.entity_type.clone(), error_pattern.clone()); - let accum = failures.entry(key).or_insert_with(|| UnmetIntentAccum { - entity_type: entry.entity_type.clone(), - action: entry.action.clone(), - error_pattern, - count: 0, - first_seen: entry.timestamp.clone(), - last_seen: entry.timestamp.clone(), - sample_body: None, - sample_intent: None, - }); - accum.count += 1; - accum.last_seen = entry.timestamp.clone(); - // Capture the most recent non-None body/intent as sample. - if entry.request_body.is_some() { - accum.sample_body = entry.request_body.clone(); - } - if entry.intent.is_some() { - accum.sample_intent = entry.intent.clone(); - } - } - } - - let intents: Vec = failures - .into_values() - .map(|accum| { - let resolved = submitted_specs.contains_key(&accum.entity_type); - let resolved_by = submitted_specs.get(&accum.entity_type).cloned(); - let recommendation = if resolved { - format!("Spec for '{}' has been submitted.", accum.entity_type) - } else { - match accum.error_pattern.as_str() { - "EntitySetNotFound" => { - format!("Consider creating '{}' entity type.", accum.entity_type,) - } - _ => format!( - "Investigate failures for '{}' (pattern: {}).", - accum.entity_type, accum.error_pattern, - ), - } - }; - - UnmetIntent { - entity_type: accum.entity_type, - action: accum.action, - error_pattern: accum.error_pattern, - failure_count: accum.count, - first_seen: accum.first_seen, - last_seen: accum.last_seen, - status: if resolved { - "resolved".to_string() - } else { - "open".to_string() - }, - resolved_by, - recommendation, - sample_body: accum.sample_body, - sample_intent: accum.sample_intent, - } - }) - .collect(); - let open_count = intents.iter().filter(|i| i.status == "open").count(); - let resolved_count = intents.iter().filter(|i| i.status == "resolved").count(); - tracing::Span::current().record("intent_count", intents.len()); - if open_count > 0 { - tracing::warn!( - entry_count = entries.len(), - intents_count = intents.len(), - open_count, - resolved_count, - "unmet_intent" - ); - } else { - tracing::info!( - entry_count = entries.len(), - intents_count = intents.len(), - open_count, - resolved_count, - "unmet_intent" - ); - } - intents -} - -/// Generate richer, intent-shaped evidence from recent trajectories. -/// -/// Unlike `generate_unmet_intents_from_aggregated`, this path intentionally -/// loads bounded raw trajectories so the evolution analyst can reason about: -/// - explicit caller intents (`X-Intent`) -/// - repeated failures around the same intended outcome -/// - workaround sequences (failure followed by alternate success) -/// - abandonment candidates (failed attempts that never recover) -#[instrument(skip_all, fields(entry_count = entries.len(), candidate_count = tracing::field::Empty))] -pub(crate) fn generate_intent_evidence( - entries: &[crate::state::TrajectoryEntry], -) -> IntentEvidenceSummary { - if entries.is_empty() { - return IntentEvidenceSummary { - intent_candidates: Vec::new(), - workaround_patterns: Vec::new(), - abandonment_patterns: Vec::new(), - trajectory_samples: Vec::new(), - }; - } - - let mut sorted_entries = entries.to_vec(); - sorted_entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); - - let mut candidates = BTreeMap::::new(); - let mut pending_failures = BTreeMap::<(String, String), PendingFailure>::new(); - let mut workarounds = BTreeMap::::new(); - let mut abandonments = BTreeMap::::new(); - - for entry in &sorted_entries { - let intent_key = derive_intent_key(entry); - let intent_title = - derive_intent_title(entry.intent.as_deref(), &entry.entity_type, &entry.action); - let intent_statement = entry - .intent - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| derive_intent_statement(&entry.entity_type, &entry.action)); - let symptom_title = derive_symptom_title(entry); - let issue_title = derive_issue_title( - &intent_title, - entry.intent.as_deref(), - &entry.entity_type, - &entry.action, - ); - let sample = sample_from_entry(entry); - let accum = candidates - .entry(intent_key.clone()) - .or_insert_with(|| IntentCandidateAccum { - intent_key: intent_key.clone(), - intent_title: intent_title.clone(), - intent_statement: intent_statement.clone(), - recommended_issue_title: issue_title.clone(), - symptom_title: symptom_title.clone(), - entity_types: BTreeSet::new(), - attempted_actions: BTreeSet::new(), - successful_actions: BTreeSet::new(), - failure_patterns: BTreeSet::new(), - sample_intent: None, - sample_body: None, - sample_agents: BTreeSet::new(), - total_count: 0, - failure_count: 0, - success_count: 0, - authz_denials: 0, - workaround_count: 0, - abandonment_count: 0, - success_after_failure_count: 0, - first_seen: entry.timestamp.clone(), - last_seen: entry.timestamp.clone(), - evidence_examples: Vec::new(), - }); - - accum.total_count += 1; - accum.entity_types.insert(entry.entity_type.clone()); - accum.attempted_actions.insert(entry.action.clone()); - accum.last_seen = entry.timestamp.clone(); - if entry.timestamp < accum.first_seen { - accum.first_seen = entry.timestamp.clone(); - } - if let Some(agent_id) = entry - .agent_id - .as_deref() - .filter(|value| !value.trim().is_empty()) - { - accum.sample_agents.insert(agent_id.to_string()); - } - if let Some(intent) = entry - .intent - .as_deref() - .filter(|value| !value.trim().is_empty()) - { - accum.sample_intent = Some(intent.to_string()); - } - if entry.request_body.is_some() { - accum.sample_body = entry.request_body.clone(); - } - - if accum.evidence_examples.len() < 4 || !entry.success { - accum.evidence_examples.push(sample.clone()); - accum.evidence_examples.truncate(4); - } - - if entry.success { - accum.success_count += 1; - accum.successful_actions.insert(entry.action.clone()); - } else { - accum.failure_count += 1; - let error_pattern = categorize_error(entry.error.as_deref()); - let is_authz_denied = error_pattern == "AuthzDenied"; - accum.failure_patterns.insert(error_pattern); - if entry.authz_denied == Some(true) || is_authz_denied { - accum.authz_denials += 1; - } - } - - let actor_key = actor_intent_key(entry); - if entry.success { - if let Some(pending) = pending_failures.remove(&(actor_key.clone(), intent_key.clone())) - { - if pending - .failed_actions - .iter() - .any(|action| action != &entry.action) - { - accum.workaround_count += 1; - accum.success_after_failure_count += 1; - let workaround_key = format!( - "{}::{}", - intent_key, - normalize_for_key(&format!( - "{}->{}", - join_set(&pending.failed_actions), - entry.action - )) - ); - let workaround = - workarounds - .entry(workaround_key) - .or_insert_with(|| WorkaroundAccum { - intent_key: intent_key.clone(), - intent_title: intent_title.clone(), - failed_actions: pending.failed_actions.clone(), - successful_actions: BTreeSet::new(), - sample_agents: BTreeSet::new(), - occurrences: 0, - last_seen: entry.timestamp.clone(), - }); - workaround.occurrences += 1; - workaround.last_seen = entry.timestamp.clone(); - workaround.successful_actions.insert(entry.action.clone()); - if let Some(agent_id) = pending - .agent_id - .as_deref() - .filter(|value| !value.trim().is_empty()) - { - workaround.sample_agents.insert(agent_id.to_string()); - } - if let Some(agent_id) = entry - .agent_id - .as_deref() - .filter(|value| !value.trim().is_empty()) - { - workaround.sample_agents.insert(agent_id.to_string()); - } - } else { - accum.success_after_failure_count += 1; - } - } - } else { - let pending = pending_failures - .entry((actor_key, intent_key.clone())) - .or_insert_with(|| PendingFailure { - intent_key: intent_key.clone(), - failed_actions: BTreeSet::new(), - agent_id: entry.agent_id.clone(), - first_seen: entry.timestamp.clone(), - last_seen: entry.timestamp.clone(), - }); - pending.failed_actions.insert(entry.action.clone()); - pending.last_seen = entry.timestamp.clone(); - if entry.timestamp < pending.first_seen { - pending.first_seen = entry.timestamp.clone(); - } - } - } - - for pending in pending_failures.into_values() { - if let Some(candidate) = candidates.get_mut(&pending.intent_key) { - candidate.abandonment_count += 1; - } - let abandonment = abandonments - .entry(pending.intent_key.clone()) - .or_insert_with(|| AbandonmentAccum { - intent_key: pending.intent_key.clone(), - intent_title: candidates - .get(&pending.intent_key) - .map(|value| value.intent_title.clone()) - .unwrap_or_else(|| "Investigate unmet intent".to_string()), - failed_actions: BTreeSet::new(), - sample_agents: BTreeSet::new(), - abandonment_count: 0, - first_seen: pending.first_seen.clone(), - last_seen: pending.last_seen.clone(), - }); - abandonment.abandonment_count += 1; - abandonment - .failed_actions - .extend(pending.failed_actions.into_iter()); - abandonment.last_seen = pending.last_seen.clone(); - if pending.first_seen < abandonment.first_seen { - abandonment.first_seen = pending.first_seen.clone(); - } - if let Some(agent_id) = pending - .agent_id - .as_deref() - .filter(|value| !value.trim().is_empty()) - { - abandonment.sample_agents.insert(agent_id.to_string()); - } - } - - let mut intent_candidates = candidates - .into_values() - .filter(|candidate| { - candidate.failure_count > 0 - || candidate.workaround_count > 0 - || candidate.abandonment_count > 0 - }) - .map(finalize_intent_candidate) - .collect::>(); - intent_candidates.sort_by(|a, b| { - score_intent_candidate(b) - .cmp(&score_intent_candidate(a)) - .then_with(|| b.last_seen.cmp(&a.last_seen)) - }); - intent_candidates.truncate(12); - - let mut workaround_patterns = workarounds - .into_values() - .map(finalize_workaround_pattern) - .collect::>(); - workaround_patterns.sort_by(|a, b| { - b.occurrences - .cmp(&a.occurrences) - .then_with(|| b.last_seen.cmp(&a.last_seen)) - }); - workaround_patterns.truncate(8); - - let mut abandonment_patterns = abandonments - .into_values() - .map(finalize_abandonment_pattern) - .collect::>(); - abandonment_patterns.sort_by(|a, b| { - b.abandonment_count - .cmp(&a.abandonment_count) - .then_with(|| b.last_seen.cmp(&a.last_seen)) - }); - abandonment_patterns.truncate(8); - - let trajectory_samples = sorted_entries - .iter() - .rev() - .take(20) - .map(sample_from_entry) - .collect::>(); - - tracing::Span::current().record("candidate_count", intent_candidates.len()); - - IntentEvidenceSummary { - intent_candidates, - workaround_patterns, - abandonment_patterns, - trajectory_samples, - } -} - -fn finalize_intent_candidate(candidate: IntentCandidateAccum) -> IntentCandidate { - let success_rate = if candidate.total_count == 0 { - 0.0 - } else { - candidate.success_count as f64 / candidate.total_count as f64 - }; - let suggested_kind = if candidate.authz_denials > 0 - && candidate.authz_denials - >= candidate - .failure_count - .saturating_sub(candidate.success_count) - { - "governance_gap".to_string() - } else if candidate.workaround_count > 0 { - "workaround".to_string() - } else if candidate - .failure_patterns - .iter() - .any(|pattern| matches!(pattern.as_str(), "EntitySetNotFound" | "ActionNotFound")) - { - "missing_capability".to_string() - } else { - "friction".to_string() - }; - let status = if candidate.failure_count == 0 { - "resolved" - } else if candidate.workaround_count > 0 { - "workaround" - } else if candidate.success_count > 0 { - "mixed" - } else { - "open" - } - .to_string(); - let hint_entity_type = candidate.entity_types.iter().next().cloned(); - let hint_action = candidate.attempted_actions.iter().next().cloned(); - let hint_intent = candidate.sample_intent.clone(); - let recommendation = match suggested_kind.as_str() { - "governance_gap" => format!( - "Align policy with the intended '{}' workflow and keep the scope limited to the minimum required principals/resources.", - candidate.intent_title - ), - "workaround" => format!( - "Promote the successful workaround into a first-class capability for '{}', so users stop relying on alternate action chains.", - candidate.intent_title - ), - "friction" => format!( - "Collapse the repeated multi-step flow behind '{}' into a simpler supported path.", - candidate.intent_title - ), - _ => format!( - "Add direct product/spec support for '{}'.", - candidate.intent_title - ), - }; - let problem_statement = match suggested_kind.as_str() { - "governance_gap" => format!( - "The intended outcome '{}' is blocked by repeated authorization denials across the current workflow.", - candidate.intent_statement - ), - "workaround" => format!( - "Users and agents are trying to achieve '{}' and are only succeeding through alternate action paths rather than a direct capability.", - candidate.intent_statement - ), - "friction" => format!( - "The intended outcome '{}' is possible, but only after repeated retries or unnecessary extra steps.", - candidate.intent_statement - ), - _ => format!( - "The intended outcome '{}' is not directly supported by the current product/spec surface.", - candidate.intent_statement - ), - }; - - IntentCandidate { - intent_key: candidate.intent_key.clone(), - intent_title: candidate.intent_title.clone(), - intent_statement: candidate.intent_statement, - recommended_issue_title: candidate.recommended_issue_title, - symptom_title: candidate.symptom_title, - suggested_kind: suggested_kind.clone(), - status, - entity_types: candidate.entity_types.into_iter().collect(), - attempted_actions: candidate.attempted_actions.iter().cloned().collect(), - successful_actions: candidate.successful_actions.iter().cloned().collect(), - failure_patterns: candidate.failure_patterns.iter().cloned().collect(), - total_count: candidate.total_count, - failure_count: candidate.failure_count, - success_count: candidate.success_count, - authz_denials: candidate.authz_denials, - workaround_count: candidate.workaround_count, - abandonment_count: candidate.abandonment_count, - success_after_failure_count: candidate.success_after_failure_count, - success_rate, - first_seen: candidate.first_seen, - last_seen: candidate.last_seen, - sample_intent: candidate.sample_intent, - sample_body: candidate.sample_body, - sample_agents: candidate.sample_agents.iter().cloned().collect(), - recommendation, - problem_statement, - logfire_query_hint: build_logfire_query_hint( - &suggested_kind, - hint_entity_type.as_deref(), - hint_action.as_deref(), - hint_intent.as_deref(), - ), - evidence_examples: candidate.evidence_examples, - } -} - -fn finalize_workaround_pattern(pattern: WorkaroundAccum) -> WorkaroundPattern { - WorkaroundPattern { - intent_key: pattern.intent_key.clone(), - intent_title: pattern.intent_title.clone(), - failed_actions: pattern.failed_actions.iter().cloned().collect(), - successful_actions: pattern.successful_actions.iter().cloned().collect(), - occurrences: pattern.occurrences, - sample_agents: pattern.sample_agents.iter().cloned().collect(), - last_seen: pattern.last_seen, - recommendation: format!( - "Inspect '{}' and graduate the successful alternate path into a supported single-step workflow.", - pattern.intent_title - ), - logfire_query_hint: build_logfire_query_hint( - "alternate_success_paths", - None, - pattern.failed_actions.iter().next().map(String::as_str), - Some(pattern.intent_title.as_str()), - ), - } -} - -fn finalize_abandonment_pattern(pattern: AbandonmentAccum) -> AbandonmentPattern { - AbandonmentPattern { - intent_key: pattern.intent_key.clone(), - intent_title: pattern.intent_title.clone(), - failed_actions: pattern.failed_actions.iter().cloned().collect(), - abandonment_count: pattern.abandonment_count, - sample_agents: pattern.sample_agents.iter().cloned().collect(), - first_seen: pattern.first_seen, - last_seen: pattern.last_seen, - recommendation: format!( - "Investigate why '{}' never reaches a successful outcome after the observed failed attempts.", - pattern.intent_title - ), - logfire_query_hint: build_logfire_query_hint( - "intent_abandonment", - None, - pattern.failed_actions.iter().next().map(String::as_str), - Some(pattern.intent_title.as_str()), - ), - } -} - -fn sample_from_entry(entry: &crate::state::TrajectoryEntry) -> TrajectorySample { - TrajectorySample { - timestamp: entry.timestamp.clone(), - entity_type: entry.entity_type.clone(), - action: entry.action.clone(), - success: entry.success, - error_pattern: (!entry.success).then(|| categorize_error(entry.error.as_deref())), - error: entry.error.clone(), - intent: entry.intent.clone(), - agent_id: entry.agent_id.clone(), - session_id: entry.session_id.clone(), - } -} - -fn score_intent_candidate(candidate: &IntentCandidate) -> u64 { - candidate.failure_count.saturating_mul(4) - + candidate.workaround_count.saturating_mul(5) - + candidate.abandonment_count.saturating_mul(4) - + candidate.authz_denials.saturating_mul(3) - + candidate.success_after_failure_count.saturating_mul(2) -} - -fn actor_intent_key(entry: &crate::state::TrajectoryEntry) -> String { - let actor = entry - .session_id - .as_deref() - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) - .or_else(|| { - entry - .agent_id - .as_deref() - .filter(|value| !value.trim().is_empty()) - .map(str::to_string) - }) - .unwrap_or_else(|| "anonymous".to_string()); - format!("{actor}::{}", derive_intent_key(entry)) -} - -fn derive_intent_key(entry: &crate::state::TrajectoryEntry) -> String { - if let Some(intent) = entry - .intent - .as_deref() - .filter(|value| !value.trim().is_empty()) - { - return normalize_for_key(intent); - } - - if let Some(request_body) = entry.request_body.as_ref() { - for key in ["intent", "goal", "objective", "Title", "title"] { - if let Some(value) = request_body.get(key).and_then(serde_json::Value::as_str) - && !value.trim().is_empty() - { - return normalize_for_key(value); - } - } - } - - normalize_for_key(&derive_intent_statement(&entry.entity_type, &entry.action)) -} - -fn derive_intent_title(sample_intent: Option<&str>, entity_type: &str, action: &str) -> String { - if let Some(intent) = sample_intent.filter(|value| !value.trim().is_empty()) { - return title_case(intent); - } - - let action_lower = action.to_ascii_lowercase(); - let entity = humanize_identifier(entity_type).to_ascii_lowercase(); - if action_lower.starts_with("generate") { - return format!("Enable {entity} generation"); - } - if action_lower.starts_with("create") { - return format!("Enable {entity} creation"); - } - if let Some(target) = action - .strip_prefix("MoveTo") - .or_else(|| action.strip_prefix("moveTo")) - { - return format!( - "Allow {} to reach {}", - humanize_identifier(entity_type).to_ascii_lowercase(), - humanize_identifier(target).to_ascii_lowercase() - ); - } - - format!( - "Enable {} {} workflow", - entity, - humanize_identifier(action).to_ascii_lowercase() - ) -} - -fn derive_issue_title( - intent_title: &str, - sample_intent: Option<&str>, - entity_type: &str, - action: &str, -) -> String { - if !intent_title.trim().is_empty() { - return title_case(intent_title); - } - if let Some(intent) = sample_intent.filter(|value| !value.trim().is_empty()) { - return title_case(intent); - } - title_case(&derive_intent_statement(entity_type, action)) -} - -fn derive_intent_statement(entity_type: &str, action: &str) -> String { - let action_lower = action.to_ascii_lowercase(); - let entity = humanize_identifier(entity_type).to_ascii_lowercase(); - if action_lower.starts_with("generate") { - return format!("Generate {entity}"); - } - if action_lower.starts_with("create") { - return format!("Create {entity}"); - } - if let Some(target) = action - .strip_prefix("MoveTo") - .or_else(|| action.strip_prefix("moveTo")) - { - return format!( - "Move {} to {}", - entity, - humanize_identifier(target).to_ascii_lowercase() - ); - } - format!( - "{} {}", - humanize_identifier(action), - humanize_identifier(entity_type).to_ascii_lowercase() - ) -} - -fn derive_symptom_title(entry: &crate::state::TrajectoryEntry) -> String { - if entry.success { - return format!( - "{} succeeded via {}", - humanize_identifier(&entry.entity_type), - humanize_identifier(&entry.action) - ); - } - - let error_pattern = categorize_error(entry.error.as_deref()); - match error_pattern.as_str() { - "AuthzDenied" => format!( - "{} is denied while attempting {}", - humanize_identifier(&entry.entity_type), - humanize_identifier(&entry.action) - ), - "EntitySetNotFound" => format!( - "{} is missing for {}", - humanize_identifier(&entry.entity_type), - humanize_identifier(&entry.action) - ), - _ => format!( - "{} fails during {}", - humanize_identifier(&entry.entity_type), - humanize_identifier(&entry.action) - ), - } -} - -fn build_logfire_query_hint( - query_kind: &str, - entity_type: Option<&str>, - action: Option<&str>, - intent_text: Option<&str>, -) -> serde_json::Value { - let normalized_query_kind = match query_kind { - "workaround" => "alternate_success_paths", - "governance_gap" => "intent_failure_cluster", - other => other, - }; - let mut hint = serde_json::json!({ - "tool": "logfire_query", - "query_kind": normalized_query_kind, - "service_name": "temper-platform", - "environment": "local", - "limit": 25, - "lookback_minutes": 240, - }); - if let Some(entity_type) = entity_type.filter(|value| !value.trim().is_empty()) { - hint["entity_type"] = serde_json::json!(entity_type); - } - if let Some(action) = action.filter(|value| !value.trim().is_empty()) { - hint["action"] = serde_json::json!(action); - } - if let Some(intent_text) = intent_text.filter(|value| !value.trim().is_empty()) { - hint["intent_text"] = serde_json::json!(intent_text); - } - hint -} - -fn normalize_for_key(value: &str) -> String { - value - .trim() - .to_ascii_lowercase() - .chars() - .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) - .collect() -} - -fn humanize_identifier(value: &str) -> String { - let mut out = String::new(); - let mut previous_lowercase = false; - for ch in value.chars() { - if ch == '_' || ch == '-' { - if !out.ends_with(' ') { - out.push(' '); - } - previous_lowercase = false; - continue; - } - if ch.is_ascii_uppercase() && previous_lowercase { - out.push(' '); - } - out.push(ch.to_ascii_lowercase()); - previous_lowercase = ch.is_ascii_lowercase(); - } - out.split_whitespace().collect::>().join(" ") -} - -fn title_case(value: &str) -> String { - value - .split_whitespace() - .map(|word| { - let mut chars = word.chars(); - let Some(first) = chars.next() else { - return String::new(); - }; - format!( - "{}{}", - first.to_ascii_uppercase(), - chars.as_str().to_ascii_lowercase() - ) - }) - .collect::>() - .join(" ") -} - -fn join_set(values: &BTreeSet) -> String { - values.iter().cloned().collect::>().join(",") -} - -/// Minimum number of platform-source trajectory failures before generating a FR-Record. -const FEATURE_REQUEST_THRESHOLD: u64 = 3; - -/// Generate feature request records from platform-source trajectories. -/// -/// Filters trajectory entries with `source == Some(Platform)`, groups by -/// `(action, error_pattern)`, and creates `FeatureRequestRecord`s for groups -/// that exceed the frequency threshold. -pub(crate) fn generate_feature_requests( - entries: &[crate::state::TrajectoryEntry], -) -> Vec { - if entries.is_empty() { - return Vec::new(); - } - - // Group platform-source failures by (action, error_pattern). - let mut groups: BTreeMap<(String, String), PlatformGapAccum> = BTreeMap::new(); - - for entry in entries { - // Only consider platform-source trajectories. - if entry.source != Some(TrajectorySource::Platform) { - continue; - } - if entry.success { - continue; - } - - let error_pattern = categorize_error(entry.error.as_deref()); - - // AuthzDenied = governance decision, not a feature request. - if error_pattern == "AuthzDenied" || entry.authz_denied == Some(true) { - continue; - } - - let key = (entry.action.clone(), error_pattern.clone()); - let accum = groups.entry(key).or_insert_with(|| PlatformGapAccum { - action: entry.action.clone(), - error_pattern, - description: entry.error.clone().unwrap_or_default(), - count: 0, - timestamps: Vec::new(), - }); - accum.count += 1; - accum.timestamps.push(entry.timestamp.clone()); - } - - let mut feature_requests = Vec::new(); - - for accum in groups.into_values() { - if accum.count < FEATURE_REQUEST_THRESHOLD { - continue; - } - - let category = match accum.error_pattern.as_str() { - "EntitySetNotFound" => PlatformGapCategory::MissingCapability, - "ActionNotFound" => PlatformGapCategory::MissingMethod, - _ => PlatformGapCategory::MissingCapability, - }; - - let description = format!( - "Agents tried '{}' {} times — {}", - accum.action, accum.count, accum.description, - ); - - let header = RecordHeader::new(RecordType::FeatureRequest, "insight-generator"); - feature_requests.push(FeatureRequestRecord { - header, - category, - description, - frequency: accum.count, - trajectory_refs: accum.timestamps, - disposition: FeatureRequestDisposition::Open, - developer_notes: None, - }); - } - - // Sort by frequency (highest first). - feature_requests.sort_by_key(|b| std::cmp::Reverse(b.frequency)); - feature_requests -} - -/// Accumulator for platform gap grouping. -struct PlatformGapAccum { - action: String, - error_pattern: String, - description: String, - count: u64, - timestamps: Vec, -} - -/// Categorize an error string into a pattern name. -/// Generate unmet intent summaries from SQL-aggregated trajectory rows. -/// -/// Accepts the output of [`ServerState::load_unmet_intent_rows_aggregated`] -/// instead of loading thousands of raw [`TrajectoryEntry`] rows. The -/// `submitted_specs` map is keyed by entity_type and holds the latest -/// SubmitSpec timestamp for that type. -/// -/// Multiple SQL rows that map to the same (entity_type, error_pattern) after -/// [`categorize_error`] are merged by summing counts and taking extreme timestamps. -#[instrument(skip_all, fields(failure_row_count = failures.len(), intent_count = tracing::field::Empty))] -pub(crate) fn generate_unmet_intents_from_aggregated( - failures: &[temper_store_turso::UnmetIntentAggRow], - submitted_specs: &std::collections::BTreeMap, -) -> Vec { - // Merge rows whose raw errors collapse to the same category. - let mut groups: BTreeMap<(String, String), UnmetIntentAccum> = BTreeMap::new(); - - for row in failures { - let error_pattern = categorize_error(row.error.as_deref()); - // AuthzDenied belongs in the Decisions view, not Unmet Intents. - if error_pattern == "AuthzDenied" { - continue; - } - let key = (row.entity_type.clone(), error_pattern.clone()); - let accum = groups.entry(key).or_insert_with(|| UnmetIntentAccum { - entity_type: row.entity_type.clone(), - action: row.action.clone(), - error_pattern, - count: 0, - first_seen: row.first_seen.clone(), - last_seen: row.last_seen.clone(), - sample_body: None, - sample_intent: None, - }); - accum.count += row.count; - if row.first_seen < accum.first_seen { - accum.first_seen = row.first_seen.clone(); - } - if row.last_seen > accum.last_seen { - accum.last_seen = row.last_seen.clone(); - } - } - - let intents: Vec = groups - .into_values() - .map(|accum| { - let resolved = submitted_specs.contains_key(&accum.entity_type); - let resolved_by = submitted_specs.get(&accum.entity_type).cloned(); - let recommendation = if resolved { - format!("Spec for '{}' has been submitted.", accum.entity_type) - } else { - match accum.error_pattern.as_str() { - "EntitySetNotFound" => { - format!("Consider creating '{}' entity type.", accum.entity_type) - } - _ => format!( - "Investigate failures for '{}' (pattern: {}).", - accum.entity_type, accum.error_pattern, - ), - } - }; - UnmetIntent { - entity_type: accum.entity_type, - action: accum.action, - error_pattern: accum.error_pattern, - failure_count: accum.count, - first_seen: accum.first_seen, - last_seen: accum.last_seen, - status: if resolved { - "resolved".to_string() - } else { - "open".to_string() - }, - resolved_by, - recommendation, - sample_body: accum.sample_body, - sample_intent: accum.sample_intent, - } - }) - .collect(); - tracing::Span::current().record("intent_count", intents.len()); - intents -} - -fn categorize_error(error: Option<&str>) -> String { +pub(super) fn categorize_error(error: Option<&str>) -> String { match error { - Some(e) if e.contains("EntitySetNotFound") || e.contains("entity set not found") => { + Some(err) if err.contains("EntitySetNotFound") || err.contains("entity set not found") => { "EntitySetNotFound".to_string() } - Some(e) if e.contains("Authorization denied") || e.contains("authorization denied") => { + Some(err) + if err.contains("Authorization denied") || err.contains("authorization denied") => + { "AuthzDenied".to_string() } - Some(e) if e.contains("ActionNotFound") || e.contains("unknown action") => { + Some(err) if err.contains("ActionNotFound") || err.contains("unknown action") => { "ActionNotFound".to_string() } - Some(e) if e.contains("guard") => "GuardRejected".to_string(), + Some(err) if err.contains("guard") => "GuardRejected".to_string(), Some(_) => "Other".to_string(), None => "Unknown".to_string(), } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::state::TrajectoryEntry; - - fn entry(entity_type: &str, action: &str, success: bool) -> TrajectoryEntry { - TrajectoryEntry { - timestamp: "2026-01-01T00:00:00Z".to_string(), - tenant: "test".to_string(), - entity_type: entity_type.to_string(), - entity_id: "e1".to_string(), - action: action.to_string(), - success, - from_status: None, - to_status: None, - error: None, - agent_id: None, - session_id: None, - authz_denied: None, - denied_resource: None, - denied_module: None, - source: None, - spec_governed: None, - agent_type: None, - request_body: None, - intent: None, - } - } - - fn failed_entry(entity_type: &str, action: &str, error: &str) -> TrajectoryEntry { - TrajectoryEntry { - error: Some(error.to_string()), - ..entry(entity_type, action, false) - } - } - - fn authz_denied_entry(entity_type: &str, action: &str) -> TrajectoryEntry { - TrajectoryEntry { - authz_denied: Some(true), - ..entry(entity_type, action, false) - } - } - - fn platform_failed_entry(entity_type: &str, action: &str, error: &str) -> TrajectoryEntry { - TrajectoryEntry { - source: Some(TrajectorySource::Platform), - ..failed_entry(entity_type, action, error) - } - } - - fn failed_entry_with_intent( - entity_type: &str, - action: &str, - error: &str, - intent: &str, - agent_id: &str, - session_id: &str, - ) -> TrajectoryEntry { - TrajectoryEntry { - error: Some(error.to_string()), - intent: Some(intent.to_string()), - agent_id: Some(agent_id.to_string()), - session_id: Some(session_id.to_string()), - ..entry(entity_type, action, false) - } - } - - fn success_entry_with_intent( - entity_type: &str, - action: &str, - intent: &str, - agent_id: &str, - session_id: &str, - ) -> TrajectoryEntry { - TrajectoryEntry { - intent: Some(intent.to_string()), - agent_id: Some(agent_id.to_string()), - session_id: Some(session_id.to_string()), - ..entry(entity_type, action, true) - } - } - - #[test] - fn empty_input_returns_empty() { - assert!(generate_insights(&[]).is_empty()); - assert!(generate_unmet_intents(&[]).is_empty()); - assert!(generate_feature_requests(&[]).is_empty()); - } - - #[test] - fn below_threshold_signals_skipped() { - // Single entry (total < 2) should produce no insights. - let entries = vec![entry("Ticket", "Create", true)]; - let insights = generate_insights(&entries); - assert!( - insights.is_empty(), - "signals with total < 2 should be skipped" - ); - } - - #[test] - fn entity_set_not_found_open_unmet_intent() { - let entries = vec![ - failed_entry("Invoice", "Create", "EntitySetNotFound: Invoice"), - failed_entry("Invoice", "Create", "EntitySetNotFound: Invoice"), - ]; - let insights = generate_insights(&entries); - assert!(!insights.is_empty()); - assert!(insights[0].signal.intent.contains("not found")); - assert!(insights[0].recommendation.contains("Consider creating")); - } - - #[test] - fn entity_set_not_found_resolved_by_submit_spec() { - let entries = vec![ - failed_entry("Invoice", "Create", "EntitySetNotFound: Invoice"), - failed_entry("Invoice", "Create", "EntitySetNotFound: Invoice"), - entry("Invoice", "SubmitSpec", true), - ]; - let insights = generate_insights(&entries); - assert!(!insights.is_empty()); - let resolved = insights - .iter() - .find(|i| i.signal.intent.contains("Invoice")) - .unwrap(); - assert!(resolved.signal.intent.contains("resolved")); - assert!(resolved.recommendation.contains("submitted")); - } - - #[test] - fn authz_denial_above_threshold_generates_insight() { - // > 30% authz denials should trigger an insight. - let mut entries = Vec::new(); - for _ in 0..4 { - entries.push(authz_denied_entry("Task", "Delete")); - } - entries.push(entry("Task", "Delete", true)); - // 4 denials out of 5 = 80% > 30% - - let insights = generate_insights(&entries); - let denial_insight = insights.iter().find(|i| i.signal.intent.contains("denied")); - assert!( - denial_insight.is_some(), - "should generate authz denial insight" - ); - assert!( - denial_insight - .unwrap() - .recommendation - .contains("Cedar permit") - ); - } - - #[test] - fn authz_denial_below_threshold_no_special_insight() { - // 1 denial out of 10 = 10% < 30% — no special authz insight. - let mut entries = Vec::new(); - entries.push(authz_denied_entry("Task", "Delete")); - for _ in 0..9 { - entries.push(entry("Task", "Delete", true)); - } - - let insights = generate_insights(&entries); - let denial_insight = insights.iter().find(|i| i.signal.intent.contains("denied")); - assert!( - denial_insight.is_none(), - "should not generate authz denial insight below threshold" - ); - } - - #[test] - fn insights_sorted_by_priority_descending() { - let mut entries = Vec::new(); - // High failure rate action. - for _ in 0..20 { - entries.push(failed_entry("Order", "Process", "guard rejected")); - } - // Low failure rate action. - for _ in 0..2 { - entries.push(entry("User", "Login", false)); - } - - let insights = generate_insights(&entries); - for window in insights.windows(2) { - assert!( - window[0].priority_score >= window[1].priority_score, - "insights should be sorted by priority descending" - ); - } - } - - #[test] - fn feature_requests_empty_for_non_platform_source() { - let entries = vec![ - failed_entry("Ticket", "Create", "EntitySetNotFound"), - failed_entry("Ticket", "Create", "EntitySetNotFound"), - failed_entry("Ticket", "Create", "EntitySetNotFound"), - ]; - assert!( - generate_feature_requests(&entries).is_empty(), - "non-platform source should not generate FRs" - ); - } - - #[test] - fn feature_requests_below_threshold_skipped() { - // 2 failures < FEATURE_REQUEST_THRESHOLD (3) - let entries = vec![ - platform_failed_entry("Task", "Archive", "EntitySetNotFound"), - platform_failed_entry("Task", "Archive", "EntitySetNotFound"), - ]; - assert!(generate_feature_requests(&entries).is_empty()); - } - - #[test] - fn feature_requests_above_threshold_generated() { - let entries = vec![ - platform_failed_entry("Report", "Generate", "ActionNotFound: Generate"), - platform_failed_entry("Report", "Generate", "ActionNotFound: Generate"), - platform_failed_entry("Report", "Generate", "ActionNotFound: Generate"), - ]; - let frs = generate_feature_requests(&entries); - assert_eq!(frs.len(), 1); - assert!(frs[0].description.contains("Generate")); - assert_eq!(frs[0].frequency, 3); - } - - #[test] - fn unmet_intents_open_vs_resolved() { - let entries = vec![ - failed_entry("Billing", "Charge", "EntitySetNotFound"), - failed_entry("Billing", "Charge", "EntitySetNotFound"), - entry("Billing", "SubmitSpec", true), - ]; - let intents = generate_unmet_intents(&entries); - assert!(!intents.is_empty()); - let billing = intents.iter().find(|i| i.entity_type == "Billing").unwrap(); - assert_eq!(billing.status, "resolved"); - } - - #[test] - fn intent_evidence_prefers_explicit_intent_and_detects_workaround() { - let entries = vec![ - failed_entry_with_intent( - "Invoice", - "GenerateInvoice", - "EntitySetNotFound: Invoice", - "Send an invoice to the customer", - "agent-1", - "session-1", - ), - success_entry_with_intent( - "InvoiceDraft", - "CreateDraft", - "Send an invoice to the customer", - "agent-1", - "session-1", - ), - ]; - - let evidence = generate_intent_evidence(&entries); - assert_eq!(evidence.intent_candidates.len(), 1); - assert_eq!(evidence.workaround_patterns.len(), 1); - assert_eq!( - evidence.intent_candidates[0].intent_title, - "Send An Invoice To The Customer" - ); - assert_eq!(evidence.intent_candidates[0].suggested_kind, "workaround"); - assert_eq!(evidence.intent_candidates[0].workaround_count, 1); - assert_eq!(evidence.workaround_patterns[0].occurrences, 1); - } - - #[test] - fn intent_evidence_marks_abandonment_for_unrecovered_failures() { - let entries = vec![ - failed_entry_with_intent( - "Issue", - "MoveToTodo", - "Authorization denied", - "Move issue into active work", - "worker-1", - "session-2", - ), - failed_entry_with_intent( - "Issue", - "MoveToTodo", - "Authorization denied", - "Move issue into active work", - "worker-1", - "session-2", - ), - ]; - - let evidence = generate_intent_evidence(&entries); - assert_eq!(evidence.intent_candidates.len(), 1); - assert_eq!(evidence.abandonment_patterns.len(), 1); - assert_eq!(evidence.intent_candidates[0].abandonment_count, 1); - assert_eq!( - evidence.intent_candidates[0].suggested_kind, - "governance_gap" - ); - } - - #[test] - fn categorize_error_patterns() { - assert_eq!( - categorize_error(Some("EntitySetNotFound: X")), - "EntitySetNotFound" - ); - assert_eq!( - categorize_error(Some("Authorization denied")), - "AuthzDenied" - ); - assert_eq!( - categorize_error(Some("ActionNotFound: Y")), - "ActionNotFound" - ); - assert_eq!(categorize_error(Some("guard rejected")), "GuardRejected"); - assert_eq!(categorize_error(Some("something else")), "Other"); - assert_eq!(categorize_error(None), "Unknown"); - } -} diff --git a/crates/temper-server/src/observe/evolution/insight_generator/gap_analysis.rs b/crates/temper-server/src/observe/evolution/insight_generator/gap_analysis.rs new file mode 100644 index 00000000..bcd6a998 --- /dev/null +++ b/crates/temper-server/src/observe/evolution/insight_generator/gap_analysis.rs @@ -0,0 +1,298 @@ +use std::collections::BTreeMap; + +use tracing::instrument; + +use temper_evolution::records::{ + FeatureRequestDisposition, FeatureRequestRecord, PlatformGapCategory, RecordHeader, RecordType, +}; + +use crate::state::trajectory::TrajectorySource; + +/// A grouped unmet intent from trajectory data. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct UnmetIntent { + pub entity_type: String, + pub action: String, + pub error_pattern: String, + pub failure_count: u64, + pub first_seen: String, + pub last_seen: String, + pub status: String, + pub resolved_by: Option, + pub recommendation: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub sample_body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sample_intent: Option, +} + +struct UnmetIntentAccum { + entity_type: String, + action: String, + error_pattern: String, + count: u64, + first_seen: String, + last_seen: String, + sample_body: Option, + sample_intent: Option, +} + +/// Generate unmet intent summaries from trajectory data. +/// +/// This path is superseded in production by [`generate_unmet_intents_from_aggregated`] +/// (SQL GROUP BY). Retained for unit tests that exercise the aggregation logic +/// against in-memory trajectory slices. +#[cfg_attr(not(test), allow(dead_code))] +#[instrument(skip_all, fields(entry_count = entries.len(), intent_count = tracing::field::Empty))] +pub(crate) fn generate_unmet_intents( + entries: &[crate::state::TrajectoryEntry], +) -> Vec { + let mut submitted_specs: BTreeMap = BTreeMap::new(); + let mut failures: BTreeMap<(String, String), UnmetIntentAccum> = BTreeMap::new(); + + for entry in entries { + if entry.action == "SubmitSpec" && entry.success { + submitted_specs.insert(entry.entity_type.clone(), entry.timestamp.clone()); + continue; + } + + if !entry.success { + let error_pattern = super::categorize_error(entry.error.as_deref()); + if error_pattern == "AuthzDenied" || entry.authz_denied == Some(true) { + continue; + } + + let key = (entry.entity_type.clone(), error_pattern.clone()); + let accum = failures.entry(key).or_insert_with(|| UnmetIntentAccum { + entity_type: entry.entity_type.clone(), + action: entry.action.clone(), + error_pattern, + count: 0, + first_seen: entry.timestamp.clone(), + last_seen: entry.timestamp.clone(), + sample_body: None, + sample_intent: None, + }); + accum.count += 1; + accum.last_seen = entry.timestamp.clone(); + if entry.request_body.is_some() { + accum.sample_body = entry.request_body.clone(); + } + if entry.intent.is_some() { + accum.sample_intent = entry.intent.clone(); + } + } + } + + let intents: Vec = failures + .into_values() + .map(|accum| { + let resolved = submitted_specs.contains_key(&accum.entity_type); + let resolved_by = submitted_specs.get(&accum.entity_type).cloned(); + let recommendation = if resolved { + format!("Spec for '{}' has been submitted.", accum.entity_type) + } else { + match accum.error_pattern.as_str() { + "EntitySetNotFound" => { + format!("Consider creating '{}' entity type.", accum.entity_type) + } + _ => format!( + "Investigate failures for '{}' (pattern: {}).", + accum.entity_type, accum.error_pattern, + ), + } + }; + + UnmetIntent { + entity_type: accum.entity_type, + action: accum.action, + error_pattern: accum.error_pattern, + failure_count: accum.count, + first_seen: accum.first_seen, + last_seen: accum.last_seen, + status: if resolved { + "resolved".to_string() + } else { + "open".to_string() + }, + resolved_by, + recommendation, + sample_body: accum.sample_body, + sample_intent: accum.sample_intent, + } + }) + .collect(); + let open_count = intents + .iter() + .filter(|intent| intent.status == "open") + .count(); + let resolved_count = intents + .iter() + .filter(|intent| intent.status == "resolved") + .count(); + tracing::Span::current().record("intent_count", intents.len()); + if open_count > 0 { + tracing::warn!( + entry_count = entries.len(), + intents_count = intents.len(), + open_count, + resolved_count, + "unmet_intent" + ); + } else { + tracing::info!( + entry_count = entries.len(), + intents_count = intents.len(), + open_count, + resolved_count, + "unmet_intent" + ); + } + intents +} + +const FEATURE_REQUEST_THRESHOLD: u64 = 3; + +pub(crate) fn generate_feature_requests( + entries: &[crate::state::TrajectoryEntry], +) -> Vec { + if entries.is_empty() { + return Vec::new(); + } + + let mut groups: BTreeMap<(String, String), PlatformGapAccum> = BTreeMap::new(); + + for entry in entries { + if entry.source != Some(TrajectorySource::Platform) || entry.success { + continue; + } + + let error_pattern = super::categorize_error(entry.error.as_deref()); + if error_pattern == "AuthzDenied" || entry.authz_denied == Some(true) { + continue; + } + + let key = (entry.action.clone(), error_pattern.clone()); + let accum = groups.entry(key).or_insert_with(|| PlatformGapAccum { + action: entry.action.clone(), + error_pattern, + description: entry.error.clone().unwrap_or_default(), + count: 0, + timestamps: Vec::new(), + }); + accum.count += 1; + accum.timestamps.push(entry.timestamp.clone()); + } + + let mut feature_requests = Vec::new(); + for accum in groups.into_values() { + if accum.count < FEATURE_REQUEST_THRESHOLD { + continue; + } + + let category = match accum.error_pattern.as_str() { + "EntitySetNotFound" => PlatformGapCategory::MissingCapability, + "ActionNotFound" => PlatformGapCategory::MissingMethod, + _ => PlatformGapCategory::MissingCapability, + }; + + feature_requests.push(FeatureRequestRecord { + header: RecordHeader::new(RecordType::FeatureRequest, "insight-generator"), + category, + description: format!( + "Agents tried '{}' {} times — {}", + accum.action, accum.count, accum.description, + ), + frequency: accum.count, + trajectory_refs: accum.timestamps, + disposition: FeatureRequestDisposition::Open, + developer_notes: None, + }); + } + + feature_requests.sort_by_key(|record| std::cmp::Reverse(record.frequency)); + feature_requests +} + +struct PlatformGapAccum { + action: String, + error_pattern: String, + description: String, + count: u64, + timestamps: Vec, +} + +#[instrument(skip_all, fields(failure_row_count = failures.len(), intent_count = tracing::field::Empty))] +pub(crate) fn generate_unmet_intents_from_aggregated( + failures: &[temper_store_turso::UnmetIntentAggRow], + submitted_specs: &std::collections::BTreeMap, +) -> Vec { + let mut groups: BTreeMap<(String, String), UnmetIntentAccum> = BTreeMap::new(); + + for row in failures { + let error_pattern = super::categorize_error(row.error.as_deref()); + if error_pattern == "AuthzDenied" { + continue; + } + + let key = (row.entity_type.clone(), error_pattern.clone()); + let accum = groups.entry(key).or_insert_with(|| UnmetIntentAccum { + entity_type: row.entity_type.clone(), + action: row.action.clone(), + error_pattern, + count: 0, + first_seen: row.first_seen.clone(), + last_seen: row.last_seen.clone(), + sample_body: None, + sample_intent: None, + }); + accum.count += row.count; + if row.first_seen < accum.first_seen { + accum.first_seen = row.first_seen.clone(); + } + if row.last_seen > accum.last_seen { + accum.last_seen = row.last_seen.clone(); + } + } + + let intents: Vec = groups + .into_values() + .map(|accum| { + let resolved = submitted_specs.contains_key(&accum.entity_type); + let resolved_by = submitted_specs.get(&accum.entity_type).cloned(); + let recommendation = if resolved { + format!("Spec for '{}' has been submitted.", accum.entity_type) + } else { + match accum.error_pattern.as_str() { + "EntitySetNotFound" => { + format!("Consider creating '{}' entity type.", accum.entity_type) + } + _ => format!( + "Investigate failures for '{}' (pattern: {}).", + accum.entity_type, accum.error_pattern, + ), + } + }; + + UnmetIntent { + entity_type: accum.entity_type, + action: accum.action, + error_pattern: accum.error_pattern, + failure_count: accum.count, + first_seen: accum.first_seen, + last_seen: accum.last_seen, + status: if resolved { + "resolved".to_string() + } else { + "open".to_string() + }, + resolved_by, + recommendation, + sample_body: accum.sample_body, + sample_intent: accum.sample_intent, + } + }) + .collect(); + tracing::Span::current().record("intent_count", intents.len()); + intents +} diff --git a/crates/temper-server/src/observe/evolution/insight_generator/intent_evidence.rs b/crates/temper-server/src/observe/evolution/insight_generator/intent_evidence.rs new file mode 100644 index 00000000..406f30a4 --- /dev/null +++ b/crates/temper-server/src/observe/evolution/insight_generator/intent_evidence.rs @@ -0,0 +1,821 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use tracing::instrument; + +/// Richer unmet-intent evidence derived from recent trajectories. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct IntentEvidenceSummary { + pub intent_candidates: Vec, + pub workaround_patterns: Vec, + pub abandonment_patterns: Vec, + pub trajectory_samples: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct IntentCandidate { + pub intent_key: String, + pub intent_title: String, + pub intent_statement: String, + pub recommended_issue_title: String, + pub symptom_title: String, + pub suggested_kind: String, + pub status: String, + pub entity_types: Vec, + pub attempted_actions: Vec, + pub successful_actions: Vec, + pub failure_patterns: Vec, + pub total_count: u64, + pub failure_count: u64, + pub success_count: u64, + pub authz_denials: u64, + pub workaround_count: u64, + pub abandonment_count: u64, + pub success_after_failure_count: u64, + pub success_rate: f64, + pub first_seen: String, + pub last_seen: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub sample_intent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sample_body: Option, + pub sample_agents: Vec, + pub recommendation: String, + pub problem_statement: String, + pub logfire_query_hint: serde_json::Value, + pub evidence_examples: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct WorkaroundPattern { + pub intent_key: String, + pub intent_title: String, + pub failed_actions: Vec, + pub successful_actions: Vec, + pub occurrences: u64, + pub sample_agents: Vec, + pub last_seen: String, + pub recommendation: String, + pub logfire_query_hint: serde_json::Value, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct AbandonmentPattern { + pub intent_key: String, + pub intent_title: String, + pub failed_actions: Vec, + pub abandonment_count: u64, + pub sample_agents: Vec, + pub first_seen: String, + pub last_seen: String, + pub recommendation: String, + pub logfire_query_hint: serde_json::Value, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct TrajectorySample { + pub timestamp: String, + pub entity_type: String, + pub action: String, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_pattern: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub intent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +struct IntentCandidateAccum { + intent_key: String, + intent_title: String, + intent_statement: String, + recommended_issue_title: String, + symptom_title: String, + entity_types: BTreeSet, + attempted_actions: BTreeSet, + successful_actions: BTreeSet, + failure_patterns: BTreeSet, + sample_intent: Option, + sample_body: Option, + sample_agents: BTreeSet, + total_count: u64, + failure_count: u64, + success_count: u64, + authz_denials: u64, + workaround_count: u64, + abandonment_count: u64, + success_after_failure_count: u64, + first_seen: String, + last_seen: String, + evidence_examples: Vec, +} + +struct PendingFailure { + intent_key: String, + failed_actions: BTreeSet, + agent_id: Option, + first_seen: String, + last_seen: String, +} + +struct WorkaroundAccum { + intent_key: String, + intent_title: String, + failed_actions: BTreeSet, + successful_actions: BTreeSet, + sample_agents: BTreeSet, + occurrences: u64, + last_seen: String, +} + +struct AbandonmentAccum { + intent_key: String, + intent_title: String, + failed_actions: BTreeSet, + sample_agents: BTreeSet, + abandonment_count: u64, + first_seen: String, + last_seen: String, +} + +/// Generate richer, intent-shaped evidence from recent trajectories. +/// +/// Unlike `generate_unmet_intents_from_aggregated`, this path intentionally +/// loads bounded raw trajectories so the evolution analyst can reason about: +/// - explicit caller intents (`X-Intent`) +/// - repeated failures around the same intended outcome +/// - workaround sequences (failure followed by alternate success) +/// - abandonment candidates (failed attempts that never recover) +#[instrument(skip_all, fields(entry_count = entries.len(), candidate_count = tracing::field::Empty))] +pub(crate) fn generate_intent_evidence( + entries: &[crate::state::TrajectoryEntry], +) -> IntentEvidenceSummary { + if entries.is_empty() { + return IntentEvidenceSummary { + intent_candidates: Vec::new(), + workaround_patterns: Vec::new(), + abandonment_patterns: Vec::new(), + trajectory_samples: Vec::new(), + }; + } + + let mut sorted_entries = entries.to_vec(); + sorted_entries.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); + + let mut candidates = BTreeMap::::new(); + let mut pending_failures = BTreeMap::<(String, String), PendingFailure>::new(); + let mut workarounds = BTreeMap::::new(); + let mut abandonments = BTreeMap::::new(); + + for entry in &sorted_entries { + let intent_key = derive_intent_key(entry); + let intent_title = + derive_intent_title(entry.intent.as_deref(), &entry.entity_type, &entry.action); + let intent_statement = entry + .intent + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| derive_intent_statement(&entry.entity_type, &entry.action)); + let symptom_title = derive_symptom_title(entry); + let issue_title = derive_issue_title( + &intent_title, + entry.intent.as_deref(), + &entry.entity_type, + &entry.action, + ); + let sample = sample_from_entry(entry); + let accum = candidates + .entry(intent_key.clone()) + .or_insert_with(|| IntentCandidateAccum { + intent_key: intent_key.clone(), + intent_title: intent_title.clone(), + intent_statement: intent_statement.clone(), + recommended_issue_title: issue_title.clone(), + symptom_title: symptom_title.clone(), + entity_types: BTreeSet::new(), + attempted_actions: BTreeSet::new(), + successful_actions: BTreeSet::new(), + failure_patterns: BTreeSet::new(), + sample_intent: None, + sample_body: None, + sample_agents: BTreeSet::new(), + total_count: 0, + failure_count: 0, + success_count: 0, + authz_denials: 0, + workaround_count: 0, + abandonment_count: 0, + success_after_failure_count: 0, + first_seen: entry.timestamp.clone(), + last_seen: entry.timestamp.clone(), + evidence_examples: Vec::new(), + }); + + accum.total_count += 1; + accum.entity_types.insert(entry.entity_type.clone()); + accum.attempted_actions.insert(entry.action.clone()); + accum.last_seen = entry.timestamp.clone(); + if entry.timestamp < accum.first_seen { + accum.first_seen = entry.timestamp.clone(); + } + if let Some(agent_id) = entry + .agent_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + accum.sample_agents.insert(agent_id.to_string()); + } + if let Some(intent) = entry + .intent + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + accum.sample_intent = Some(intent.to_string()); + } + if entry.request_body.is_some() { + accum.sample_body = entry.request_body.clone(); + } + + if accum.evidence_examples.len() < 4 || !entry.success { + accum.evidence_examples.push(sample.clone()); + accum.evidence_examples.truncate(4); + } + + if entry.success { + accum.success_count += 1; + accum.successful_actions.insert(entry.action.clone()); + } else { + accum.failure_count += 1; + let error_pattern = super::categorize_error(entry.error.as_deref()); + let is_authz_denied = error_pattern == "AuthzDenied"; + accum.failure_patterns.insert(error_pattern); + if entry.authz_denied == Some(true) || is_authz_denied { + accum.authz_denials += 1; + } + } + + let actor_key = actor_intent_key(entry); + if entry.success { + if let Some(pending) = pending_failures.remove(&(actor_key.clone(), intent_key.clone())) + { + if pending + .failed_actions + .iter() + .any(|action| action != &entry.action) + { + accum.workaround_count += 1; + accum.success_after_failure_count += 1; + let workaround_key = format!( + "{}::{}", + intent_key, + normalize_for_key(&format!( + "{}->{}", + join_set(&pending.failed_actions), + entry.action + )) + ); + let workaround = + workarounds + .entry(workaround_key) + .or_insert_with(|| WorkaroundAccum { + intent_key: intent_key.clone(), + intent_title: intent_title.clone(), + failed_actions: pending.failed_actions.clone(), + successful_actions: BTreeSet::new(), + sample_agents: BTreeSet::new(), + occurrences: 0, + last_seen: entry.timestamp.clone(), + }); + workaround.occurrences += 1; + workaround.last_seen = entry.timestamp.clone(); + workaround.successful_actions.insert(entry.action.clone()); + if let Some(agent_id) = pending + .agent_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + workaround.sample_agents.insert(agent_id.to_string()); + } + if let Some(agent_id) = entry + .agent_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + workaround.sample_agents.insert(agent_id.to_string()); + } + } else { + accum.success_after_failure_count += 1; + } + } + } else { + let pending = pending_failures + .entry((actor_key, intent_key.clone())) + .or_insert_with(|| PendingFailure { + intent_key: intent_key.clone(), + failed_actions: BTreeSet::new(), + agent_id: entry.agent_id.clone(), + first_seen: entry.timestamp.clone(), + last_seen: entry.timestamp.clone(), + }); + pending.failed_actions.insert(entry.action.clone()); + pending.last_seen = entry.timestamp.clone(); + if entry.timestamp < pending.first_seen { + pending.first_seen = entry.timestamp.clone(); + } + } + } + + for pending in pending_failures.into_values() { + if let Some(candidate) = candidates.get_mut(&pending.intent_key) { + candidate.abandonment_count += 1; + } + let abandonment = abandonments + .entry(pending.intent_key.clone()) + .or_insert_with(|| AbandonmentAccum { + intent_key: pending.intent_key.clone(), + intent_title: candidates + .get(&pending.intent_key) + .map(|value| value.intent_title.clone()) + .unwrap_or_else(|| "Investigate unmet intent".to_string()), + failed_actions: BTreeSet::new(), + sample_agents: BTreeSet::new(), + abandonment_count: 0, + first_seen: pending.first_seen.clone(), + last_seen: pending.last_seen.clone(), + }); + abandonment.abandonment_count += 1; + abandonment + .failed_actions + .extend(pending.failed_actions.into_iter()); + abandonment.last_seen = pending.last_seen.clone(); + if pending.first_seen < abandonment.first_seen { + abandonment.first_seen = pending.first_seen.clone(); + } + if let Some(agent_id) = pending + .agent_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + abandonment.sample_agents.insert(agent_id.to_string()); + } + } + + let mut intent_candidates = candidates + .into_values() + .filter(|candidate| { + candidate.failure_count > 0 + || candidate.workaround_count > 0 + || candidate.abandonment_count > 0 + }) + .map(finalize_intent_candidate) + .collect::>(); + intent_candidates.sort_by(|a, b| { + score_intent_candidate(b) + .cmp(&score_intent_candidate(a)) + .then_with(|| b.last_seen.cmp(&a.last_seen)) + }); + intent_candidates.truncate(12); + + let mut workaround_patterns = workarounds + .into_values() + .map(finalize_workaround_pattern) + .collect::>(); + workaround_patterns.sort_by(|a, b| { + b.occurrences + .cmp(&a.occurrences) + .then_with(|| b.last_seen.cmp(&a.last_seen)) + }); + workaround_patterns.truncate(8); + + let mut abandonment_patterns = abandonments + .into_values() + .map(finalize_abandonment_pattern) + .collect::>(); + abandonment_patterns.sort_by(|a, b| { + b.abandonment_count + .cmp(&a.abandonment_count) + .then_with(|| b.last_seen.cmp(&a.last_seen)) + }); + abandonment_patterns.truncate(8); + + let trajectory_samples = sorted_entries + .iter() + .rev() + .take(20) + .map(sample_from_entry) + .collect::>(); + + tracing::Span::current().record("candidate_count", intent_candidates.len()); + + IntentEvidenceSummary { + intent_candidates, + workaround_patterns, + abandonment_patterns, + trajectory_samples, + } +} + +fn finalize_intent_candidate(candidate: IntentCandidateAccum) -> IntentCandidate { + let success_rate = if candidate.total_count == 0 { + 0.0 + } else { + candidate.success_count as f64 / candidate.total_count as f64 + }; + let suggested_kind = if candidate.authz_denials > 0 + && candidate.authz_denials + >= candidate + .failure_count + .saturating_sub(candidate.success_count) + { + "governance_gap".to_string() + } else if candidate.workaround_count > 0 { + "workaround".to_string() + } else if candidate + .failure_patterns + .iter() + .any(|pattern| matches!(pattern.as_str(), "EntitySetNotFound" | "ActionNotFound")) + { + "missing_capability".to_string() + } else { + "friction".to_string() + }; + let status = if candidate.failure_count == 0 { + "resolved" + } else if candidate.workaround_count > 0 { + "workaround" + } else if candidate.success_count > 0 { + "mixed" + } else { + "open" + } + .to_string(); + let hint_entity_type = candidate.entity_types.iter().next().cloned(); + let hint_action = candidate.attempted_actions.iter().next().cloned(); + let hint_intent = candidate.sample_intent.clone(); + let recommendation = match suggested_kind.as_str() { + "governance_gap" => format!( + "Align policy with the intended '{}' workflow and keep the scope limited to the minimum required principals/resources.", + candidate.intent_title + ), + "workaround" => format!( + "Promote the successful workaround into a first-class capability for '{}', so users stop relying on alternate action chains.", + candidate.intent_title + ), + "friction" => format!( + "Collapse the repeated multi-step flow behind '{}' into a simpler supported path.", + candidate.intent_title + ), + _ => format!( + "Add direct product/spec support for '{}'.", + candidate.intent_title + ), + }; + let problem_statement = match suggested_kind.as_str() { + "governance_gap" => format!( + "The intended outcome '{}' is blocked by repeated authorization denials across the current workflow.", + candidate.intent_statement + ), + "workaround" => format!( + "Users and agents are trying to achieve '{}' and are only succeeding through alternate action paths rather than a direct capability.", + candidate.intent_statement + ), + "friction" => format!( + "The intended outcome '{}' is possible, but only after repeated retries or unnecessary extra steps.", + candidate.intent_statement + ), + _ => format!( + "The intended outcome '{}' is not directly supported by the current product/spec surface.", + candidate.intent_statement + ), + }; + + IntentCandidate { + intent_key: candidate.intent_key.clone(), + intent_title: candidate.intent_title.clone(), + intent_statement: candidate.intent_statement, + recommended_issue_title: candidate.recommended_issue_title, + symptom_title: candidate.symptom_title, + suggested_kind: suggested_kind.clone(), + status, + entity_types: candidate.entity_types.into_iter().collect(), + attempted_actions: candidate.attempted_actions.iter().cloned().collect(), + successful_actions: candidate.successful_actions.iter().cloned().collect(), + failure_patterns: candidate.failure_patterns.iter().cloned().collect(), + total_count: candidate.total_count, + failure_count: candidate.failure_count, + success_count: candidate.success_count, + authz_denials: candidate.authz_denials, + workaround_count: candidate.workaround_count, + abandonment_count: candidate.abandonment_count, + success_after_failure_count: candidate.success_after_failure_count, + success_rate, + first_seen: candidate.first_seen, + last_seen: candidate.last_seen, + sample_intent: candidate.sample_intent, + sample_body: candidate.sample_body, + sample_agents: candidate.sample_agents.iter().cloned().collect(), + recommendation, + problem_statement, + logfire_query_hint: build_logfire_query_hint( + &suggested_kind, + hint_entity_type.as_deref(), + hint_action.as_deref(), + hint_intent.as_deref(), + ), + evidence_examples: candidate.evidence_examples, + } +} + +fn finalize_workaround_pattern(pattern: WorkaroundAccum) -> WorkaroundPattern { + WorkaroundPattern { + intent_key: pattern.intent_key.clone(), + intent_title: pattern.intent_title.clone(), + failed_actions: pattern.failed_actions.iter().cloned().collect(), + successful_actions: pattern.successful_actions.iter().cloned().collect(), + occurrences: pattern.occurrences, + sample_agents: pattern.sample_agents.iter().cloned().collect(), + last_seen: pattern.last_seen, + recommendation: format!( + "Inspect '{}' and graduate the successful alternate path into a supported single-step workflow.", + pattern.intent_title + ), + logfire_query_hint: build_logfire_query_hint( + "alternate_success_paths", + None, + pattern.failed_actions.iter().next().map(String::as_str), + Some(pattern.intent_title.as_str()), + ), + } +} + +fn finalize_abandonment_pattern(pattern: AbandonmentAccum) -> AbandonmentPattern { + AbandonmentPattern { + intent_key: pattern.intent_key.clone(), + intent_title: pattern.intent_title.clone(), + failed_actions: pattern.failed_actions.iter().cloned().collect(), + abandonment_count: pattern.abandonment_count, + sample_agents: pattern.sample_agents.iter().cloned().collect(), + first_seen: pattern.first_seen, + last_seen: pattern.last_seen, + recommendation: format!( + "Investigate why '{}' never reaches a successful outcome after the observed failed attempts.", + pattern.intent_title + ), + logfire_query_hint: build_logfire_query_hint( + "intent_abandonment", + None, + pattern.failed_actions.iter().next().map(String::as_str), + Some(pattern.intent_title.as_str()), + ), + } +} + +fn sample_from_entry(entry: &crate::state::TrajectoryEntry) -> TrajectorySample { + TrajectorySample { + timestamp: entry.timestamp.clone(), + entity_type: entry.entity_type.clone(), + action: entry.action.clone(), + success: entry.success, + error_pattern: (!entry.success).then(|| super::categorize_error(entry.error.as_deref())), + error: entry.error.clone(), + intent: entry.intent.clone(), + agent_id: entry.agent_id.clone(), + session_id: entry.session_id.clone(), + } +} + +fn score_intent_candidate(candidate: &IntentCandidate) -> u64 { + candidate.failure_count.saturating_mul(4) + + candidate.workaround_count.saturating_mul(5) + + candidate.abandonment_count.saturating_mul(4) + + candidate.authz_denials.saturating_mul(3) + + candidate.success_after_failure_count.saturating_mul(2) +} + +fn actor_intent_key(entry: &crate::state::TrajectoryEntry) -> String { + let actor = entry + .session_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .or_else(|| { + entry + .agent_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + }) + .unwrap_or_else(|| "anonymous".to_string()); + format!("{actor}::{}", derive_intent_key(entry)) +} + +fn derive_intent_key(entry: &crate::state::TrajectoryEntry) -> String { + if let Some(intent) = entry + .intent + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + return normalize_for_key(intent); + } + + if let Some(request_body) = entry.request_body.as_ref() { + for key in ["intent", "goal", "objective", "Title", "title"] { + if let Some(value) = request_body.get(key).and_then(serde_json::Value::as_str) + && !value.trim().is_empty() + { + return normalize_for_key(value); + } + } + } + + normalize_for_key(&derive_intent_statement(&entry.entity_type, &entry.action)) +} + +fn derive_intent_title(sample_intent: Option<&str>, entity_type: &str, action: &str) -> String { + if let Some(intent) = sample_intent.filter(|value| !value.trim().is_empty()) { + return title_case(intent); + } + + let action_lower = action.to_ascii_lowercase(); + let entity = humanize_identifier(entity_type).to_ascii_lowercase(); + if action_lower.starts_with("generate") { + return format!("Enable {entity} generation"); + } + if action_lower.starts_with("create") { + return format!("Enable {entity} creation"); + } + if let Some(target) = action + .strip_prefix("MoveTo") + .or_else(|| action.strip_prefix("moveTo")) + { + return format!( + "Allow {} to reach {}", + humanize_identifier(entity_type).to_ascii_lowercase(), + humanize_identifier(target).to_ascii_lowercase() + ); + } + + format!( + "Enable {} {} workflow", + entity, + humanize_identifier(action).to_ascii_lowercase() + ) +} + +fn derive_issue_title( + intent_title: &str, + sample_intent: Option<&str>, + entity_type: &str, + action: &str, +) -> String { + if !intent_title.trim().is_empty() { + return title_case(intent_title); + } + if let Some(intent) = sample_intent.filter(|value| !value.trim().is_empty()) { + return title_case(intent); + } + title_case(&derive_intent_statement(entity_type, action)) +} + +fn derive_intent_statement(entity_type: &str, action: &str) -> String { + let action_lower = action.to_ascii_lowercase(); + let entity = humanize_identifier(entity_type).to_ascii_lowercase(); + if action_lower.starts_with("generate") { + return format!("Generate {entity}"); + } + if action_lower.starts_with("create") { + return format!("Create {entity}"); + } + if let Some(target) = action + .strip_prefix("MoveTo") + .or_else(|| action.strip_prefix("moveTo")) + { + return format!( + "Move {} to {}", + entity, + humanize_identifier(target).to_ascii_lowercase() + ); + } + format!( + "{} {}", + humanize_identifier(action), + humanize_identifier(entity_type).to_ascii_lowercase() + ) +} + +fn derive_symptom_title(entry: &crate::state::TrajectoryEntry) -> String { + if entry.success { + return format!( + "{} succeeded via {}", + humanize_identifier(&entry.entity_type), + humanize_identifier(&entry.action) + ); + } + + let error_pattern = super::categorize_error(entry.error.as_deref()); + match error_pattern.as_str() { + "AuthzDenied" => format!( + "{} is denied while attempting {}", + humanize_identifier(&entry.entity_type), + humanize_identifier(&entry.action) + ), + "EntitySetNotFound" => format!( + "{} is missing for {}", + humanize_identifier(&entry.entity_type), + humanize_identifier(&entry.action) + ), + _ => format!( + "{} fails during {}", + humanize_identifier(&entry.entity_type), + humanize_identifier(&entry.action) + ), + } +} + +fn build_logfire_query_hint( + query_kind: &str, + entity_type: Option<&str>, + action: Option<&str>, + intent_text: Option<&str>, +) -> serde_json::Value { + let normalized_query_kind = match query_kind { + "workaround" => "alternate_success_paths", + "governance_gap" => "intent_failure_cluster", + other => other, + }; + let mut hint = serde_json::json!({ + "tool": "logfire_query", + "query_kind": normalized_query_kind, + "service_name": "temper-platform", + "environment": "local", + "limit": 25, + "lookback_minutes": 240, + }); + if let Some(entity_type) = entity_type.filter(|value| !value.trim().is_empty()) { + hint["entity_type"] = serde_json::json!(entity_type); + } + if let Some(action) = action.filter(|value| !value.trim().is_empty()) { + hint["action"] = serde_json::json!(action); + } + if let Some(intent_text) = intent_text.filter(|value| !value.trim().is_empty()) { + hint["intent_text"] = serde_json::json!(intent_text); + } + hint +} + +fn normalize_for_key(value: &str) -> String { + value + .trim() + .to_ascii_lowercase() + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .collect() +} + +fn humanize_identifier(value: &str) -> String { + let mut out = String::new(); + let mut previous_lowercase = false; + for ch in value.chars() { + if ch == '_' || ch == '-' { + if !out.ends_with(' ') { + out.push(' '); + } + previous_lowercase = false; + continue; + } + if ch.is_ascii_uppercase() && previous_lowercase { + out.push(' '); + } + out.push(ch.to_ascii_lowercase()); + previous_lowercase = ch.is_ascii_lowercase(); + } + out.split_whitespace().collect::>().join(" ") +} + +fn title_case(value: &str) -> String { + value + .split_whitespace() + .map(|word| { + let mut chars = word.chars(); + let Some(first) = chars.next() else { + return String::new(); + }; + format!( + "{}{}", + first.to_ascii_uppercase(), + chars.as_str().to_ascii_lowercase() + ) + }) + .collect::>() + .join(" ") +} + +fn join_set(values: &BTreeSet) -> String { + values.iter().cloned().collect::>().join(",") +} diff --git a/crates/temper-server/src/observe/evolution/insight_generator/mod_test.rs b/crates/temper-server/src/observe/evolution/insight_generator/mod_test.rs new file mode 100644 index 00000000..d0f6fcff --- /dev/null +++ b/crates/temper-server/src/observe/evolution/insight_generator/mod_test.rs @@ -0,0 +1,319 @@ +use super::*; +use crate::state::{TrajectoryEntry, trajectory::TrajectorySource}; + +fn entry(entity_type: &str, action: &str, success: bool) -> TrajectoryEntry { + TrajectoryEntry { + timestamp: "2026-01-01T00:00:00Z".to_string(), + tenant: "test".to_string(), + entity_type: entity_type.to_string(), + entity_id: "e1".to_string(), + action: action.to_string(), + success, + from_status: None, + to_status: None, + error: None, + agent_id: None, + session_id: None, + authz_denied: None, + denied_resource: None, + denied_module: None, + source: None, + spec_governed: None, + agent_type: None, + request_body: None, + intent: None, + } +} + +fn failed_entry(entity_type: &str, action: &str, error: &str) -> TrajectoryEntry { + TrajectoryEntry { + error: Some(error.to_string()), + ..entry(entity_type, action, false) + } +} + +fn authz_denied_entry(entity_type: &str, action: &str) -> TrajectoryEntry { + TrajectoryEntry { + authz_denied: Some(true), + ..entry(entity_type, action, false) + } +} + +fn platform_failed_entry(entity_type: &str, action: &str, error: &str) -> TrajectoryEntry { + TrajectoryEntry { + source: Some(TrajectorySource::Platform), + ..failed_entry(entity_type, action, error) + } +} + +fn failed_entry_with_intent( + entity_type: &str, + action: &str, + error: &str, + intent: &str, + agent_id: &str, + session_id: &str, +) -> TrajectoryEntry { + TrajectoryEntry { + error: Some(error.to_string()), + intent: Some(intent.to_string()), + agent_id: Some(agent_id.to_string()), + session_id: Some(session_id.to_string()), + ..entry(entity_type, action, false) + } +} + +fn success_entry_with_intent( + entity_type: &str, + action: &str, + intent: &str, + agent_id: &str, + session_id: &str, +) -> TrajectoryEntry { + TrajectoryEntry { + intent: Some(intent.to_string()), + agent_id: Some(agent_id.to_string()), + session_id: Some(session_id.to_string()), + ..entry(entity_type, action, true) + } +} + +#[test] +fn empty_input_returns_empty() { + assert!(generate_insights(&[]).is_empty()); + assert!(gap_analysis::generate_unmet_intents(&[]).is_empty()); + assert!(generate_feature_requests(&[]).is_empty()); +} + +#[test] +fn below_threshold_signals_skipped() { + let entries = vec![entry("Ticket", "Create", true)]; + let insights = generate_insights(&entries); + assert!( + insights.is_empty(), + "signals with total < 2 should be skipped" + ); +} + +#[test] +fn entity_set_not_found_open_unmet_intent() { + let entries = vec![ + failed_entry("Invoice", "Create", "EntitySetNotFound: Invoice"), + failed_entry("Invoice", "Create", "EntitySetNotFound: Invoice"), + ]; + let insights = generate_insights(&entries); + assert!(!insights.is_empty()); + assert!(insights[0].signal.intent.contains("not found")); + assert!(insights[0].recommendation.contains("Consider creating")); +} + +#[test] +fn entity_set_not_found_resolved_by_submit_spec() { + let entries = vec![ + failed_entry("Invoice", "Create", "EntitySetNotFound: Invoice"), + failed_entry("Invoice", "Create", "EntitySetNotFound: Invoice"), + entry("Invoice", "SubmitSpec", true), + ]; + let insights = generate_insights(&entries); + assert!(!insights.is_empty()); + let resolved = insights + .iter() + .find(|insight| insight.signal.intent.contains("Invoice")) + .unwrap(); + assert!(resolved.signal.intent.contains("resolved")); + assert!(resolved.recommendation.contains("submitted")); +} + +#[test] +fn authz_denial_above_threshold_generates_insight() { + let mut entries = Vec::new(); + for _ in 0..4 { + entries.push(authz_denied_entry("Task", "Delete")); + } + entries.push(entry("Task", "Delete", true)); + + let insights = generate_insights(&entries); + let denial_insight = insights + .iter() + .find(|insight| insight.signal.intent.contains("denied")); + assert!( + denial_insight.is_some(), + "should generate authz denial insight" + ); + assert!( + denial_insight + .unwrap() + .recommendation + .contains("Cedar permit") + ); +} + +#[test] +fn authz_denial_below_threshold_no_special_insight() { + let mut entries = Vec::new(); + entries.push(authz_denied_entry("Task", "Delete")); + for _ in 0..9 { + entries.push(entry("Task", "Delete", true)); + } + + let insights = generate_insights(&entries); + let denial_insight = insights + .iter() + .find(|insight| insight.signal.intent.contains("denied")); + assert!( + denial_insight.is_none(), + "should not generate authz denial insight below threshold" + ); +} + +#[test] +fn insights_sorted_by_priority_descending() { + let mut entries = Vec::new(); + for _ in 0..20 { + entries.push(failed_entry("Order", "Process", "guard rejected")); + } + for _ in 0..2 { + entries.push(entry("User", "Login", false)); + } + + let insights = generate_insights(&entries); + for window in insights.windows(2) { + assert!( + window[0].priority_score >= window[1].priority_score, + "insights should be sorted by priority descending" + ); + } +} + +#[test] +fn feature_requests_empty_for_non_platform_source() { + let entries = vec![ + failed_entry("Ticket", "Create", "EntitySetNotFound"), + failed_entry("Ticket", "Create", "EntitySetNotFound"), + failed_entry("Ticket", "Create", "EntitySetNotFound"), + ]; + assert!( + generate_feature_requests(&entries).is_empty(), + "non-platform source should not generate FRs" + ); +} + +#[test] +fn feature_requests_below_threshold_skipped() { + let entries = vec![ + platform_failed_entry("Task", "Archive", "EntitySetNotFound"), + platform_failed_entry("Task", "Archive", "EntitySetNotFound"), + ]; + assert!(generate_feature_requests(&entries).is_empty()); +} + +#[test] +fn feature_requests_above_threshold_generated() { + let entries = vec![ + platform_failed_entry("Report", "Generate", "ActionNotFound: Generate"), + platform_failed_entry("Report", "Generate", "ActionNotFound: Generate"), + platform_failed_entry("Report", "Generate", "ActionNotFound: Generate"), + ]; + let feature_requests = generate_feature_requests(&entries); + assert_eq!(feature_requests.len(), 1); + assert!(feature_requests[0].description.contains("Generate")); + assert_eq!(feature_requests[0].frequency, 3); +} + +#[test] +fn unmet_intents_open_vs_resolved() { + let entries = vec![ + failed_entry("Billing", "Charge", "EntitySetNotFound"), + failed_entry("Billing", "Charge", "EntitySetNotFound"), + entry("Billing", "SubmitSpec", true), + ]; + let intents = gap_analysis::generate_unmet_intents(&entries); + assert!(!intents.is_empty()); + let billing = intents + .iter() + .find(|intent| intent.entity_type == "Billing") + .unwrap(); + assert_eq!(billing.status, "resolved"); +} + +#[test] +fn intent_evidence_prefers_explicit_intent_and_detects_workaround() { + let entries = vec![ + failed_entry_with_intent( + "Invoice", + "GenerateInvoice", + "EntitySetNotFound: Invoice", + "Send an invoice to the customer", + "agent-1", + "session-1", + ), + success_entry_with_intent( + "InvoiceDraft", + "CreateDraft", + "Send an invoice to the customer", + "agent-1", + "session-1", + ), + ]; + + let evidence = intent_evidence::generate_intent_evidence(&entries); + assert_eq!(evidence.intent_candidates.len(), 1); + assert_eq!(evidence.workaround_patterns.len(), 1); + assert_eq!( + evidence.intent_candidates[0].intent_title, + "Send An Invoice To The Customer" + ); + assert_eq!(evidence.intent_candidates[0].suggested_kind, "workaround"); + assert_eq!(evidence.intent_candidates[0].workaround_count, 1); + assert_eq!(evidence.workaround_patterns[0].occurrences, 1); +} + +#[test] +fn intent_evidence_marks_abandonment_for_unrecovered_failures() { + let entries = vec![ + failed_entry_with_intent( + "Issue", + "MoveToTodo", + "Authorization denied", + "Move issue into active work", + "worker-1", + "session-2", + ), + failed_entry_with_intent( + "Issue", + "MoveToTodo", + "Authorization denied", + "Move issue into active work", + "worker-1", + "session-2", + ), + ]; + + let evidence = intent_evidence::generate_intent_evidence(&entries); + assert_eq!(evidence.intent_candidates.len(), 1); + assert_eq!(evidence.abandonment_patterns.len(), 1); + assert_eq!(evidence.intent_candidates[0].abandonment_count, 1); + assert_eq!( + evidence.intent_candidates[0].suggested_kind, + "governance_gap" + ); +} + +#[test] +fn categorize_error_patterns() { + assert_eq!( + categorize_error(Some("EntitySetNotFound: X")), + "EntitySetNotFound" + ); + assert_eq!( + categorize_error(Some("Authorization denied")), + "AuthzDenied" + ); + assert_eq!( + categorize_error(Some("ActionNotFound: Y")), + "ActionNotFound" + ); + assert_eq!(categorize_error(Some("guard rejected")), "GuardRejected"); + assert_eq!(categorize_error(Some("something else")), "Other"); + assert_eq!(categorize_error(None), "Unknown"); +} diff --git a/crates/temper-server/src/observe/evolution/operations.rs b/crates/temper-server/src/observe/evolution/operations.rs index 39bf2468..6137a10b 100644 --- a/crates/temper-server/src/observe/evolution/operations.rs +++ b/crates/temper-server/src/observe/evolution/operations.rs @@ -1,18 +1,11 @@ use std::collections::BTreeMap; use std::convert::Infallible; -use axum::extract::{Json as ExtractJson, Path, Query, State}; +use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::Json; use axum::response::sse::{Event, KeepAlive, Sse}; -use serde::{Deserialize, Serialize}; -use temper_evolution::records::{ImpactAssessment, SolutionOption}; -use temper_evolution::{ - AnalysisRecord, Complexity, FeatureRequestDisposition, InsightCategory, InsightRecord, - InsightSignal, ObservationClass, ObservationRecord, ProblemRecord, RecordHeader, RecordType, - Severity, SolutionRisk, Trend, -}; -use temper_runtime::scheduler::{sim_now, sim_uuid}; +use temper_evolution::FeatureRequestDisposition; use temper_runtime::tenant::TenantId; use tokio_stream::StreamExt; use tokio_stream::wrappers::BroadcastStream; @@ -21,536 +14,19 @@ use tracing::instrument; use super::insight_generator; use crate::authz::require_observe_auth; use crate::odata::extract_tenant; -use crate::request_context::{AgentContext, extract_agent_context}; +use crate::request_context::AgentContext; use crate::sentinel; -use crate::state::{DispatchExtOptions, ServerState}; - -/// Persist an evolution record to Turso and return whether persistence succeeded. -async fn persist_evolution_record( - state: &ServerState, - record_id: &str, - record_type: &str, - status: &str, - created_by: &str, - derived_from: Option<&str>, - data_json: &str, -) -> Result<(), String> { - let Some(turso) = state.platform_persistent_store() else { - tracing::debug!( - record_id, - record_type, - status, - created_by, - "evolution.store.unavailable" - ); - return Ok(()); - }; - turso - .insert_evolution_record( - record_id, - record_type, - status, - created_by, - derived_from, - data_json, - ) - .await - .map_err(|e| { - tracing::warn!( - record_id, - record_type, - status, - created_by, - error = %e, - "evolution.store.write" - ); - e.to_string() - })?; - tracing::info!( - record_id, - record_type, - status, - created_by, - derived_from, - "evolution.store.write" - ); - Ok(()) -} - -/// Create an entity in the temper-system tenant, logging a warning on failure. -async fn create_system_entity( - state: &ServerState, - entity_type: &str, - entity_id: &str, - action: &str, - params: serde_json::Value, -) { - let system_tenant = TenantId::new("temper-system"); - if let Err(e) = state - .dispatch_tenant_action( - &system_tenant, - entity_type, - entity_id, - action, - params, - &AgentContext::system(), - ) - .await - { - tracing::warn!(error = %e, entity_type, entity_id, "failed to create system entity"); - } -} - -/// Persist sentinel alerts to Turso and create Observation entities. -async fn persist_alerts( - state: &ServerState, - alerts: &[sentinel::SentinelAlert], -) -> Result, StatusCode> { - let mut results = Vec::new(); - for alert in alerts { - tracing::warn!( - rule = %alert.rule_name, - record_id = %alert.record.header.id, - source = %alert.record.source, - classification = ?alert.record.classification, - observed_value = ?alert.record.observed_value, - threshold = ?alert.record.threshold_value, - "evolution.sentinel" - ); - let data_json = serde_json::to_string(&alert.record).unwrap_or_default(); - if let Err(e) = persist_evolution_record( - state, - &alert.record.header.id, - "Observation", - &format!("{:?}", alert.record.header.status), - &alert.record.header.created_by, - alert.record.header.derived_from.as_deref(), - &data_json, - ) - .await - { - tracing::warn!( - record_id = %alert.record.header.id, - error = %e, - "evolution.store.write" - ); - return Err(StatusCode::INTERNAL_SERVER_ERROR); - } - - let obs_id = format!("OBS-{}", sim_uuid()); - create_system_entity( - state, - "Observation", - &obs_id, - "CreateObservation", - serde_json::json!({ - "source": alert.record.source, - "classification": format!("{:?}", alert.record.classification), - "evidence_query": alert.record.evidence_query, - "context": serde_json::to_string(&alert.record.context).unwrap_or_default(), - "tenant": "temper-system", - "legacy_record_id": alert.record.header.id, - }), - ) - .await; - - results.push(serde_json::json!({ - "rule": alert.rule_name, - "record_id": alert.record.header.id, - "entity_id": obs_id, - "source": alert.record.source, - "classification": alert.record.classification, - "threshold": alert.record.threshold_value, - "observed": alert.record.observed_value, - })); - } - Ok(results) -} - -/// Persist generated insights to Turso and create Insight entities. -async fn persist_insights( - state: &ServerState, - insights: &[temper_evolution::InsightRecord], -) -> Vec { - let mut results = Vec::new(); - for insight in insights { - tracing::info!( - record_id = %insight.header.id, - category = ?insight.category, - intent = %insight.signal.intent, - volume = insight.signal.volume, - success_rate = insight.signal.success_rate, - priority_score = insight.priority_score, - "evolution.insight" - ); - let data_json = serde_json::to_string(insight).unwrap_or_default(); - if let Err(e) = persist_evolution_record( - state, - &insight.header.id, - "Insight", - &format!("{:?}", insight.header.status), - &insight.header.created_by, - insight.header.derived_from.as_deref(), - &data_json, - ) - .await - { - tracing::warn!(record_id = %insight.header.id, error = %e, "evolution.store.write"); - } - - let insight_id = format!("INS-{}", sim_uuid()); - create_system_entity( - state, - "Insight", - &insight_id, - "CreateInsight", - serde_json::json!({ - "observation_id": "", - "category": format!("{:?}", insight.category), - "signal": insight.signal.intent, - "recommendation": insight.recommendation, - "priority_score": format!("{:.4}", insight.priority_score), - "legacy_record_id": insight.header.id, - }), - ) - .await; - - results.push(serde_json::json!({ - "record_id": insight.header.id, - "entity_id": insight_id, - "category": format!("{:?}", insight.category), - "intent": insight.signal.intent, - "priority_score": insight.priority_score, - "recommendation": insight.recommendation, - })); - } - results -} - -#[derive(Debug, Deserialize)] -pub(crate) struct EvolutionAnalyzeRequest { - pub reason: Option, - pub source: Option, - pub trigger_context: Option, -} - -#[derive(Debug, Deserialize)] -pub(crate) struct EvolutionMaterializeRequest { - pub intent_discovery_id: String, - pub analysis_json: String, - pub signal_summary_json: String, - pub tenant: Option, - pub reason: Option, - pub source: Option, -} - -#[derive(Debug, Default, Deserialize)] -struct AgentAnalysisPayload { - #[serde(default)] - summary: String, - #[serde(default)] - findings: Vec, -} - -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -struct AgentFinding { - #[serde(default)] - kind: String, - #[serde(default)] - title: String, - #[serde(default)] - symptom_title: String, - #[serde(default)] - intent_title: String, - #[serde(default)] - recommended_issue_title: String, - #[serde(default)] - intent: String, - #[serde(default)] - recommendation: String, - #[serde(default)] - priority_score: f64, - #[serde(default)] - volume: u64, - #[serde(default)] - success_rate: f64, - #[serde(default)] - trend: String, - #[serde(default)] - requires_spec_change: bool, - #[serde(default)] - problem_statement: String, - #[serde(default)] - root_cause: String, - #[serde(default)] - spec_diff: String, - #[serde(default)] - acceptance_criteria: Vec, - #[serde(default)] - dedupe_key: String, - #[serde(default)] - evidence: serde_json::Value, -} - -async fn spawn_intent_discovery( - state: &ServerState, - tenant: &TenantId, - reason: &str, - source: &str, - trigger_context: serde_json::Value, - agent_ctx: &AgentContext, - await_integration: bool, -) -> Result<(String, crate::entity_actor::EntityResponse), String> { - let discovery_id = format!("intent-discovery-{}", sim_uuid()); - let response = state - .dispatch_tenant_action_ext( - tenant, - "IntentDiscovery", - &discovery_id, - "Trigger", - serde_json::json!({ - "reason": reason, - "source": source, - "trigger_context_json": trigger_context.to_string(), - }), - DispatchExtOptions { - agent_ctx, - await_integration, - }, - ) - .await?; - Ok((discovery_id, response)) -} - -fn next_system_entity_id(prefix: &str) -> String { - format!("{prefix}-{}", sim_uuid()) -} - -fn trend_from_str(value: &str) -> Trend { - match value.trim().to_ascii_lowercase().as_str() { - "declining" => Trend::Declining, - "stable" => Trend::Stable, - _ => Trend::Growing, - } -} - -fn severity_from_score(score: f64) -> Severity { - if score >= 0.85 { - Severity::Critical - } else if score >= 0.65 { - Severity::High - } else if score >= 0.40 { - Severity::Medium - } else { - Severity::Low - } -} - -fn solution_risk_from_score(score: f64) -> SolutionRisk { - if score >= 0.85 { - SolutionRisk::High - } else if score >= 0.65 { - SolutionRisk::Medium - } else if score >= 0.35 { - SolutionRisk::Low - } else { - SolutionRisk::None - } -} - -fn complexity_from_finding(finding: &AgentFinding) -> Complexity { - match finding.kind.trim().to_ascii_lowercase().as_str() { - "friction" => Complexity::Low, - "governance_gap" => Complexity::Low, - "workaround" => Complexity::Medium, - _ => Complexity::Medium, - } -} - -fn observation_class_for_finding(finding: &AgentFinding) -> ObservationClass { - match finding.kind.trim().to_ascii_lowercase().as_str() { - "governance_gap" => ObservationClass::AuthzDenied, - _ => ObservationClass::Trajectory, - } -} - -fn insight_category_for_finding(finding: &AgentFinding) -> InsightCategory { - match finding.kind.trim().to_ascii_lowercase().as_str() { - "friction" => InsightCategory::Friction, - "workaround" => InsightCategory::Workaround, - "governance_gap" => InsightCategory::PlatformGap, - _ => InsightCategory::UnmetIntent, - } -} - -fn issue_priority_level(score: f64) -> i64 { - if score >= 0.85 { - 1 - } else if score >= 0.65 { - 2 - } else if score >= 0.40 { - 3 - } else { - 4 - } -} - -fn preferred_title(candidates: &[&str], fallback: &str) -> String { - candidates - .iter() - .find_map(|value| { - let trimmed = value.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) - }) - .unwrap_or_else(|| fallback.to_string()) -} - -fn finding_symptom_title(finding: &AgentFinding) -> String { - preferred_title( - &[ - &finding.symptom_title, - &finding.title, - &finding.problem_statement, - ], - "Observed workflow symptom", - ) -} - -fn finding_intent_title(finding: &AgentFinding) -> String { - preferred_title( - &[&finding.intent_title, &finding.intent, &finding.title], - "Enable unmet intent", - ) -} - -fn finding_issue_title(finding: &AgentFinding) -> String { - preferred_title( - &[ - &finding.recommended_issue_title, - &finding.intent_title, - &finding.title, - &finding.intent, - &finding.symptom_title, - ], - "Investigate unmet intent", - ) -} +use crate::state::{ObserveRefreshHint, ServerState}; -fn default_acceptance_criteria(finding: &AgentFinding) -> Vec { - if !finding.acceptance_criteria.is_empty() { - return finding.acceptance_criteria.clone(); - } - let issue_title = finding_issue_title(finding); - vec![ - format!( - "Agents can complete '{}' without the current failure mode.", - issue_title - ), - "Observe metrics show improved completion for the affected workflow.".to_string(), - ] -} - -fn build_issue_description(summary: &str, finding: &AgentFinding, record_ids: &[String]) -> String { - let acceptance_criteria = default_acceptance_criteria(finding) - .into_iter() - .map(|item| format!("- {item}")) - .collect::>() - .join("\n"); - format!( - "Summary:\n{summary}\n\nIntent Title:\n{}\n\nObserved Symptom:\n{}\n\nIntent:\n{}\n\nRecommendation:\n{}\n\nProblem Statement:\n{}\n\nRoot Cause:\n{}\n\nSpec Diff:\n{}\n\nAcceptance Criteria:\n{}\n\nEvidence:\n{}\n\nEvolution Records:\n{}", - finding_intent_title(finding), - finding_symptom_title(finding), - if finding.intent.is_empty() { - "No explicit intent supplied." - } else { - finding.intent.as_str() - }, - finding.recommendation, - if finding.problem_statement.is_empty() { - "No formal problem statement supplied." - } else { - finding.problem_statement.as_str() - }, - if finding.root_cause.is_empty() { - "No root cause supplied." - } else { - finding.root_cause.as_str() - }, - if finding.spec_diff.is_empty() { - "No spec diff supplied." - } else { - finding.spec_diff.as_str() - }, - acceptance_criteria, - serde_json::to_string_pretty(&finding.evidence).unwrap_or_else(|_| "{}".to_string()), - record_ids.join(", ") - ) -} +mod materialize; +mod support; -async fn create_issue_for_finding( - state: &ServerState, - tenant: &TenantId, - summary: &str, - finding: &AgentFinding, - record_ids: &[String], -) -> Result { - let issue_id = sim_uuid().to_string(); - let now = sim_now().to_rfc3339(); - let description = build_issue_description(summary, finding, record_ids); - let acceptance_criteria = default_acceptance_criteria(finding).join("\n"); - let issue_title = finding_issue_title(finding); +pub(crate) use materialize::{handle_evolution_analyze, handle_evolution_materialize}; - state - .get_or_create_tenant_entity( - tenant, - "Issue", - &issue_id, - serde_json::json!({ - "Id": issue_id.clone(), - "Title": issue_title, - "Description": description, - "AcceptanceCriteria": acceptance_criteria, - "Priority": issue_priority_level(finding.priority_score), - "CreatedAt": now, - "UpdatedAt": now, - }), - ) - .await?; - - let system_ctx = AgentContext::system(); - let _ = state - .dispatch_tenant_action( - tenant, - "Issue", - &issue_id, - "SetPriority", - serde_json::json!({ "level": issue_priority_level(finding.priority_score) }), - &system_ctx, - ) - .await; - let _ = state - .dispatch_tenant_action( - tenant, - "Issue", - &issue_id, - "MoveToTriage", - serde_json::json!({}), - &system_ctx, - ) - .await; - let _ = state - .dispatch_tenant_action( - tenant, - "Issue", - &issue_id, - "MoveToTodo", - serde_json::json!({}), - &system_ctx, - ) - .await; - - Ok(issue_id) -} +use support::{ + create_system_entity_logged, emit_refresh_hints, next_system_entity_id, persist_alerts, + persist_insights, spawn_intent_discovery, +}; /// POST /api/evolution/sentinel/check -- trigger sentinel rule evaluation. /// @@ -588,9 +64,11 @@ pub(crate) async fn handle_sentinel_check( ); } let results = persist_alerts(&state, &alerts).await?; + let analysis_tenant = extract_tenant(&headers, &state).unwrap_or_else(|_| TenantId::new("temper-system")); let mut discovery_results = Vec::new(); + let system_ctx = AgentContext::system(); for alert in &alerts { let trigger_context = serde_json::json!({ "rule_name": alert.rule_name.clone(), @@ -605,7 +83,7 @@ pub(crate) async fn handle_sentinel_check( &format!("sentinel:{}", alert.rule_name), "automated", trigger_context, - &AgentContext::system(), + &system_ctx, false, ) .await @@ -614,8 +92,12 @@ pub(crate) async fn handle_sentinel_check( "entity_id": entity_id, "reason": format!("sentinel:{}", alert.rule_name), })), - Err(e) => { - tracing::warn!(error = %e, rule = %alert.rule_name, "failed to create IntentDiscovery from sentinel") + Err(error) => { + tracing::warn!( + error = %error, + rule = %alert.rule_name, + "failed to create IntentDiscovery from sentinel" + ); } } } @@ -625,19 +107,15 @@ pub(crate) async fn handle_sentinel_check( tracing::info!(insights_count = insights.len(), "evolution.insight"); let insight_results = persist_insights(&state, &insights).await; - // Notify Observe UI that evolution data changed. - let _ = state - .observe_refresh_tx - .send(crate::state::ObserveRefreshHint::EvolutionRecords); - let _ = state - .observe_refresh_tx - .send(crate::state::ObserveRefreshHint::EvolutionInsights); - let _ = state - .observe_refresh_tx - .send(crate::state::ObserveRefreshHint::UnmetIntents); - let _ = state - .observe_refresh_tx - .send(crate::state::ObserveRefreshHint::FeatureRequests); + emit_refresh_hints( + &state, + &[ + ObserveRefreshHint::EvolutionRecords, + ObserveRefreshHint::EvolutionInsights, + ObserveRefreshHint::UnmetIntents, + ObserveRefreshHint::FeatureRequests, + ], + ); Ok(Json(serde_json::json!({ "alerts_count": alerts.len(), @@ -667,8 +145,14 @@ pub(crate) async fn handle_unmet_intents( let (failure_rows, submitted_specs) = state.load_unmet_intent_rows_aggregated().await; let intents = insight_generator::generate_unmet_intents_from_aggregated(&failure_rows, &submitted_specs); - let open_count = intents.iter().filter(|i| i.status == "open").count(); - let resolved_count = intents.iter().filter(|i| i.status == "resolved").count(); + let open_count = intents + .iter() + .filter(|intent| intent.status == "open") + .count(); + let resolved_count = intents + .iter() + .filter(|intent| intent.status == "resolved") + .count(); tracing::Span::current().record("open_count", open_count); tracing::Span::current().record("resolved_count", resolved_count); tracing::Span::current().record("total_intents", intents.len()); @@ -688,7 +172,6 @@ pub(crate) async fn handle_unmet_intents( ); } - // Per-intent detail at debug level to avoid OTEL span spam on every poll. for intent in &intents { tracing::debug!( entity_type = %intent.entity_type, @@ -743,96 +226,82 @@ pub(crate) async fn handle_feature_requests( Query(params): Query>, ) -> Result, StatusCode> { require_observe_auth(&state, &headers, "read_evolution", "Evolution")?; - let disposition_filter = params.get("disposition").map(|d| d.as_str()); + let disposition_filter = params.get("disposition").map(String::as_str); - // Load trajectory entries for feature request generation. let trajectory_entries = state.load_trajectory_entries(1_000).await; - // Query Turso directly (single source of truth). - let system_tenant = TenantId::new("temper-system"); if let Some(turso) = state.platform_persistent_store() { - // First, generate and upsert fresh feature requests from trajectory data. let generated = insight_generator::generate_feature_requests(&trajectory_entries); - for fr in &generated { - let refs_json = - serde_json::to_string(&fr.trajectory_refs).unwrap_or_else(|_| "[]".to_string()); - let disp_str = match fr.disposition { + for feature_request in &generated { + let refs_json = serde_json::to_string(&feature_request.trajectory_refs) + .unwrap_or_else(|_| "[]".to_string()); + let disposition = match feature_request.disposition { FeatureRequestDisposition::Open => "Open", FeatureRequestDisposition::Acknowledged => "Acknowledged", FeatureRequestDisposition::Planned => "Planned", FeatureRequestDisposition::WontFix => "WontFix", FeatureRequestDisposition::Resolved => "Resolved", }; - if let Err(e) = turso + if let Err(error) = turso .upsert_feature_request( - &fr.header.id, - &format!("{:?}", fr.category), - &fr.description, - fr.frequency as i64, + &feature_request.header.id, + &format!("{:?}", feature_request.category), + &feature_request.description, + feature_request.frequency as i64, &refs_json, - disp_str, - fr.developer_notes.as_deref(), + disposition, + feature_request.developer_notes.as_deref(), ) .await { - tracing::warn!(error = %e, "failed to upsert feature request to Turso"); + tracing::warn!(error = %error, "failed to upsert feature request to Turso"); } - // Also create FeatureRequest entity in temper-system tenant. - let fr_id = format!("FR-{}", sim_uuid()); - let fr_params = serde_json::json!({ - "category": format!("{:?}", fr.category), - "description": fr.description, - "frequency": format!("{}", fr.frequency), - "developer_notes": fr.developer_notes.clone().unwrap_or_default(), - "legacy_record_id": fr.header.id, - }); - if let Err(e) = state - .dispatch_tenant_action( - &system_tenant, - "FeatureRequest", - &fr_id, - "CreateFeatureRequest", - fr_params, - &AgentContext::system(), - ) - .await - { - tracing::warn!(error = %e, "failed to create FeatureRequest entity"); - } + create_system_entity_logged( + &state, + "FeatureRequest", + &next_system_entity_id("FR"), + "CreateFeatureRequest", + serde_json::json!({ + "category": format!("{:?}", feature_request.category), + "description": feature_request.description, + "frequency": feature_request.frequency.to_string(), + "developer_notes": feature_request.developer_notes.clone().unwrap_or_default(), + "legacy_record_id": feature_request.header.id, + }), + ) + .await; } - // Then read back from Turso with filter. - match turso.list_feature_requests(disposition_filter).await { + return match turso.list_feature_requests(disposition_filter).await { Ok(rows) => { - let items: Vec = rows + let feature_requests = rows .iter() - .map(|r| { + .map(|row| { serde_json::json!({ - "id": r.id, - "category": r.category, - "description": r.description, - "frequency": r.frequency, - "trajectory_refs": serde_json::from_str::(&r.trajectory_refs).unwrap_or_default(), - "disposition": r.disposition, - "developer_notes": r.developer_notes, - "created_at": r.created_at, + "id": row.id, + "category": row.category, + "description": row.description, + "frequency": row.frequency, + "trajectory_refs": serde_json::from_str::(&row.trajectory_refs).unwrap_or_default(), + "disposition": row.disposition, + "developer_notes": row.developer_notes, + "created_at": row.created_at, }) }) - .collect(); - let total = items.len(); - return Ok(Json( - serde_json::json!({ "feature_requests": items, "total": total }), - )); + .collect::>(); + Ok(Json(serde_json::json!({ + "feature_requests": feature_requests, + "total": feature_requests.len(), + }))) } - Err(e) => { - tracing::warn!(error = %e, "failed to query feature requests from Turso"); - return Err(StatusCode::SERVICE_UNAVAILABLE); + Err(error) => { + tracing::warn!(error = %error, "failed to query feature requests from Turso"); + Err(StatusCode::SERVICE_UNAVAILABLE) } - } + }; } - // No persistent store configured — return empty. Ok(Json( serde_json::json!({ "feature_requests": [], "total": 0 }), )) @@ -849,7 +318,6 @@ pub(crate) async fn handle_update_feature_request( Path(id): Path, Json(body): Json, ) -> Result, StatusCode> { - // Cedar authorization: admin/system bypass, others need manage_feature_requests. require_observe_auth( &state, &headers, @@ -857,15 +325,16 @@ pub(crate) async fn handle_update_feature_request( "FeatureRequest", )?; - let disposition = body.get("disposition").and_then(|v| v.as_str()); - let notes = body.get("developer_notes").and_then(|v| v.as_str()); + let disposition = body.get("disposition").and_then(serde_json::Value::as_str); + let notes = body + .get("developer_notes") + .and_then(serde_json::Value::as_str); - // Validate disposition if provided. - if let Some(d) = disposition { - match d.to_lowercase().as_str() { + if let Some(value) = disposition { + match value.to_lowercase().as_str() { "open" | "acknowledged" | "planned" | "wontfix" | "wont_fix" | "resolved" => {} _ => { - tracing::warn!(disposition = %d, "invalid disposition value"); + tracing::warn!(disposition = %value, "invalid disposition value"); return Err(StatusCode::BAD_REQUEST); } } @@ -878,14 +347,12 @@ pub(crate) async fn handle_update_feature_request( turso .update_feature_request(&id, disposition.unwrap_or(""), notes) .await - .map_err(|e| { - tracing::error!(error = %e, "failed to update feature request"); + .map_err(|error| { + tracing::error!(error = %error, "failed to update feature request"); StatusCode::INTERNAL_SERVER_ERROR })?; - let _ = state - .observe_refresh_tx - .send(crate::state::ObserveRefreshHint::FeatureRequests); + emit_refresh_hints(&state, &[ObserveRefreshHint::FeatureRequests]); Ok(Json(serde_json::json!({ "id": id, @@ -893,362 +360,6 @@ pub(crate) async fn handle_update_feature_request( }))) } -/// POST /api/evolution/analyze -- create and run one IntentDiscovery cycle. -#[instrument(skip_all, fields(otel.name = "POST /api/evolution/analyze"))] -pub(crate) async fn handle_evolution_analyze( - State(state): State, - headers: HeaderMap, - body: axum::body::Bytes, -) -> Result, StatusCode> { - require_observe_auth(&state, &headers, "run_sentinel", "Evolution")?; - let tenant = extract_tenant(&headers, &state).map_err(|_| StatusCode::BAD_REQUEST)?; - let payload = if body.is_empty() { - EvolutionAnalyzeRequest { - reason: None, - source: None, - trigger_context: None, - } - } else { - serde_json::from_slice::(&body) - .map_err(|_| StatusCode::BAD_REQUEST)? - }; - let agent_ctx = extract_agent_context(&headers); - let reason = payload.reason.unwrap_or_else(|| "manual".to_string()); - let source = payload.source.unwrap_or_else(|| "developer".to_string()); - let trigger_context = payload - .trigger_context - .unwrap_or_else(|| serde_json::json!({})); - - let (entity_id, response) = spawn_intent_discovery( - &state, - &tenant, - &reason, - &source, - trigger_context, - &agent_ctx, - true, - ) - .await - .map_err(|e| { - tracing::warn!(error = %e, tenant = %tenant, "failed to run IntentDiscovery"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(Json(serde_json::json!({ - "tenant": tenant.as_str(), - "entity_id": entity_id, - "success": response.success, - "status": response.state.status, - "error": response.error, - "fields": response.state.fields, - }))) -} - -/// POST /api/evolution/materialize -- persist O/P/A/I records and PM issues. -#[instrument(skip_all, fields(otel.name = "POST /api/evolution/materialize"))] -pub(crate) async fn handle_evolution_materialize( - State(state): State, - headers: HeaderMap, - ExtractJson(payload): ExtractJson, -) -> Result, StatusCode> { - require_observe_auth(&state, &headers, "run_sentinel", "Evolution")?; - let tenant = extract_tenant(&headers, &state).map_err(|_| StatusCode::BAD_REQUEST)?; - let analysis = serde_json::from_str::(&payload.analysis_json) - .map_err(|_| StatusCode::BAD_REQUEST)?; - let signal_summary = serde_json::from_str::(&payload.signal_summary_json) - .unwrap_or_else(|_| serde_json::json!({})); - let system_tenant = TenantId::new("temper-system"); - let summary = if analysis.summary.is_empty() { - "IntentDiscovery produced structured findings.".to_string() - } else { - analysis.summary.clone() - }; - - let mut record_ids = Vec::::new(); - let mut issue_ids = Vec::::new(); - let mut findings_report = Vec::::new(); - - for finding in &analysis.findings { - let mut finding_record_ids = Vec::::new(); - let mut observation_entity_id = String::new(); - let mut derived_from_record_id: Option = None; - - if finding.requires_spec_change { - let observation = ObservationRecord { - header: RecordHeader::new(RecordType::Observation, "intent-discovery"), - source: format!( - "intent-discovery:{}", - if finding.kind.is_empty() { - "analysis" - } else { - finding.kind.as_str() - } - ), - classification: observation_class_for_finding(finding), - evidence_query: format!( - "intent discovery {} -> symptom={} intent={}", - payload.intent_discovery_id, - finding_symptom_title(finding), - finding_intent_title(finding) - ), - threshold_field: None, - threshold_value: None, - observed_value: Some(finding.volume as f64), - context: serde_json::json!({ - "tenant": tenant.as_str(), - "reason": payload.reason, - "source": payload.source, - "signal_summary": signal_summary.clone(), - "finding": finding, - }), - }; - let observation_json = serde_json::to_string(&observation) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - persist_evolution_record( - &state, - &observation.header.id, - "Observation", - &format!("{:?}", observation.header.status), - &observation.header.created_by, - observation.header.derived_from.as_deref(), - &observation_json, - ) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - finding_record_ids.push(observation.header.id.clone()); - record_ids.push(observation.header.id.clone()); - - observation_entity_id = next_system_entity_id("OBS"); - create_system_entity( - &state, - "Observation", - &observation_entity_id, - "CreateObservation", - serde_json::json!({ - "source": observation.source, - "classification": format!("{:?}", observation.classification), - "evidence_query": observation.evidence_query, - "context": serde_json::to_string(&observation.context).unwrap_or_default(), - "tenant": tenant.as_str(), - "legacy_record_id": observation.header.id, - }), - ) - .await; - - let problem = ProblemRecord { - header: RecordHeader::new(RecordType::Problem, "intent-discovery") - .derived_from(&observation.header.id), - problem_statement: if finding.problem_statement.is_empty() { - format!( - "{} blocks intended workflow completion.", - finding_intent_title(finding) - ) - } else { - finding.problem_statement.clone() - }, - invariants: default_acceptance_criteria(finding), - constraints: if finding.dedupe_key.is_empty() { - Vec::new() - } else { - vec![format!("dedupe_key={}", finding.dedupe_key)] - }, - impact: ImpactAssessment { - affected_users: Some(finding.volume), - severity: severity_from_score(finding.priority_score), - trend: trend_from_str(&finding.trend), - }, - }; - let problem_json = - serde_json::to_string(&problem).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - persist_evolution_record( - &state, - &problem.header.id, - "Problem", - &format!("{:?}", problem.header.status), - &problem.header.created_by, - problem.header.derived_from.as_deref(), - &problem_json, - ) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - finding_record_ids.push(problem.header.id.clone()); - record_ids.push(problem.header.id.clone()); - - let problem_entity_id = next_system_entity_id("PRB"); - state - .dispatch_tenant_action( - &system_tenant, - "Problem", - &problem_entity_id, - "CreateProblem", - serde_json::json!({ - "observation_id": observation_entity_id, - "problem_statement": problem.problem_statement, - "severity": problem.impact.severity.to_string(), - "invariants": serde_json::to_string(&problem.invariants).unwrap_or_default(), - }), - &AgentContext::system(), - ) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - state - .dispatch_tenant_action( - &system_tenant, - "Problem", - &problem_entity_id, - "MarkReviewed", - serde_json::json!({}), - &AgentContext::system(), - ) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let analysis_record = AnalysisRecord { - header: RecordHeader::new(RecordType::Analysis, "intent-discovery") - .derived_from(&problem.header.id), - root_cause: if finding.root_cause.is_empty() { - "IntentDiscovery inferred a missing platform capability.".to_string() - } else { - finding.root_cause.clone() - }, - options: vec![SolutionOption { - description: finding.recommendation.clone(), - spec_diff: if finding.spec_diff.is_empty() { - "No explicit spec diff supplied.".to_string() - } else { - finding.spec_diff.clone() - }, - tla_impact: "NONE".to_string(), - risk: solution_risk_from_score(finding.priority_score), - complexity: complexity_from_finding(finding), - }], - recommendation: Some(0), - }; - let analysis_record_json = serde_json::to_string(&analysis_record) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - persist_evolution_record( - &state, - &analysis_record.header.id, - "Analysis", - &format!("{:?}", analysis_record.header.status), - &analysis_record.header.created_by, - analysis_record.header.derived_from.as_deref(), - &analysis_record_json, - ) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - finding_record_ids.push(analysis_record.header.id.clone()); - record_ids.push(analysis_record.header.id.clone()); - derived_from_record_id = Some(analysis_record.header.id.clone()); - - let analysis_entity_id = next_system_entity_id("ANL"); - state - .dispatch_tenant_action( - &system_tenant, - "Analysis", - &analysis_entity_id, - "CreateAnalysis", - serde_json::json!({ - "problem_id": problem_entity_id, - "root_cause": analysis_record.root_cause, - "options": serde_json::to_string(&analysis_record.options).unwrap_or_default(), - "recommendation": analysis_record.recommendation.unwrap_or_default().to_string(), - }), - &AgentContext::system(), - ) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - } - - let mut insight_header = RecordHeader::new(RecordType::Insight, "intent-discovery"); - if let Some(parent) = derived_from_record_id.as_ref() { - insight_header = insight_header.derived_from(parent.clone()); - } - let insight = InsightRecord { - header: insight_header, - category: insight_category_for_finding(finding), - signal: InsightSignal { - intent: if finding.intent.is_empty() { - finding_intent_title(finding) - } else { - finding.intent.clone() - }, - volume: finding.volume, - success_rate: finding.success_rate, - trend: trend_from_str(&finding.trend), - growth_rate: None, - }, - recommendation: finding.recommendation.clone(), - priority_score: finding.priority_score, - }; - let insight_json = - serde_json::to_string(&insight).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - persist_evolution_record( - &state, - &insight.header.id, - "Insight", - &format!("{:?}", insight.header.status), - &insight.header.created_by, - insight.header.derived_from.as_deref(), - &insight_json, - ) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - finding_record_ids.push(insight.header.id.clone()); - record_ids.push(insight.header.id.clone()); - - create_system_entity( - &state, - "Insight", - &next_system_entity_id("INS"), - "CreateInsight", - serde_json::json!({ - "observation_id": observation_entity_id, - "category": format!("{:?}", insight.category), - "signal": insight.signal.intent, - "recommendation": insight.recommendation, - "priority_score": format!("{:.4}", insight.priority_score), - "legacy_record_id": insight.header.id, - }), - ) - .await; - - let issue_id = - create_issue_for_finding(&state, &tenant, &summary, finding, &finding_record_ids) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - issue_ids.push(issue_id.clone()); - findings_report.push(serde_json::json!({ - "title": finding_issue_title(finding), - "intent_title": finding_intent_title(finding), - "symptom_title": finding_symptom_title(finding), - "kind": finding.kind.clone(), - "record_ids": finding_record_ids, - "issue_id": issue_id, - })); - } - - let _ = state - .observe_refresh_tx - .send(crate::state::ObserveRefreshHint::EvolutionRecords); - let _ = state - .observe_refresh_tx - .send(crate::state::ObserveRefreshHint::EvolutionInsights); - let _ = state - .observe_refresh_tx - .send(crate::state::ObserveRefreshHint::Entities); - - Ok(Json(serde_json::json!({ - "intent_discovery_id": payload.intent_discovery_id, - "tenant": payload.tenant.unwrap_or_else(|| tenant.as_str().to_string()), - "records_created_count": record_ids.len(), - "issues_created_count": issue_ids.len(), - "record_ids": record_ids, - "issue_ids": issue_ids, - "findings": findings_report, - }))) -} - /// GET /observe/evolution/stream -- SSE for real-time evolution events. /// /// Streams new evolution records and insights as they are generated. @@ -1259,18 +370,15 @@ pub(crate) async fn handle_evolution_stream( headers: HeaderMap, ) -> Result>>, StatusCode> { require_observe_auth(&state, &headers, "read_evolution", "EvolutionStream")?; - // Subscribe to pending decision broadcasts (which include authz denials - // that create evolution records). A dedicated evolution broadcast channel - // could be added later for O/P/A/D/I records specifically. let rx = state.pending_decision_tx.subscribe(); let stream = BroadcastStream::new(rx).filter_map(|result| match result { - Ok(pd) => Some(Ok(Event::default() + Ok(pending_decision) => Some(Ok(Event::default() .event("evolution_event") .json_data(serde_json::json!({ "type": "new_decision", - "decision_id": pd.id, - "action": pd.action, - "resource_type": pd.resource_type, + "decision_id": pending_decision.id, + "action": pending_decision.action, + "resource_type": pending_decision.resource_type, "status": "pending", })) .unwrap_or_else(|_| Event::default().data("{}")))), @@ -1279,33 +387,3 @@ pub(crate) async fn handle_evolution_stream( Ok(Sse::new(stream).keep_alive(KeepAlive::default())) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn issue_title_prefers_intent_shaped_fields() { - let finding = AgentFinding { - title: "Invoice entity type not implemented".to_string(), - symptom_title: "GenerateInvoice hits EntitySetNotFound on Invoice".to_string(), - intent_title: "Enable invoice generation workflow".to_string(), - recommended_issue_title: "Enable invoice generation workflow".to_string(), - intent: "Generate invoices for customers".to_string(), - ..AgentFinding::default() - }; - - assert_eq!( - finding_issue_title(&finding), - "Enable invoice generation workflow" - ); - assert_eq!( - finding_symptom_title(&finding), - "GenerateInvoice hits EntitySetNotFound on Invoice" - ); - assert_eq!( - finding_intent_title(&finding), - "Enable invoice generation workflow" - ); - } -} diff --git a/crates/temper-server/src/observe/evolution/operations/materialize.rs b/crates/temper-server/src/observe/evolution/operations/materialize.rs new file mode 100644 index 00000000..d6c0d022 --- /dev/null +++ b/crates/temper-server/src/observe/evolution/operations/materialize.rs @@ -0,0 +1,669 @@ +use axum::extract::{Json as ExtractJson, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::Json; +use serde::{Deserialize, Serialize}; +use temper_evolution::records::{ImpactAssessment, SolutionOption}; +use temper_evolution::{ + AnalysisRecord, Complexity, InsightCategory, InsightRecord, InsightSignal, ObservationClass, + ObservationRecord, ProblemRecord, RecordHeader, RecordType, Severity, SolutionRisk, Trend, +}; +use temper_runtime::scheduler::sim_now; +use temper_runtime::tenant::TenantId; +use tracing::instrument; + +use crate::authz::require_observe_auth; +use crate::odata::extract_tenant; +use crate::request_context::{AgentContext, extract_agent_context}; +use crate::state::{ObserveRefreshHint, ServerState}; + +use super::support::{ + create_system_entity_logged, dispatch_system_action_required, emit_refresh_hints, + next_system_entity_id, persist_record, spawn_intent_discovery, +}; + +#[cfg(test)] +#[path = "materialize_test.rs"] +mod tests; + +#[derive(Debug, Deserialize)] +pub(crate) struct EvolutionAnalyzeRequest { + pub reason: Option, + pub source: Option, + pub trigger_context: Option, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct EvolutionMaterializeRequest { + pub intent_discovery_id: String, + pub analysis_json: String, + pub signal_summary_json: String, + pub tenant: Option, + pub reason: Option, + pub source: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct AgentAnalysisPayload { + #[serde(default)] + summary: String, + #[serde(default)] + findings: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +struct AgentFinding { + #[serde(default)] + kind: String, + #[serde(default)] + title: String, + #[serde(default)] + symptom_title: String, + #[serde(default)] + intent_title: String, + #[serde(default)] + recommended_issue_title: String, + #[serde(default)] + intent: String, + #[serde(default)] + recommendation: String, + #[serde(default)] + priority_score: f64, + #[serde(default)] + volume: u64, + #[serde(default)] + success_rate: f64, + #[serde(default)] + trend: String, + #[serde(default)] + requires_spec_change: bool, + #[serde(default)] + problem_statement: String, + #[serde(default)] + root_cause: String, + #[serde(default)] + spec_diff: String, + #[serde(default)] + acceptance_criteria: Vec, + #[serde(default)] + dedupe_key: String, + #[serde(default)] + evidence: serde_json::Value, +} + +#[derive(Default)] +struct SpecChangeArtifacts { + record_ids: Vec, + observation_entity_id: String, + derived_from_record_id: Option, +} + +struct MaterializedFinding { + record_ids: Vec, + issue_id: String, + report: serde_json::Value, +} + +fn trend_from_str(value: &str) -> Trend { + match value.trim().to_ascii_lowercase().as_str() { + "declining" => Trend::Declining, + "stable" => Trend::Stable, + _ => Trend::Growing, + } +} + +fn severity_from_score(score: f64) -> Severity { + if score >= 0.85 { + Severity::Critical + } else if score >= 0.65 { + Severity::High + } else if score >= 0.40 { + Severity::Medium + } else { + Severity::Low + } +} + +fn solution_risk_from_score(score: f64) -> SolutionRisk { + if score >= 0.85 { + SolutionRisk::High + } else if score >= 0.65 { + SolutionRisk::Medium + } else if score >= 0.35 { + SolutionRisk::Low + } else { + SolutionRisk::None + } +} + +fn complexity_from_finding(finding: &AgentFinding) -> Complexity { + match finding.kind.trim().to_ascii_lowercase().as_str() { + "friction" | "governance_gap" => Complexity::Low, + "workaround" => Complexity::Medium, + _ => Complexity::Medium, + } +} + +fn observation_class_for_finding(finding: &AgentFinding) -> ObservationClass { + match finding.kind.trim().to_ascii_lowercase().as_str() { + "governance_gap" => ObservationClass::AuthzDenied, + _ => ObservationClass::Trajectory, + } +} + +fn insight_category_for_finding(finding: &AgentFinding) -> InsightCategory { + match finding.kind.trim().to_ascii_lowercase().as_str() { + "friction" => InsightCategory::Friction, + "workaround" => InsightCategory::Workaround, + "governance_gap" => InsightCategory::PlatformGap, + _ => InsightCategory::UnmetIntent, + } +} + +fn issue_priority_level(score: f64) -> i64 { + if score >= 0.85 { + 1 + } else if score >= 0.65 { + 2 + } else if score >= 0.40 { + 3 + } else { + 4 + } +} + +fn preferred_title(candidates: &[&str], fallback: &str) -> String { + candidates + .iter() + .find_map(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) + .unwrap_or_else(|| fallback.to_string()) +} + +fn finding_symptom_title(finding: &AgentFinding) -> String { + preferred_title( + &[ + &finding.symptom_title, + &finding.title, + &finding.problem_statement, + ], + "Observed workflow symptom", + ) +} + +fn finding_intent_title(finding: &AgentFinding) -> String { + preferred_title( + &[&finding.intent_title, &finding.intent, &finding.title], + "Enable unmet intent", + ) +} + +fn finding_issue_title(finding: &AgentFinding) -> String { + preferred_title( + &[ + &finding.recommended_issue_title, + &finding.intent_title, + &finding.title, + &finding.intent, + &finding.symptom_title, + ], + "Investigate unmet intent", + ) +} + +fn default_acceptance_criteria(finding: &AgentFinding) -> Vec { + if !finding.acceptance_criteria.is_empty() { + return finding.acceptance_criteria.clone(); + } + let issue_title = finding_issue_title(finding); + vec![ + format!( + "Agents can complete '{}' without the current failure mode.", + issue_title + ), + "Observe metrics show improved completion for the affected workflow.".to_string(), + ] +} + +fn build_issue_description(summary: &str, finding: &AgentFinding, record_ids: &[String]) -> String { + let acceptance_criteria = default_acceptance_criteria(finding) + .into_iter() + .map(|item| format!("- {item}")) + .collect::>() + .join("\n"); + format!( + "Summary:\n{summary}\n\nIntent Title:\n{}\n\nObserved Symptom:\n{}\n\nIntent:\n{}\n\nRecommendation:\n{}\n\nProblem Statement:\n{}\n\nRoot Cause:\n{}\n\nSpec Diff:\n{}\n\nAcceptance Criteria:\n{}\n\nEvidence:\n{}\n\nEvolution Records:\n{}", + finding_intent_title(finding), + finding_symptom_title(finding), + if finding.intent.is_empty() { + "No explicit intent supplied." + } else { + finding.intent.as_str() + }, + finding.recommendation, + if finding.problem_statement.is_empty() { + "No formal problem statement supplied." + } else { + finding.problem_statement.as_str() + }, + if finding.root_cause.is_empty() { + "No root cause supplied." + } else { + finding.root_cause.as_str() + }, + if finding.spec_diff.is_empty() { + "No spec diff supplied." + } else { + finding.spec_diff.as_str() + }, + acceptance_criteria, + serde_json::to_string_pretty(&finding.evidence).unwrap_or_else(|_| "{}".to_string()), + record_ids.join(", ") + ) +} + +async fn create_issue_for_finding( + state: &ServerState, + tenant: &TenantId, + summary: &str, + finding: &AgentFinding, + record_ids: &[String], +) -> Result { + let issue_id = temper_runtime::scheduler::sim_uuid().to_string(); + let now = sim_now().to_rfc3339(); + let issue_title = finding_issue_title(finding); + let description = build_issue_description(summary, finding, record_ids); + let acceptance_criteria = default_acceptance_criteria(finding).join("\n"); + + state + .get_or_create_tenant_entity( + tenant, + "Issue", + &issue_id, + serde_json::json!({ + "Id": issue_id.clone(), + "Title": issue_title, + "Description": description, + "AcceptanceCriteria": acceptance_criteria, + "Priority": issue_priority_level(finding.priority_score), + "CreatedAt": now, + "UpdatedAt": now, + }), + ) + .await?; + + let system_ctx = AgentContext::system(); + let _ = state + .dispatch_tenant_action( + tenant, + "Issue", + &issue_id, + "SetPriority", + serde_json::json!({ "level": issue_priority_level(finding.priority_score) }), + &system_ctx, + ) + .await; + let _ = state + .dispatch_tenant_action( + tenant, + "Issue", + &issue_id, + "MoveToTriage", + serde_json::json!({}), + &system_ctx, + ) + .await; + let _ = state + .dispatch_tenant_action( + tenant, + "Issue", + &issue_id, + "MoveToTodo", + serde_json::json!({}), + &system_ctx, + ) + .await; + + Ok(issue_id) +} + +async fn materialize_spec_change_records( + state: &ServerState, + tenant: &TenantId, + payload: &EvolutionMaterializeRequest, + signal_summary: &serde_json::Value, + finding: &AgentFinding, +) -> Result { + if !finding.requires_spec_change { + return Ok(SpecChangeArtifacts::default()); + } + + let observation = ObservationRecord { + header: RecordHeader::new(RecordType::Observation, "intent-discovery"), + source: format!( + "intent-discovery:{}", + if finding.kind.is_empty() { + "analysis" + } else { + finding.kind.as_str() + } + ), + classification: observation_class_for_finding(finding), + evidence_query: format!( + "intent discovery {} -> symptom={} intent={}", + payload.intent_discovery_id, + finding_symptom_title(finding), + finding_intent_title(finding) + ), + threshold_field: None, + threshold_value: None, + observed_value: Some(finding.volume as f64), + context: serde_json::json!({ + "tenant": tenant.as_str(), + "reason": payload.reason, + "source": payload.source, + "signal_summary": signal_summary.clone(), + "finding": finding, + }), + }; + persist_record(state, "Observation", &observation.header, &observation).await?; + + let observation_entity_id = next_system_entity_id("OBS"); + create_system_entity_logged( + state, + "Observation", + &observation_entity_id, + "CreateObservation", + serde_json::json!({ + "source": observation.source, + "classification": format!("{:?}", observation.classification), + "evidence_query": observation.evidence_query, + "context": serde_json::to_string(&observation.context).unwrap_or_default(), + "tenant": tenant.as_str(), + "legacy_record_id": observation.header.id, + }), + ) + .await; + + let problem = ProblemRecord { + header: RecordHeader::new(RecordType::Problem, "intent-discovery") + .derived_from(&observation.header.id), + problem_statement: if finding.problem_statement.is_empty() { + format!( + "{} blocks intended workflow completion.", + finding_intent_title(finding) + ) + } else { + finding.problem_statement.clone() + }, + invariants: default_acceptance_criteria(finding), + constraints: if finding.dedupe_key.is_empty() { + Vec::new() + } else { + vec![format!("dedupe_key={}", finding.dedupe_key)] + }, + impact: ImpactAssessment { + affected_users: Some(finding.volume), + severity: severity_from_score(finding.priority_score), + trend: trend_from_str(&finding.trend), + }, + }; + persist_record(state, "Problem", &problem.header, &problem).await?; + + let problem_entity_id = next_system_entity_id("PRB"); + dispatch_system_action_required( + state, + "Problem", + &problem_entity_id, + "CreateProblem", + serde_json::json!({ + "observation_id": observation_entity_id, + "problem_statement": problem.problem_statement, + "severity": problem.impact.severity.to_string(), + "invariants": serde_json::to_string(&problem.invariants).unwrap_or_default(), + }), + ) + .await?; + dispatch_system_action_required( + state, + "Problem", + &problem_entity_id, + "MarkReviewed", + serde_json::json!({}), + ) + .await?; + + let analysis = AnalysisRecord { + header: RecordHeader::new(RecordType::Analysis, "intent-discovery") + .derived_from(&problem.header.id), + root_cause: if finding.root_cause.is_empty() { + "IntentDiscovery inferred a missing platform capability.".to_string() + } else { + finding.root_cause.clone() + }, + options: vec![SolutionOption { + description: finding.recommendation.clone(), + spec_diff: if finding.spec_diff.is_empty() { + "No explicit spec diff supplied.".to_string() + } else { + finding.spec_diff.clone() + }, + tla_impact: "NONE".to_string(), + risk: solution_risk_from_score(finding.priority_score), + complexity: complexity_from_finding(finding), + }], + recommendation: Some(0), + }; + persist_record(state, "Analysis", &analysis.header, &analysis).await?; + + let analysis_entity_id = next_system_entity_id("ANL"); + dispatch_system_action_required( + state, + "Analysis", + &analysis_entity_id, + "CreateAnalysis", + serde_json::json!({ + "problem_id": problem_entity_id, + "root_cause": analysis.root_cause, + "options": serde_json::to_string(&analysis.options).unwrap_or_default(), + "recommendation": analysis.recommendation.unwrap_or_default().to_string(), + }), + ) + .await?; + + Ok(SpecChangeArtifacts { + record_ids: vec![ + observation.header.id.clone(), + problem.header.id.clone(), + analysis.header.id.clone(), + ], + observation_entity_id, + derived_from_record_id: Some(analysis.header.id.clone()), + }) +} + +async fn materialize_finding( + state: &ServerState, + tenant: &TenantId, + summary: &str, + payload: &EvolutionMaterializeRequest, + signal_summary: &serde_json::Value, + finding: &AgentFinding, +) -> Result { + let mut artifacts = + materialize_spec_change_records(state, tenant, payload, signal_summary, finding).await?; + + let mut insight_header = RecordHeader::new(RecordType::Insight, "intent-discovery"); + if let Some(parent) = artifacts.derived_from_record_id.as_ref() { + insight_header = insight_header.derived_from(parent.clone()); + } + let insight = InsightRecord { + header: insight_header, + category: insight_category_for_finding(finding), + signal: InsightSignal { + intent: if finding.intent.is_empty() { + finding_intent_title(finding) + } else { + finding.intent.clone() + }, + volume: finding.volume, + success_rate: finding.success_rate, + trend: trend_from_str(&finding.trend), + growth_rate: None, + }, + recommendation: finding.recommendation.clone(), + priority_score: finding.priority_score, + }; + persist_record(state, "Insight", &insight.header, &insight).await?; + artifacts.record_ids.push(insight.header.id.clone()); + + create_system_entity_logged( + state, + "Insight", + &next_system_entity_id("INS"), + "CreateInsight", + serde_json::json!({ + "observation_id": artifacts.observation_entity_id, + "category": format!("{:?}", insight.category), + "signal": insight.signal.intent, + "recommendation": insight.recommendation, + "priority_score": format!("{:.4}", insight.priority_score), + "legacy_record_id": insight.header.id, + }), + ) + .await; + + let issue_id = create_issue_for_finding(state, tenant, summary, finding, &artifacts.record_ids) + .await + .map_err(|error| { + tracing::warn!( + error = %error, + issue_title = %finding_issue_title(finding), + "evolution.issue.create" + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(MaterializedFinding { + report: serde_json::json!({ + "title": finding_issue_title(finding), + "intent_title": finding_intent_title(finding), + "symptom_title": finding_symptom_title(finding), + "kind": finding.kind.clone(), + "record_ids": artifacts.record_ids, + "issue_id": issue_id, + }), + record_ids: artifacts.record_ids, + issue_id, + }) +} + +/// POST /api/evolution/analyze -- create and run one IntentDiscovery cycle. +#[instrument(skip_all, fields(otel.name = "POST /api/evolution/analyze"))] +pub(crate) async fn handle_evolution_analyze( + State(state): State, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, StatusCode> { + require_observe_auth(&state, &headers, "run_sentinel", "Evolution")?; + let tenant = extract_tenant(&headers, &state).map_err(|_| StatusCode::BAD_REQUEST)?; + let payload = if body.is_empty() { + EvolutionAnalyzeRequest { + reason: None, + source: None, + trigger_context: None, + } + } else { + serde_json::from_slice::(&body) + .map_err(|_| StatusCode::BAD_REQUEST)? + }; + let agent_ctx = extract_agent_context(&headers); + let reason = payload.reason.unwrap_or_else(|| "manual".to_string()); + let source = payload.source.unwrap_or_else(|| "developer".to_string()); + let trigger_context = payload + .trigger_context + .unwrap_or_else(|| serde_json::json!({})); + + let (entity_id, response) = spawn_intent_discovery( + &state, + &tenant, + &reason, + &source, + trigger_context, + &agent_ctx, + true, + ) + .await + .map_err(|error| { + tracing::warn!(error = %error, tenant = %tenant, "failed to run IntentDiscovery"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(serde_json::json!({ + "tenant": tenant.as_str(), + "entity_id": entity_id, + "success": response.success, + "status": response.state.status, + "error": response.error, + "fields": response.state.fields, + }))) +} + +/// POST /api/evolution/materialize -- persist O/P/A/I records and PM issues. +#[instrument(skip_all, fields(otel.name = "POST /api/evolution/materialize"))] +pub(crate) async fn handle_evolution_materialize( + State(state): State, + headers: HeaderMap, + ExtractJson(payload): ExtractJson, +) -> Result, StatusCode> { + require_observe_auth(&state, &headers, "run_sentinel", "Evolution")?; + let tenant = extract_tenant(&headers, &state).map_err(|_| StatusCode::BAD_REQUEST)?; + let analysis = serde_json::from_str::(&payload.analysis_json) + .map_err(|_| StatusCode::BAD_REQUEST)?; + let signal_summary = serde_json::from_str::(&payload.signal_summary_json) + .unwrap_or_else(|_| serde_json::json!({})); + let summary = if analysis.summary.is_empty() { + "IntentDiscovery produced structured findings.".to_string() + } else { + analysis.summary.clone() + }; + + let mut record_ids = Vec::::new(); + let mut issue_ids = Vec::::new(); + let mut findings_report = Vec::::new(); + + for finding in &analysis.findings { + let materialized = materialize_finding( + &state, + &tenant, + &summary, + &payload, + &signal_summary, + finding, + ) + .await?; + record_ids.extend(materialized.record_ids); + issue_ids.push(materialized.issue_id); + findings_report.push(materialized.report); + } + + emit_refresh_hints( + &state, + &[ + ObserveRefreshHint::EvolutionRecords, + ObserveRefreshHint::EvolutionInsights, + ObserveRefreshHint::Entities, + ], + ); + + Ok(Json(serde_json::json!({ + "intent_discovery_id": payload.intent_discovery_id, + "tenant": payload.tenant.unwrap_or_else(|| tenant.as_str().to_string()), + "records_created_count": record_ids.len(), + "issues_created_count": issue_ids.len(), + "record_ids": record_ids, + "issue_ids": issue_ids, + "findings": findings_report, + }))) +} diff --git a/crates/temper-server/src/observe/evolution/operations/materialize_test.rs b/crates/temper-server/src/observe/evolution/operations/materialize_test.rs new file mode 100644 index 00000000..474d2287 --- /dev/null +++ b/crates/temper-server/src/observe/evolution/operations/materialize_test.rs @@ -0,0 +1,26 @@ +use super::*; + +#[test] +fn issue_title_prefers_intent_shaped_fields() { + let finding = AgentFinding { + title: "Invoice entity type not implemented".to_string(), + symptom_title: "GenerateInvoice hits EntitySetNotFound on Invoice".to_string(), + intent_title: "Enable invoice generation workflow".to_string(), + recommended_issue_title: "Enable invoice generation workflow".to_string(), + intent: "Generate invoices for customers".to_string(), + ..AgentFinding::default() + }; + + assert_eq!( + finding_issue_title(&finding), + "Enable invoice generation workflow" + ); + assert_eq!( + finding_symptom_title(&finding), + "GenerateInvoice hits EntitySetNotFound on Invoice" + ); + assert_eq!( + finding_intent_title(&finding), + "Enable invoice generation workflow" + ); +} diff --git a/crates/temper-server/src/observe/evolution/operations/support.rs b/crates/temper-server/src/observe/evolution/operations/support.rs new file mode 100644 index 00000000..3d3fd5a3 --- /dev/null +++ b/crates/temper-server/src/observe/evolution/operations/support.rs @@ -0,0 +1,283 @@ +use axum::http::StatusCode; +use temper_evolution::InsightRecord; +use temper_evolution::RecordHeader; +use temper_runtime::scheduler::sim_uuid; +use temper_runtime::tenant::TenantId; + +use crate::request_context::AgentContext; +use crate::sentinel; +use crate::state::{DispatchExtOptions, ObserveRefreshHint, ServerState}; + +pub(super) async fn persist_evolution_record( + state: &ServerState, + record_id: &str, + record_type: &str, + status: &str, + created_by: &str, + derived_from: Option<&str>, + data_json: &str, +) -> Result<(), String> { + let Some(turso) = state.platform_persistent_store() else { + tracing::debug!( + record_id, + record_type, + status, + created_by, + "evolution.store.unavailable" + ); + return Ok(()); + }; + + turso + .insert_evolution_record( + record_id, + record_type, + status, + created_by, + derived_from, + data_json, + ) + .await + .map_err(|error| { + tracing::warn!( + record_id, + record_type, + status, + created_by, + error = %error, + "evolution.store.write" + ); + error.to_string() + })?; + tracing::info!( + record_id, + record_type, + status, + created_by, + derived_from, + "evolution.store.write" + ); + Ok(()) +} + +pub(super) async fn persist_record( + state: &ServerState, + record_type: &str, + header: &RecordHeader, + record: &T, +) -> Result<(), StatusCode> { + let data_json = serde_json::to_string(record).map_err(|error| { + tracing::warn!( + record_id = %header.id, + record_type, + error = %error, + "evolution.store.serialize" + ); + StatusCode::INTERNAL_SERVER_ERROR + })?; + persist_evolution_record( + state, + &header.id, + record_type, + &format!("{:?}", header.status), + &header.created_by, + header.derived_from.as_deref(), + &data_json, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +pub(super) async fn dispatch_system_action( + state: &ServerState, + entity_type: &str, + entity_id: &str, + action: &str, + params: serde_json::Value, +) -> Result { + let system_tenant = TenantId::new("temper-system"); + state + .dispatch_tenant_action( + &system_tenant, + entity_type, + entity_id, + action, + params, + &AgentContext::system(), + ) + .await +} + +pub(super) async fn dispatch_system_action_required( + state: &ServerState, + entity_type: &str, + entity_id: &str, + action: &str, + params: serde_json::Value, +) -> Result { + dispatch_system_action(state, entity_type, entity_id, action, params) + .await + .map_err(|error| { + tracing::warn!( + error = %error, + entity_type, + entity_id, + action, + "evolution.system_entity.dispatch" + ); + StatusCode::INTERNAL_SERVER_ERROR + }) +} + +pub(super) async fn create_system_entity_logged( + state: &ServerState, + entity_type: &str, + entity_id: &str, + action: &str, + params: serde_json::Value, +) { + if let Err(error) = dispatch_system_action(state, entity_type, entity_id, action, params).await + { + tracing::warn!( + error = %error, + entity_type, + entity_id, + action, + "failed to create system entity" + ); + } +} + +pub(super) async fn persist_alerts( + state: &ServerState, + alerts: &[sentinel::SentinelAlert], +) -> Result, StatusCode> { + let mut results = Vec::new(); + for alert in alerts { + tracing::warn!( + rule = %alert.rule_name, + record_id = %alert.record.header.id, + source = %alert.record.source, + classification = ?alert.record.classification, + observed_value = ?alert.record.observed_value, + threshold = ?alert.record.threshold_value, + "evolution.sentinel" + ); + + persist_record(state, "Observation", &alert.record.header, &alert.record).await?; + + let observation_id = next_system_entity_id("OBS"); + create_system_entity_logged( + state, + "Observation", + &observation_id, + "CreateObservation", + serde_json::json!({ + "source": alert.record.source, + "classification": format!("{:?}", alert.record.classification), + "evidence_query": alert.record.evidence_query, + "context": serde_json::to_string(&alert.record.context).unwrap_or_default(), + "tenant": "temper-system", + "legacy_record_id": alert.record.header.id, + }), + ) + .await; + + results.push(serde_json::json!({ + "rule": alert.rule_name, + "record_id": alert.record.header.id, + "entity_id": observation_id, + "source": alert.record.source, + "classification": alert.record.classification, + "threshold": alert.record.threshold_value, + "observed": alert.record.observed_value, + })); + } + Ok(results) +} + +pub(super) async fn persist_insights( + state: &ServerState, + insights: &[InsightRecord], +) -> Vec { + let mut results = Vec::new(); + for insight in insights { + tracing::info!( + record_id = %insight.header.id, + category = ?insight.category, + intent = %insight.signal.intent, + volume = insight.signal.volume, + success_rate = insight.signal.success_rate, + priority_score = insight.priority_score, + "evolution.insight" + ); + let _ = persist_record(state, "Insight", &insight.header, insight).await; + + let insight_id = next_system_entity_id("INS"); + create_system_entity_logged( + state, + "Insight", + &insight_id, + "CreateInsight", + serde_json::json!({ + "observation_id": "", + "category": format!("{:?}", insight.category), + "signal": insight.signal.intent, + "recommendation": insight.recommendation, + "priority_score": format!("{:.4}", insight.priority_score), + "legacy_record_id": insight.header.id, + }), + ) + .await; + + results.push(serde_json::json!({ + "record_id": insight.header.id, + "entity_id": insight_id, + "category": format!("{:?}", insight.category), + "intent": insight.signal.intent, + "priority_score": insight.priority_score, + "recommendation": insight.recommendation, + })); + } + results +} + +pub(super) async fn spawn_intent_discovery( + state: &ServerState, + tenant: &TenantId, + reason: &str, + source: &str, + trigger_context: serde_json::Value, + agent_ctx: &AgentContext, + await_integration: bool, +) -> Result<(String, crate::entity_actor::EntityResponse), String> { + let discovery_id = format!("intent-discovery-{}", sim_uuid()); + let response = state + .dispatch_tenant_action_ext( + tenant, + "IntentDiscovery", + &discovery_id, + "Trigger", + serde_json::json!({ + "reason": reason, + "source": source, + "trigger_context_json": trigger_context.to_string(), + }), + DispatchExtOptions { + agent_ctx, + await_integration, + }, + ) + .await?; + Ok((discovery_id, response)) +} + +pub(super) fn next_system_entity_id(prefix: &str) -> String { + format!("{prefix}-{}", sim_uuid()) +} + +pub(super) fn emit_refresh_hints(state: &ServerState, hints: &[ObserveRefreshHint]) { + for hint in hints { + let _ = state.observe_refresh_tx.send(hint.clone()); + } +} diff --git a/crates/temper-server/src/odata/read.rs b/crates/temper-server/src/odata/read.rs index 688a5b96..75c432d4 100644 --- a/crates/temper-server/src/odata/read.rs +++ b/crates/temper-server/src/odata/read.rs @@ -4,9 +4,10 @@ use std::sync::{Arc, RwLock}; use axum::extract::{Query, State}; use axum::http::{HeaderMap, StatusCode}; -use axum::response::IntoResponse; +use axum::response::{IntoResponse, Response}; use temper_odata::path::{ODataPath, parse_path}; use temper_odata::query::parse_query_options; +use temper_odata::query::types::{ExpandItem, ExpandOptions, QueryOptions}; use temper_runtime::tenant::TenantId; use temper_wasm::{StreamRegistry, WasmInvocationContext}; use tracing::instrument; @@ -61,7 +62,7 @@ async fn resolve_parent_entity( })?; let mut parent_body = serde_json::to_value(&response.state).unwrap_or_default(); - let expand_item = temper_odata::query::types::ExpandItem { + let expand_item = ExpandItem { property: property.clone(), options: None, }; @@ -82,19 +83,8 @@ async fn resolve_parent_entity( })?; // For single-valued nav, extract the target entity type and id - let target_type = { - let registry = state.registry.read().unwrap(); // ci-ok: infallible lock - let tc = registry.get_tenant(tenant); - tc.and_then(|tc| { - crate::query_eval::find_nav_target(&tc.csdl, &parent_type, property) - }) - .ok_or_else(|| { - ( - StatusCode::NOT_FOUND, - format!("Nav target for '{property}' not found"), - ) - })? - }; + let target_type = + resolve_navigation_target_type(state, tenant, &parent_type, property)?; let entity_id = nav_value .get("entity_id") @@ -125,19 +115,8 @@ async fn resolve_parent_entity( let (parent_type, _parent_key, _parent_set) = Box::pin(resolve_parent_entity(parent, state, tenant)).await?; - let target_type = { - let registry = state.registry.read().unwrap(); // ci-ok: infallible lock - let tc = registry.get_tenant(tenant); - tc.and_then(|tc| { - crate::query_eval::find_nav_target(&tc.csdl, &parent_type, property) - }) - .ok_or_else(|| { - ( - StatusCode::NOT_FOUND, - format!("Nav target for '{property}' not found"), - ) - })? - }; + let target_type = + resolve_navigation_target_type(state, tenant, &parent_type, property)?; let key_str = extract_key(key); let set_name = resolve_entity_set_name(state, tenant, &target_type); @@ -150,6 +129,140 @@ async fn resolve_parent_entity( } } +fn resolve_navigation_target_type( + state: &ServerState, + tenant: &TenantId, + parent_type: &str, + property: &str, +) -> Result { + let registry = state.registry.read().unwrap(); // ci-ok: infallible lock + let tenant_config = registry.get_tenant(tenant); + tenant_config + .and_then(|tc| crate::query_eval::find_nav_target(&tc.csdl, parent_type, property)) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + format!("Nav target for '{property}' not found"), + ) + }) +} + +fn service_document_body(state: &ServerState, tenant: &TenantId) -> serde_json::Value { + let entity_sets: Vec = tenant_entity_sets(state, tenant) + .iter() + .map(|name| serde_json::json!({"name": name, "kind": "EntitySet", "url": name})) + .collect(); + serde_json::json!({"@odata.context": "$metadata", "value": entity_sets}) +} + +async fn entity_set_not_found_response( + state: &ServerState, + tenant: &TenantId, + set_name: &str, +) -> Response { + record_entity_set_not_found(state, tenant.as_str(), set_name).await; + odata_error( + StatusCode::NOT_FOUND, + "EntitySetNotFound", + &format!("Entity set '{set_name}' not found"), + ) + .into_response() +} + +fn resource_not_found_response(set_name: &str, key: &str) -> Response { + odata_error( + StatusCode::NOT_FOUND, + "ResourceNotFound", + &format!("Entity '{set_name}' with key '{key}' not found"), + ) + .into_response() +} + +async fn load_existing_entity_response( + state: &ServerState, + tenant: &TenantId, + entity_type: &str, + set_name: &str, + key: &str, +) -> Result { + if !state.entity_exists(tenant, entity_type, key) { + return Err(resource_not_found_response(set_name, key)); + } + + state + .get_tenant_entity_state(tenant, entity_type, key) + .await + .map_err(|_| resource_not_found_response(set_name, key)) +} + +async fn apply_entity_query_options( + mut body: serde_json::Value, + entity_type: &str, + state: &ServerState, + tenant: &TenantId, + query_options: &QueryOptions, + select_before_expand: bool, +) -> serde_json::Value { + if select_before_expand && let Some(ref select) = query_options.select { + body = select_fields(vec![body], select).pop().unwrap_or_default(); + } + + if let Some(ref expand_items) = query_options.expand { + expand_entity(&mut body, expand_items, entity_type, state, tenant).await; + } + + if !select_before_expand && let Some(ref select) = query_options.select { + body = select_fields(vec![body], select).pop().unwrap_or_default(); + } + + body +} + +struct EntityBodyOptions<'a> { + context: String, + odata_id: Option, + query_options: &'a QueryOptions, + enrich: bool, + function: Option<&'a str>, + select_before_expand: bool, +} + +async fn build_entity_body( + state: &ServerState, + tenant: &TenantId, + entity_type: &str, + set_name: &str, + key: &str, + options: EntityBodyOptions<'_>, +) -> Result { + let response = load_existing_entity_response(state, tenant, entity_type, set_name, key).await?; + let mut body = annotate_entity( + serde_json::to_value(&response.state).unwrap_or_default(), + options.context, + options.odata_id, + ); + + if options.enrich { + enrich_entity_response(&mut body, entity_type, set_name, key, state, tenant); + } + + if let Some(name) = options.function + && let Some(obj) = body.as_object_mut() + { + obj.insert("@odata.function".to_string(), serde_json::json!(name)); + } + + Ok(apply_entity_query_options( + body, + entity_type, + state, + tenant, + options.query_options, + options.select_before_expand, + ) + .await) +} + /// Enrich an entity response with `@odata.actions` and `@odata.children`. /// /// - `@odata.actions`: Actions available from the entity's current state, @@ -271,17 +384,11 @@ pub(super) async fn handle_odata_get_for_tenant( } .into_response(), - ODataPath::ServiceDocument => { - let entity_sets: Vec = tenant_entity_sets(&state, &tenant) - .iter() - .map(|name| serde_json::json!({"name": name, "kind": "EntitySet", "url": name})) - .collect(); - ODataResponse { - status: StatusCode::OK, - body: serde_json::json!({"@odata.context": "$metadata", "value": entity_sets}), - } - .into_response() + ODataPath::ServiceDocument => ODataResponse { + status: StatusCode::OK, + body: service_document_body(&state, &tenant), } + .into_response(), ODataPath::EntitySet(name) => { handle_entity_set(&state, &tenant, &name, &query_options).await @@ -322,19 +429,11 @@ async fn handle_entity_set( state: &ServerState, tenant: &TenantId, name: &str, - query_options: &temper_odata::query::types::QueryOptions, + query_options: &QueryOptions, ) -> axum::response::Response { let entity_type = match resolve_entity_type(state, tenant, name) { Some(t) => t, - None => { - record_entity_set_not_found(state, tenant.as_str(), name).await; - return odata_error( - StatusCode::NOT_FOUND, - "EntitySetNotFound", - &format!("Entity set '{name}' not found"), - ) - .into_response(); - } + None => return entity_set_not_found_response(state, tenant, name).await, }; let default_page_size = odata_default_page_size(); @@ -395,64 +494,37 @@ async fn handle_entity( tenant: &TenantId, set_name: &str, key: &temper_odata::path::KeyValue, - query_options: &temper_odata::query::types::QueryOptions, + query_options: &QueryOptions, ) -> axum::response::Response { let entity_type = match resolve_entity_type(state, tenant, set_name) { Some(t) => t, - None => { - record_entity_set_not_found(state, tenant.as_str(), set_name).await; - return odata_error( - StatusCode::NOT_FOUND, - "EntitySetNotFound", - &format!("Entity set '{set_name}' not found"), - ) - .into_response(); - } + None => return entity_set_not_found_response(state, tenant, set_name).await, }; let key_str = extract_key(key); - if !state.entity_exists(tenant, &entity_type, &key_str) { - return odata_error( - StatusCode::NOT_FOUND, - "ResourceNotFound", - &format!("Entity '{set_name}' with key '{key_str}' not found"), - ) - .into_response(); - } - - match state - .get_tenant_entity_state(tenant, &entity_type, &key_str) - .await + match build_entity_body( + state, + tenant, + &entity_type, + set_name, + &key_str, + EntityBodyOptions { + context: format!("$metadata#{set_name}/$entity"), + odata_id: Some(format!("{set_name}('{key_str}')")), + query_options, + enrich: true, + function: None, + select_before_expand: false, + }, + ) + .await { - Ok(response) => { - let mut body = annotate_entity( - serde_json::to_value(&response.state).unwrap_or_default(), - format!("$metadata#{set_name}/$entity"), - Some(format!("{set_name}('{key_str}')")), - ); - - enrich_entity_response(&mut body, &entity_type, set_name, &key_str, state, tenant); - - if let Some(ref expand_items) = query_options.expand { - expand_entity(&mut body, expand_items, &entity_type, state, tenant).await; - } - - if let Some(ref select) = query_options.select { - body = select_fields(vec![body], select).pop().unwrap_or_default(); - } - - ODataResponse { - status: StatusCode::OK, - body, - } - .into_response() + Ok(body) => ODataResponse { + status: StatusCode::OK, + body, } - Err(_) => odata_error( - StatusCode::NOT_FOUND, - "ResourceNotFound", - &format!("Entity '{set_name}' with key '{key_str}' not found"), - ) .into_response(), + Err(resp) => resp, } } @@ -462,7 +534,7 @@ async fn handle_navigation_property( tenant: &TenantId, parent: &ODataPath, property: &str, - query_options: &temper_odata::query::types::QueryOptions, + query_options: &QueryOptions, ) -> axum::response::Response { let (parent_type, parent_key, parent_set) = match resolve_parent_entity(parent, state, tenant).await { @@ -472,45 +544,16 @@ async fn handle_navigation_property( } }; - let parent_entity_type = match resolve_entity_type(state, tenant, &parent_set) { - Some(t) => t, - None => { - record_entity_set_not_found(state, tenant.as_str(), &parent_set).await; - return odata_error( - StatusCode::NOT_FOUND, - "EntitySetNotFound", - &format!("Entity set '{parent_set}' not found"), - ) - .into_response(); - } - }; - - if !state.entity_exists(tenant, &parent_entity_type, &parent_key) { - return odata_error( - StatusCode::NOT_FOUND, - "ResourceNotFound", - &format!("Entity '{parent_set}' with key '{parent_key}' not found"), - ) - .into_response(); - } - - let response = match state - .get_tenant_entity_state(tenant, &parent_type, &parent_key) - .await - { - Ok(r) => r, - Err(_) => { - return odata_error( - StatusCode::NOT_FOUND, - "ResourceNotFound", - &format!("Entity '{parent_set}' with key '{parent_key}' not found"), - ) - .into_response(); - } - }; + let response = + match load_existing_entity_response(state, tenant, &parent_type, &parent_set, &parent_key) + .await + { + Ok(r) => r, + Err(resp) => return resp, + }; let mut parent_body = serde_json::to_value(&response.state).unwrap_or_default(); - let nav_opts = temper_odata::query::types::ExpandOptions { + let nav_opts = ExpandOptions { select: query_options.select.clone(), filter: query_options.filter.clone(), orderby: query_options.orderby.clone(), @@ -518,7 +561,7 @@ async fn handle_navigation_property( skip: query_options.skip, expand: query_options.expand.clone(), }; - let expand_item = temper_odata::query::types::ExpandItem { + let expand_item = ExpandItem { property: property.to_string(), options: if has_expand_options(&nav_opts) { Some(nav_opts) @@ -586,7 +629,7 @@ async fn handle_navigation_entity( parent: &ODataPath, property: &str, key: &temper_odata::path::KeyValue, - query_options: &temper_odata::query::types::QueryOptions, + query_options: &QueryOptions, ) -> axum::response::Response { let (parent_type, _parent_key, _parent_set) = match resolve_parent_entity(parent, state, tenant).await { @@ -596,13 +639,8 @@ async fn handle_navigation_entity( } }; - let target_type = { - let registry = state.registry.read().unwrap(); // ci-ok: infallible lock - let tc = registry.get_tenant(tenant); - tc.and_then(|tc| crate::query_eval::find_nav_target(&tc.csdl, &parent_type, property)) - }; - - let Some(target_type) = target_type else { + let Ok(target_type) = resolve_navigation_target_type(state, tenant, &parent_type, property) + else { return odata_error( StatusCode::NOT_FOUND, "NavigationPropertyNotFound", @@ -614,55 +652,29 @@ async fn handle_navigation_entity( let key_str = extract_key(key); let target_set = resolve_entity_set_name(state, tenant, &target_type); - if !state.entity_exists(tenant, &target_type, &key_str) { - return odata_error( - StatusCode::NOT_FOUND, - "ResourceNotFound", - &format!("Entity '{target_set}' with key '{key_str}' not found"), - ) - .into_response(); - } - - match state - .get_tenant_entity_state(tenant, &target_type, &key_str) - .await + match build_entity_body( + state, + tenant, + &target_type, + &target_set, + &key_str, + EntityBodyOptions { + context: format!("$metadata#{target_set}/$entity"), + odata_id: Some(format!("{target_set}('{key_str}')")), + query_options, + enrich: true, + function: None, + select_before_expand: false, + }, + ) + .await { - Ok(response) => { - let mut body = annotate_entity( - serde_json::to_value(&response.state).unwrap_or_default(), - format!("$metadata#{target_set}/$entity"), - Some(format!("{target_set}('{key_str}')")), - ); - - enrich_entity_response( - &mut body, - &target_type, - &target_set, - &key_str, - state, - tenant, - ); - - if let Some(ref expand_items) = query_options.expand { - expand_entity(&mut body, expand_items, &target_type, state, tenant).await; - } - - if let Some(ref select) = query_options.select { - body = select_fields(vec![body], select).pop().unwrap_or_default(); - } - - ODataResponse { - status: StatusCode::OK, - body, - } - .into_response() + Ok(body) => ODataResponse { + status: StatusCode::OK, + body, } - Err(_) => odata_error( - StatusCode::NOT_FOUND, - "ResourceNotFound", - &format!("Entity '{target_set}' with key '{key_str}' not found"), - ) .into_response(), + Err(resp) => resp, } } @@ -672,7 +684,7 @@ async fn handle_bound_function( tenant: &TenantId, parent: &ODataPath, function: &str, - query_options: &temper_odata::query::types::QueryOptions, + query_options: &QueryOptions, ) -> axum::response::Response { let (parent_set, parent_key) = match parent { ODataPath::Entity(set_name, key) => (set_name.clone(), extract_key(key)), @@ -698,58 +710,29 @@ async fn handle_bound_function( } }; - if !state.entity_exists(tenant, &entity_type, &parent_key) { - return odata_error( - StatusCode::NOT_FOUND, - "ResourceNotFound", - &format!("Entity '{parent_set}' with key '{parent_key}' not found"), - ) - .into_response(); - } - - match state - .get_tenant_entity_state(tenant, &entity_type, &parent_key) - .await + match build_entity_body( + state, + tenant, + &entity_type, + &parent_set, + &parent_key, + EntityBodyOptions { + context: format!("$metadata#{entity_type}"), + odata_id: None, + query_options, + enrich: false, + function: Some(function), + select_before_expand: true, + }, + ) + .await { - Ok(response) => { - let mut body = annotate_entity( - serde_json::to_value(&response.state).unwrap_or_default(), - format!("$metadata#{entity_type}"), - None, - ); - if let Some(obj) = body.as_object_mut() { - obj.insert("@odata.function".to_string(), serde_json::json!(function)); - } - - if let Some(ref select) = query_options.select { - let selected = crate::query_eval::select_fields(vec![body.clone()], select); - if let Some(first) = selected.into_iter().next() { - body = first; - } - } - if let Some(ref expand_items) = query_options.expand { - crate::query_eval::expand_entity( - &mut body, - expand_items, - &entity_type, - state, - tenant, - ) - .await; - } - - ODataResponse { - status: StatusCode::OK, - body, - } - .into_response() + Ok(body) => ODataResponse { + status: StatusCode::OK, + body, } - Err(_) => odata_error( - StatusCode::NOT_FOUND, - "ResourceNotFound", - &format!("Entity '{parent_set}' with key '{parent_key}' not found"), - ) .into_response(), + Err(resp) => resp, } } @@ -777,13 +760,9 @@ pub async fn handle_service_document( Ok(t) => t, Err(e) => return e.into_response(), }; - let entity_sets: Vec = tenant_entity_sets(&state, &tenant) - .iter() - .map(|name| serde_json::json!({"name": name, "kind": "EntitySet", "url": name})) - .collect(); ODataResponse { status: StatusCode::OK, - body: serde_json::json!({"@odata.context": "$metadata", "value": entity_sets}), + body: service_document_body(&state, &tenant), } .into_response() } @@ -835,14 +814,7 @@ async fn handle_stream_get( let entity_type = match resolve_entity_type(state, tenant, &set_name) { Some(t) => t, - None => { - return odata_error( - StatusCode::NOT_FOUND, - "EntitySetNotFound", - &format!("Entity set '{set_name}' not found"), - ) - .into_response(); - } + None => return entity_set_not_found_response(state, tenant, &set_name).await, }; // 2. Check HasStream=true @@ -851,20 +823,11 @@ async fn handle_stream_get( } // 3. Get entity state - let entity_state = match state - .get_tenant_entity_state(tenant, &entity_type, &key) - .await - { - Ok(resp) => serde_json::to_value(&resp.state).unwrap_or_default(), - Err(_) => { - return odata_error( - StatusCode::NOT_FOUND, - "ResourceNotFound", - &format!("{set_name}('{key}') not found"), - ) - .into_response(); - } - }; + let entity_state = + match load_existing_entity_response(state, tenant, &entity_type, &set_name, &key).await { + Ok(resp) => serde_json::to_value(&resp.state).unwrap_or_default(), + Err(resp) => return resp, + }; // 4. Check if entity has content (boolean may be in top-level `booleans` map or `fields`) let has_content = entity_state diff --git a/crates/temper-server/src/state/dispatch/wasm.rs b/crates/temper-server/src/state/dispatch/wasm.rs index fbc51e69..532e1ea9 100644 --- a/crates/temper-server/src/state/dispatch/wasm.rs +++ b/crates/temper-server/src/state/dispatch/wasm.rs @@ -6,11 +6,6 @@ use tracing::instrument; use crate::entity_actor::{EntityResponse, EntityState}; use crate::request_context::AgentContext; use crate::secrets::template::resolve_secret_templates; -use crate::state::pending_decisions::PendingDecision; -use crate::state::trajectory::{TrajectoryEntry, TrajectorySource}; -use crate::state::wasm_invocation_log::WasmInvocationEntry; -use temper_observe::wide_event; -use temper_runtime::scheduler::{sim_now, sim_uuid}; use temper_runtime::tenant::TenantId; use temper_wasm::{ AuthorizedWasmHost, ProductionWasmHost, StreamRegistry, WasmAuthzContext, WasmAuthzGate, @@ -21,6 +16,10 @@ use super::{ HttpCallAuthzDenialTracker, TrackingWasmAuthzGate, WasmDispatchMode, WasmDispatchRequest, WasmEntityRef, }; +use replay_inputs::{extract_trajectory_actions_from_ots, has_replay_trajectory_input}; + +mod invocation_artifacts; +mod replay_inputs; /// Shared context threaded through the WASM dispatch call chain. /// @@ -33,6 +32,16 @@ struct WasmDispatchCtx<'a> { mode: WasmDispatchMode, } +const HTTP_CALL_AUTHZ_DENIED_PREFIX: &str = "authorization denied for http_call"; + +fn http_call_authz_denied_error(reason: &str) -> String { + format!("{HTTP_CALL_AUTHZ_DENIED_PREFIX}: {reason}") +} + +fn is_http_call_authz_denial(error: &str) -> bool { + error.contains(HTTP_CALL_AUTHZ_DENIED_PREFIX) +} + impl crate::state::ServerState { #[instrument(skip_all, fields(otel.name = "dispatch.dispatch_wasm_integrations_internal", tenant = %req.tenant, entity_type = req.entity_type, entity_id = req.entity_id, action_name = req.action))] pub(crate) async fn dispatch_wasm_integrations_internal( @@ -389,7 +398,7 @@ impl crate::state::ServerState { { Ok(result) if result.success => { if let Some(reason) = denial_tracker.take_denial() { - let error_str = format!("authorization denied for http_call: {reason}"); + let error_str = http_call_authz_denied_error(&reason); return self .handle_wasm_failure( ctx, @@ -444,7 +453,7 @@ impl crate::state::ServerState { ) }); if let Some(reason) = denial_tracker.take_denial() { - error_str = format!("authorization denied for http_call: {reason}"); + error_str = http_call_authz_denied_error(&reason); } self.handle_wasm_failure( ctx, @@ -459,9 +468,9 @@ impl crate::state::ServerState { Err(e) => { let mut error_str = e.to_string(); if let Some(reason) = denial_tracker.take_denial() - && !error_str.contains("authorization denied for http_call") + && !is_http_call_authz_denial(&error_str) { - error_str = format!("authorization denied for http_call: {reason}"); + error_str = http_call_authz_denied_error(&reason); } self.handle_wasm_failure( ctx, @@ -476,255 +485,6 @@ impl crate::state::ServerState { } } - /// Record a WASM invocation (persist log entry + emit observability events). - #[allow(clippy::too_many_arguments)] - async fn record_invocation( - &self, - entity_ref: WasmEntityRef<'_>, - module_name: &str, - trigger_action: &str, - callback_action: Option, - success: bool, - error: Option, - duration_ms: u64, - authz_denied: Option, - ) { - let log_entry = WasmInvocationEntry { - timestamp: sim_now().to_rfc3339(), - tenant: entity_ref.tenant.to_string(), - entity_type: entity_ref.entity_type.to_string(), - entity_id: entity_ref.entity_id.to_string(), - module_name: module_name.to_string(), - trigger_action: trigger_action.to_string(), - callback_action, - success, - error: error.clone(), - duration_ms, - authz_denied, - }; - let _ = self.persist_wasm_invocation(&log_entry).await; - - let wide = wide_event::from_wasm_invocation(wide_event::WasmInvocationInput { - module_name, - trigger_action, - entity_type: entity_ref.entity_type, - entity_id: entity_ref.entity_id, - tenant: &entity_ref.tenant.to_string(), - success, - duration_ns: duration_ms * 1_000_000, - error: error.as_deref(), - }); - wide_event::emit_span(&wide); - wide_event::emit_metrics(&wide); - } - - #[instrument(skip_all, fields(otel.name = "dispatch.handle_wasm_failure", trigger_action, integration_name, module_name))] - async fn handle_wasm_failure( - &self, - ctx: &WasmDispatchCtx<'_>, - integration_name: &str, - module_name: &str, - on_failure: &Option, - error_str: String, - duration_ms: u64, - ) -> Result, String> { - let is_authz_denied = error_str.contains("authorization denied for http_call"); - self.record_invocation( - ctx.entity_ref, - module_name, - ctx.action, - on_failure.clone(), - false, - Some(error_str.clone()), - duration_ms, - if is_authz_denied { Some(true) } else { None }, - ) - .await; - - let decision_id = if is_authz_denied { - self.record_wasm_authz_denial( - ctx.entity_ref, - ctx.action, - integration_name, - module_name, - &error_str, - ) - } else { - None - }; - - if let Some(cb) = on_failure { - let mut params = serde_json::json!({ - "error": error_str.clone(), - "error_message": error_str, - "integration": integration_name, - }); - if let Some(ref did) = decision_id { - params["decision_id"] = serde_json::json!(did); - params["authz_denied"] = serde_json::json!(true); - } - return self - .dispatch_wasm_callback(ctx.entity_ref, cb, params, ctx.agent_ctx, ctx.mode) - .await; - } - - Ok(None) - } - - #[instrument(skip_all, fields(otel.name = "dispatch.dispatch_wasm_callback", callback_action))] - async fn dispatch_wasm_callback( - &self, - entity_ref: WasmEntityRef<'_>, - callback_action: &str, - callback_params: serde_json::Value, - agent_ctx: &AgentContext, - mode: WasmDispatchMode, - ) -> Result, String> { - match mode { - WasmDispatchMode::Inline => { - let resp = self - .dispatch_tenant_action_core( - entity_ref.tenant, - entity_ref.entity_type, - entity_ref.entity_id, - callback_action, - callback_params, - agent_ctx, - false, - ) - .await - .map_err(|e| e.to_string())?; - Ok(Some(resp)) - } - WasmDispatchMode::Background => { - self.dispatch_tenant_action( - entity_ref.tenant, - entity_ref.entity_type, - entity_ref.entity_id, - callback_action, - callback_params, - &AgentContext::system(), - ) - .await - .map_err(|e| { - let msg = format!("failed to dispatch WASM callback '{callback_action}': {e}"); - tracing::error!(callback = %callback_action, error = %e, "{msg}"); - msg - })?; - Ok(None) - } - } - } - - /// Record a WASM authorization denial: persist decision, create governance - /// entity, and emit trajectory entry. - fn record_wasm_authz_denial( - &self, - entity_ref: WasmEntityRef<'_>, - trigger_action: &str, - integration_name: &str, - module_name: &str, - error_str: &str, - ) -> Option { - let pd = PendingDecision::from_denial( - entity_ref.tenant.as_str(), - "wasm-module", - "http_call", - "HttpEndpoint", - integration_name, - serde_json::json!({ - "entity_type": entity_ref.entity_type, - "entity_id": entity_ref.entity_id, - "module": module_name, - "trigger_action": trigger_action, - }), - error_str, - Some(module_name.to_string()), - ); - let decision_id = pd.id.clone(); - let _ = self.pending_decision_tx.send(pd.clone()); - let state_c = self.clone(); - #[rustfmt::skip] - tokio::spawn(async move { // determinism-ok: background persist - if let Err(e) = state_c.persist_pending_decision(&pd).await { - tracing::error!(error = %e, "failed to persist WASM authz decision"); - } - }); - // Create GovernanceDecision entity in temper-system tenant. - let state_c = self.clone(); - let gd_id = format!("GD-{}", sim_uuid()); - let gd_params = serde_json::json!({ - "tenant": entity_ref.tenant.as_str(), "agent_id": "wasm-module", - "action_name": "http_call", "resource_type": "HttpEndpoint", - "resource_id": integration_name, "denial_reason": error_str, - "scope": "narrow", "pending_decision_id": decision_id, - }); - #[rustfmt::skip] - tokio::spawn(async move { // determinism-ok: background entity creation - let tenant = TenantId::new("temper-system"); - if let Err(e) = state_c.dispatch_tenant_action( - &tenant, "GovernanceDecision", &gd_id, - "CreateGovernanceDecision", gd_params, &AgentContext::system(), - ).await { - tracing::warn!(error = %e, "failed to create GovernanceDecision for WASM denial"); - } - }); - let traj = TrajectoryEntry { - timestamp: sim_now().to_rfc3339(), - tenant: entity_ref.tenant.to_string(), - entity_type: entity_ref.entity_type.to_string(), - entity_id: entity_ref.entity_id.to_string(), - action: trigger_action.to_string(), - success: false, - from_status: None, - to_status: None, - error: Some(error_str.to_string()), - agent_id: None, - session_id: None, - authz_denied: Some(true), - denied_resource: Some(integration_name.to_string()), - denied_module: Some(module_name.to_string()), - source: Some(TrajectorySource::Authz), - spec_governed: None, - agent_type: None, - request_body: None, - intent: None, - }; - tracing::info!( - tenant = %traj.tenant, - entity_type = %traj.entity_type, - entity_id = %traj.entity_id, - action = %traj.action, - success = traj.success, - from_status = ?traj.from_status, - to_status = ?traj.to_status, - error = ?traj.error, - source = ?traj.source, - authz_denied = ?traj.authz_denied, - "trajectory.entry" - ); - if !traj.success { - tracing::warn!( - tenant = %traj.tenant, - entity_type = %traj.entity_type, - entity_id = %traj.entity_id, - action = %traj.action, - error = ?traj.error, - authz_denied = ?traj.authz_denied, - source = ?traj.source, - "unmet_intent" - ); - } - let state_c = self.clone(); - #[rustfmt::skip] - tokio::spawn(async move { // determinism-ok: background persist - if let Err(e) = state_c.persist_trajectory_entry(&traj).await { - tracing::error!(error = %e, "failed to persist WASM authz trajectory"); - } - }); - Some(decision_id) - } - /// Invoke a WASM module directly (not triggered by an entity action). /// /// Used by `$value` handlers for blob operations. The WASM module controls @@ -818,469 +578,3 @@ fn spec_evaluator_fn() -> temper_wasm::SpecEvaluatorFn { }, ) } - -fn has_replay_trajectory_input(params: &Value) -> bool { - has_non_empty_param(params, "Trajectories") || has_non_empty_param(params, "TrajectoryActions") -} - -fn has_non_empty_param(params: &Value, key: &str) -> bool { - match params.get(key) { - Some(Value::Array(arr)) => !arr.is_empty(), - Some(Value::String(s)) => !s.trim().is_empty(), - Some(Value::Object(obj)) => !obj.is_empty(), - Some(_) => true, - None => false, - } -} - -fn extract_trajectory_actions_from_ots(trajectory: &Value) -> Vec { - let mut actions = Vec::new(); - - let Some(turns) = trajectory.get("turns").and_then(Value::as_array) else { - return actions; - }; - - for turn in turns { - if let Some(decisions) = turn.get("decisions").and_then(Value::as_array) { - for decision in decisions { - if let Some(raw_actions) = decision - .get("choice") - .and_then(|choice| choice.get("arguments")) - .and_then(|args| args.get("trajectory_actions")) - .and_then(Value::as_array) - { - for raw in raw_actions { - if let Some(normalized) = normalize_trajectory_action(raw) { - actions.push(normalized); - } - } - } - - if let Some(choice_action) = decision - .get("choice") - .and_then(|choice| choice.get("action")) - .and_then(Value::as_str) - && let Some(code) = choice_action.strip_prefix("execute:") - { - actions.extend(extract_temper_actions_from_code(code)); - } - } - } - - if let Some(messages) = turn.get("messages").and_then(Value::as_array) { - for message in messages { - let role = message - .get("role") - .and_then(Value::as_str) - .unwrap_or_default(); - if role != "user" { - continue; - } - let text = message - .get("content") - .and_then(|content| content.get("text")) - .and_then(Value::as_str); - if let Some(code) = text { - actions.extend(extract_temper_actions_from_code(code)); - } - } - } - } - - dedupe_actions(actions) -} - -fn normalize_trajectory_action(raw: &Value) -> Option { - match raw { - Value::String(action_name) => Some(serde_json::json!({ - "action": action_name, - "params": {}, - })), - Value::Object(obj) => { - let action = obj - .get("action") - .or_else(|| obj.get("Action")) - .and_then(Value::as_str)?; - - let params = obj - .get("params") - .or_else(|| obj.get("Params")) - .and_then(parse_params_value) - .unwrap_or_else(|| serde_json::json!({})); - - Some(serde_json::json!({ - "action": action, - "params": params, - })) - } - _ => None, - } -} - -fn parse_params_value(value: &Value) -> Option { - match value { - Value::Object(_) => Some(value.clone()), - Value::Null => Some(serde_json::json!({})), - Value::String(s) => { - if let Ok(parsed) = serde_json::from_str::(s) { - return Some(parsed); - } - Some(serde_json::json!({})) - } - _ => Some(serde_json::json!({})), - } -} - -fn dedupe_actions(actions: Vec) -> Vec { - let mut deduped = Vec::new(); - let mut seen = std::collections::BTreeSet::new(); - for action in actions { - let key = action.to_string(); - if seen.insert(key) { - deduped.push(action); - } - } - deduped -} - -fn extract_temper_actions_from_code(code: &str) -> Vec { - let mut actions = Vec::new(); - let mut cursor = 0usize; - let needle = "temper.action"; - - while let Some(found) = code[cursor..].find(needle) { - let method_start = cursor + found + needle.len(); - let mut open = method_start; - while open < code.len() - && code - .as_bytes() - .get(open) - .is_some_and(|b| b.is_ascii_whitespace()) - { - open += 1; - } - if code.as_bytes().get(open) != Some(&b'(') { - cursor = method_start; - continue; - } - let Some(close) = find_matching_paren(code, open) else { - break; - }; - - let args = split_top_level_args(&code[open + 1..close]); - let (action_idx, params_idx) = - if args.len() >= 5 && parse_python_string_literal(args[3]).is_some() { - (3usize, 4usize) - } else { - (2usize, 3usize) - }; - - if args.len() > action_idx - && let Some(action_name) = parse_python_string_literal(args[action_idx]) - { - let params = args - .get(params_idx) - .and_then(|raw| parse_python_json_value(raw)) - .unwrap_or_else(|| serde_json::json!({})); - actions.push(serde_json::json!({ - "action": action_name, - "params": params, - })); - } - - cursor = close + 1; - } - - actions -} - -fn find_matching_paren(input: &str, open_idx: usize) -> Option { - let mut depth = 0i32; - let mut in_quote: Option = None; - let mut escaped = false; - - for (offset, ch) in input[open_idx..].char_indices() { - let idx = open_idx + offset; - if let Some(quote) = in_quote { - if escaped { - escaped = false; - continue; - } - if ch == '\\' { - escaped = true; - continue; - } - if ch == quote { - in_quote = None; - } - continue; - } - - match ch { - '\'' | '"' => in_quote = Some(ch), - '(' => depth += 1, - ')' => { - depth -= 1; - if depth == 0 { - return Some(idx); - } - } - _ => {} - } - } - None -} - -fn split_top_level_args(input: &str) -> Vec<&str> { - let mut parts = Vec::new(); - let mut start = 0usize; - let mut depth_paren = 0i32; - let mut depth_brace = 0i32; - let mut depth_bracket = 0i32; - let mut in_quote: Option = None; - let mut escaped = false; - - for (idx, ch) in input.char_indices() { - if let Some(quote) = in_quote { - if escaped { - escaped = false; - continue; - } - if ch == '\\' { - escaped = true; - continue; - } - if ch == quote { - in_quote = None; - } - continue; - } - - match ch { - '\'' | '"' => in_quote = Some(ch), - '(' => depth_paren += 1, - ')' => depth_paren -= 1, - '{' => depth_brace += 1, - '}' => depth_brace -= 1, - '[' => depth_bracket += 1, - ']' => depth_bracket -= 1, - ',' if depth_paren == 0 && depth_brace == 0 && depth_bracket == 0 => { - parts.push(input[start..idx].trim()); - start = idx + 1; - } - _ => {} - } - } - - if start <= input.len() { - let tail = input[start..].trim(); - if !tail.is_empty() { - parts.push(tail); - } - } - parts -} - -fn parse_python_string_literal(raw: &str) -> Option { - let s = raw.trim(); - if s.len() < 2 { - return None; - } - let quote = s.chars().next()?; - if (quote != '\'' && quote != '"') || !s.ends_with(quote) { - return None; - } - - let mut out = String::new(); - let mut escaped = false; - for ch in s[1..s.len() - 1].chars() { - if escaped { - let mapped = match ch { - 'n' => '\n', - 'r' => '\r', - 't' => '\t', - '\\' => '\\', - '\'' => '\'', - '"' => '"', - other => other, - }; - out.push(mapped); - escaped = false; - continue; - } - if ch == '\\' { - escaped = true; - continue; - } - out.push(ch); - } - if escaped { - out.push('\\'); - } - Some(out) -} - -fn parse_python_json_value(raw: &str) -> Option { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Some(serde_json::json!({})); - } - if let Ok(v) = serde_json::from_str::(trimmed) { - return Some(v); - } - let normalized = normalize_pythonish_json(trimmed); - serde_json::from_str::(&normalized).ok() -} - -fn normalize_pythonish_json(input: &str) -> String { - let mut quoted = String::with_capacity(input.len()); - let mut in_single = false; - let mut in_double = false; - let mut escaped = false; - - for ch in input.chars() { - if in_single { - if escaped { - quoted.push(ch); - escaped = false; - continue; - } - match ch { - '\\' => escaped = true, - '\'' => { - in_single = false; - quoted.push('"'); - } - '"' => quoted.push_str("\\\""), - _ => quoted.push(ch), - } - continue; - } - - if in_double { - quoted.push(ch); - if escaped { - escaped = false; - } else if ch == '\\' { - escaped = true; - } else if ch == '"' { - in_double = false; - } - continue; - } - - match ch { - '\'' => { - in_single = true; - quoted.push('"'); - } - '"' => { - in_double = true; - quoted.push('"'); - } - _ => quoted.push(ch), - } - } - - let mut out = String::with_capacity(quoted.len()); - let mut token = String::new(); - let mut in_string = false; - let mut esc = false; - - let flush_token = |token: &mut String, out: &mut String| { - if token.is_empty() { - return; - } - match token.as_str() { - "True" => out.push_str("true"), - "False" => out.push_str("false"), - "None" => out.push_str("null"), - _ => out.push_str(token), - } - token.clear(); - }; - - for ch in quoted.chars() { - if in_string { - out.push(ch); - if esc { - esc = false; - } else if ch == '\\' { - esc = true; - } else if ch == '"' { - in_string = false; - } - continue; - } - - if ch == '"' { - flush_token(&mut token, &mut out); - in_string = true; - out.push(ch); - continue; - } - - if ch.is_ascii_alphanumeric() || ch == '_' { - token.push(ch); - continue; - } - - flush_token(&mut token, &mut out); - out.push(ch); - } - flush_token(&mut token, &mut out); - - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extract_ots_actions_from_choice_arguments() { - let ots = serde_json::json!({ - "turns": [{ - "decisions": [{ - "choice": { - "arguments": { - "trajectory_actions": [ - {"action": "PromoteToCritical", "params": {"Reason": "prod"}}, - {"action": "Assign", "params": {"AgentId": "agent-2"}} - ] - } - } - }] - }] - }); - - let actions = extract_trajectory_actions_from_ots(&ots); - assert_eq!(actions.len(), 2); - assert_eq!( - actions[0].get("action").and_then(Value::as_str), - Some("PromoteToCritical") - ); - } - - #[test] - fn extract_ots_actions_from_user_code_message() { - let ots = serde_json::json!({ - "turns": [{ - "messages": [{ - "role": "user", - "content": { - "text": "temper.action('tenant-1', 'Issues', '11111111-1111-1111-1111-111111111111', 'Reassign', {'NewAssigneeId': 'agent-3'})" - } - }] - }] - }); - - let actions = extract_trajectory_actions_from_ots(&ots); - assert_eq!(actions.len(), 1); - assert_eq!(actions[0]["action"], serde_json::json!("Reassign")); - assert_eq!( - actions[0]["params"]["NewAssigneeId"], - serde_json::json!("agent-3") - ); - } -} diff --git a/crates/temper-server/src/state/dispatch/wasm/invocation_artifacts.rs b/crates/temper-server/src/state/dispatch/wasm/invocation_artifacts.rs new file mode 100644 index 00000000..33acf906 --- /dev/null +++ b/crates/temper-server/src/state/dispatch/wasm/invocation_artifacts.rs @@ -0,0 +1,262 @@ +use super::{WasmDispatchCtx, WasmDispatchMode, WasmEntityRef, is_http_call_authz_denial}; +use crate::entity_actor::EntityResponse; +use crate::request_context::AgentContext; +use crate::state::pending_decisions::PendingDecision; +use crate::state::trajectory::{TrajectoryEntry, TrajectorySource}; +use crate::state::wasm_invocation_log::WasmInvocationEntry; +use temper_observe::wide_event; +use temper_runtime::scheduler::{sim_now, sim_uuid}; +use temper_runtime::tenant::TenantId; +use tracing::instrument; + +impl crate::state::ServerState { + /// Record a WASM invocation (persist log entry + emit observability events). + #[allow(clippy::too_many_arguments)] + pub(super) async fn record_invocation( + &self, + entity_ref: WasmEntityRef<'_>, + module_name: &str, + trigger_action: &str, + callback_action: Option, + success: bool, + error: Option, + duration_ms: u64, + authz_denied: Option, + ) { + let log_entry = WasmInvocationEntry { + timestamp: sim_now().to_rfc3339(), + tenant: entity_ref.tenant.to_string(), + entity_type: entity_ref.entity_type.to_string(), + entity_id: entity_ref.entity_id.to_string(), + module_name: module_name.to_string(), + trigger_action: trigger_action.to_string(), + callback_action, + success, + error: error.clone(), + duration_ms, + authz_denied, + }; + let _ = self.persist_wasm_invocation(&log_entry).await; + + let wide = wide_event::from_wasm_invocation(wide_event::WasmInvocationInput { + module_name, + trigger_action, + entity_type: entity_ref.entity_type, + entity_id: entity_ref.entity_id, + tenant: &entity_ref.tenant.to_string(), + success, + duration_ns: duration_ms * 1_000_000, + error: error.as_deref(), + }); + wide_event::emit_span(&wide); + wide_event::emit_metrics(&wide); + } + + #[instrument(skip_all, fields(otel.name = "dispatch.handle_wasm_failure", trigger_action, integration_name, module_name))] + pub(super) async fn handle_wasm_failure( + &self, + ctx: &WasmDispatchCtx<'_>, + integration_name: &str, + module_name: &str, + on_failure: &Option, + error_str: String, + duration_ms: u64, + ) -> Result, String> { + let is_authz_denied = is_http_call_authz_denial(&error_str); + self.record_invocation( + ctx.entity_ref, + module_name, + ctx.action, + on_failure.clone(), + false, + Some(error_str.clone()), + duration_ms, + if is_authz_denied { Some(true) } else { None }, + ) + .await; + + let decision_id = if is_authz_denied { + self.record_wasm_authz_denial( + ctx.entity_ref, + ctx.action, + integration_name, + module_name, + &error_str, + ) + } else { + None + }; + + if let Some(cb) = on_failure { + let mut params = serde_json::json!({ + "error": error_str.clone(), + "error_message": error_str, + "integration": integration_name, + }); + if let Some(ref did) = decision_id { + params["decision_id"] = serde_json::json!(did); + params["authz_denied"] = serde_json::json!(true); + } + return self + .dispatch_wasm_callback(ctx.entity_ref, cb, params, ctx.agent_ctx, ctx.mode) + .await; + } + + Ok(None) + } + + #[instrument(skip_all, fields(otel.name = "dispatch.dispatch_wasm_callback", callback_action))] + pub(super) async fn dispatch_wasm_callback( + &self, + entity_ref: WasmEntityRef<'_>, + callback_action: &str, + callback_params: serde_json::Value, + agent_ctx: &AgentContext, + mode: WasmDispatchMode, + ) -> Result, String> { + match mode { + WasmDispatchMode::Inline => { + let resp = self + .dispatch_tenant_action_core( + entity_ref.tenant, + entity_ref.entity_type, + entity_ref.entity_id, + callback_action, + callback_params, + agent_ctx, + false, + ) + .await + .map_err(|e| e.to_string())?; + Ok(Some(resp)) + } + WasmDispatchMode::Background => { + self.dispatch_tenant_action( + entity_ref.tenant, + entity_ref.entity_type, + entity_ref.entity_id, + callback_action, + callback_params, + &AgentContext::system(), + ) + .await + .map_err(|e| { + let msg = format!("failed to dispatch WASM callback '{callback_action}': {e}"); + tracing::error!(callback = %callback_action, error = %e, "{msg}"); + msg + })?; + Ok(None) + } + } + } + + /// Record a WASM authorization denial: persist decision, create governance + /// entity, and emit trajectory entry. + pub(super) fn record_wasm_authz_denial( + &self, + entity_ref: WasmEntityRef<'_>, + trigger_action: &str, + integration_name: &str, + module_name: &str, + error_str: &str, + ) -> Option { + let pd = PendingDecision::from_denial( + entity_ref.tenant.as_str(), + "wasm-module", + "http_call", + "HttpEndpoint", + integration_name, + serde_json::json!({ + "entity_type": entity_ref.entity_type, + "entity_id": entity_ref.entity_id, + "module": module_name, + "trigger_action": trigger_action, + }), + error_str, + Some(module_name.to_string()), + ); + let decision_id = pd.id.clone(); + let _ = self.pending_decision_tx.send(pd.clone()); + let state_c = self.clone(); + #[rustfmt::skip] + tokio::spawn(async move { // determinism-ok: background persist + if let Err(e) = state_c.persist_pending_decision(&pd).await { + tracing::error!(error = %e, "failed to persist WASM authz decision"); + } + }); + + let state_c = self.clone(); + let gd_id = format!("GD-{}", sim_uuid()); + let gd_params = serde_json::json!({ + "tenant": entity_ref.tenant.as_str(), "agent_id": "wasm-module", + "action_name": "http_call", "resource_type": "HttpEndpoint", + "resource_id": integration_name, "denial_reason": error_str, + "scope": "narrow", "pending_decision_id": decision_id, + }); + #[rustfmt::skip] + tokio::spawn(async move { // determinism-ok: background entity creation + let tenant = TenantId::new("temper-system"); + if let Err(e) = state_c.dispatch_tenant_action( + &tenant, "GovernanceDecision", &gd_id, + "CreateGovernanceDecision", gd_params, &AgentContext::system(), + ).await { + tracing::warn!(error = %e, "failed to create GovernanceDecision for WASM denial"); + } + }); + + let traj = TrajectoryEntry { + timestamp: sim_now().to_rfc3339(), + tenant: entity_ref.tenant.to_string(), + entity_type: entity_ref.entity_type.to_string(), + entity_id: entity_ref.entity_id.to_string(), + action: trigger_action.to_string(), + success: false, + from_status: None, + to_status: None, + error: Some(error_str.to_string()), + agent_id: None, + session_id: None, + authz_denied: Some(true), + denied_resource: Some(integration_name.to_string()), + denied_module: Some(module_name.to_string()), + source: Some(TrajectorySource::Authz), + spec_governed: None, + agent_type: None, + request_body: None, + intent: None, + }; + tracing::info!( + tenant = %traj.tenant, + entity_type = %traj.entity_type, + entity_id = %traj.entity_id, + action = %traj.action, + success = traj.success, + from_status = ?traj.from_status, + to_status = ?traj.to_status, + error = ?traj.error, + source = ?traj.source, + authz_denied = ?traj.authz_denied, + "trajectory.entry" + ); + if !traj.success { + tracing::warn!( + tenant = %traj.tenant, + entity_type = %traj.entity_type, + entity_id = %traj.entity_id, + action = %traj.action, + error = ?traj.error, + authz_denied = ?traj.authz_denied, + source = ?traj.source, + "unmet_intent" + ); + } + let state_c = self.clone(); + #[rustfmt::skip] + tokio::spawn(async move { // determinism-ok: background persist + if let Err(e) = state_c.persist_trajectory_entry(&traj).await { + tracing::error!(error = %e, "failed to persist WASM authz trajectory"); + } + }); + Some(decision_id) + } +} diff --git a/crates/temper-server/src/state/dispatch/wasm/replay_inputs.rs b/crates/temper-server/src/state/dispatch/wasm/replay_inputs.rs new file mode 100644 index 00000000..6fe3ab15 --- /dev/null +++ b/crates/temper-server/src/state/dispatch/wasm/replay_inputs.rs @@ -0,0 +1,414 @@ +use serde_json::Value; + +pub(super) fn has_replay_trajectory_input(params: &Value) -> bool { + has_non_empty_param(params, "Trajectories") || has_non_empty_param(params, "TrajectoryActions") +} + +pub(super) fn extract_trajectory_actions_from_ots(trajectory: &Value) -> Vec { + let mut actions = Vec::new(); + + let Some(turns) = trajectory.get("turns").and_then(Value::as_array) else { + return actions; + }; + + for turn in turns { + if let Some(decisions) = turn.get("decisions").and_then(Value::as_array) { + for decision in decisions { + if let Some(raw_actions) = decision + .get("choice") + .and_then(|choice| choice.get("arguments")) + .and_then(|args| args.get("trajectory_actions")) + .and_then(Value::as_array) + { + for raw in raw_actions { + if let Some(normalized) = normalize_trajectory_action(raw) { + actions.push(normalized); + } + } + } + + if let Some(choice_action) = decision + .get("choice") + .and_then(|choice| choice.get("action")) + .and_then(Value::as_str) + && let Some(code) = choice_action.strip_prefix("execute:") + { + actions.extend(extract_temper_actions_from_code(code)); + } + } + } + + if let Some(messages) = turn.get("messages").and_then(Value::as_array) { + for message in messages { + let role = message + .get("role") + .and_then(Value::as_str) + .unwrap_or_default(); + if role != "user" { + continue; + } + let text = message + .get("content") + .and_then(|content| content.get("text")) + .and_then(Value::as_str); + if let Some(code) = text { + actions.extend(extract_temper_actions_from_code(code)); + } + } + } + } + + dedupe_actions(actions) +} + +fn has_non_empty_param(params: &Value, key: &str) -> bool { + match params.get(key) { + Some(Value::Array(arr)) => !arr.is_empty(), + Some(Value::String(s)) => !s.trim().is_empty(), + Some(Value::Object(obj)) => !obj.is_empty(), + Some(_) => true, + None => false, + } +} + +fn normalize_trajectory_action(raw: &Value) -> Option { + match raw { + Value::String(action_name) => Some(action_value(action_name, serde_json::json!({}))), + Value::Object(obj) => { + let action = obj + .get("action") + .or_else(|| obj.get("Action")) + .and_then(Value::as_str)?; + + let params = obj + .get("params") + .or_else(|| obj.get("Params")) + .and_then(parse_params_value) + .unwrap_or_else(|| serde_json::json!({})); + + Some(action_value(action, params)) + } + _ => None, + } +} + +fn parse_params_value(value: &Value) -> Option { + match value { + Value::Object(_) => Some(value.clone()), + Value::Null => Some(serde_json::json!({})), + Value::String(s) => serde_json::from_str::(s) + .ok() + .or_else(|| Some(serde_json::json!({}))), + _ => Some(serde_json::json!({})), + } +} + +fn dedupe_actions(actions: Vec) -> Vec { + let mut deduped = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + for action in actions { + let key = action.to_string(); + if seen.insert(key) { + deduped.push(action); + } + } + deduped +} + +fn extract_temper_actions_from_code(code: &str) -> Vec { + let mut actions = Vec::new(); + let mut cursor = 0usize; + let needle = "temper.action"; + + while let Some(found) = code[cursor..].find(needle) { + let method_start = cursor + found + needle.len(); + let mut open = method_start; + while open < code.len() + && code + .as_bytes() + .get(open) + .is_some_and(|b| b.is_ascii_whitespace()) + { + open += 1; + } + if code.as_bytes().get(open) != Some(&b'(') { + cursor = method_start; + continue; + } + let Some(close) = find_matching_paren(code, open) else { + break; + }; + + let args = split_top_level_args(&code[open + 1..close]); + let (action_idx, params_idx) = + if args.len() >= 5 && parse_python_string_literal(args[3]).is_some() { + (3usize, 4usize) + } else { + (2usize, 3usize) + }; + + if args.len() > action_idx + && let Some(action_name) = parse_python_string_literal(args[action_idx]) + { + let params = args + .get(params_idx) + .and_then(|raw| parse_python_json_value(raw)) + .unwrap_or_else(|| serde_json::json!({})); + actions.push(action_value(&action_name, params)); + } + + cursor = close + 1; + } + + actions +} + +fn find_matching_paren(input: &str, open_idx: usize) -> Option { + let mut depth = 0i32; + let mut in_quote: Option = None; + let mut escaped = false; + + for (offset, ch) in input[open_idx..].char_indices() { + let idx = open_idx + offset; + if let Some(quote) = in_quote { + if escaped { + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if ch == quote { + in_quote = None; + } + continue; + } + + match ch { + '\'' | '"' => in_quote = Some(ch), + '(' => depth += 1, + ')' => { + depth -= 1; + if depth == 0 { + return Some(idx); + } + } + _ => {} + } + } + None +} + +fn split_top_level_args(input: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut start = 0usize; + let mut depth_paren = 0i32; + let mut depth_brace = 0i32; + let mut depth_bracket = 0i32; + let mut in_quote: Option = None; + let mut escaped = false; + + for (idx, ch) in input.char_indices() { + if let Some(quote) = in_quote { + if escaped { + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + if ch == quote { + in_quote = None; + } + continue; + } + + match ch { + '\'' | '"' => in_quote = Some(ch), + '(' => depth_paren += 1, + ')' => depth_paren -= 1, + '{' => depth_brace += 1, + '}' => depth_brace -= 1, + '[' => depth_bracket += 1, + ']' => depth_bracket -= 1, + ',' if depth_paren == 0 && depth_brace == 0 && depth_bracket == 0 => { + parts.push(input[start..idx].trim()); + start = idx + 1; + } + _ => {} + } + } + + if start <= input.len() { + let tail = input[start..].trim(); + if !tail.is_empty() { + parts.push(tail); + } + } + parts +} + +fn parse_python_string_literal(raw: &str) -> Option { + let s = raw.trim(); + if s.len() < 2 { + return None; + } + let quote = s.chars().next()?; + if (quote != '\'' && quote != '"') || !s.ends_with(quote) { + return None; + } + + let mut out = String::new(); + let mut escaped = false; + for ch in s[1..s.len() - 1].chars() { + if escaped { + let mapped = match ch { + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + '\\' => '\\', + '\'' => '\'', + '"' => '"', + other => other, + }; + out.push(mapped); + escaped = false; + continue; + } + if ch == '\\' { + escaped = true; + continue; + } + out.push(ch); + } + if escaped { + out.push('\\'); + } + Some(out) +} + +fn parse_python_json_value(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Some(serde_json::json!({})); + } + if let Ok(v) = serde_json::from_str::(trimmed) { + return Some(v); + } + let normalized = normalize_pythonish_json(trimmed); + serde_json::from_str::(&normalized).ok() +} + +fn normalize_pythonish_json(input: &str) -> String { + let mut quoted = String::with_capacity(input.len()); + let mut in_single = false; + let mut in_double = false; + let mut escaped = false; + + for ch in input.chars() { + if in_single { + if escaped { + quoted.push(ch); + escaped = false; + continue; + } + match ch { + '\\' => escaped = true, + '\'' => { + in_single = false; + quoted.push('"'); + } + '"' => quoted.push_str("\\\""), + _ => quoted.push(ch), + } + continue; + } + + if in_double { + quoted.push(ch); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_double = false; + } + continue; + } + + match ch { + '\'' => { + in_single = true; + quoted.push('"'); + } + '"' => { + in_double = true; + quoted.push('"'); + } + _ => quoted.push(ch), + } + } + + let mut out = String::with_capacity(quoted.len()); + let mut token = String::new(); + let mut in_string = false; + let mut esc = false; + + let flush_token = |token: &mut String, out: &mut String| { + if token.is_empty() { + return; + } + match token.as_str() { + "True" => out.push_str("true"), + "False" => out.push_str("false"), + "None" => out.push_str("null"), + _ => out.push_str(token), + } + token.clear(); + }; + + for ch in quoted.chars() { + if in_string { + out.push(ch); + if esc { + esc = false; + } else if ch == '\\' { + esc = true; + } else if ch == '"' { + in_string = false; + } + continue; + } + + if ch == '"' { + flush_token(&mut token, &mut out); + in_string = true; + out.push(ch); + continue; + } + + if ch.is_ascii_alphanumeric() || ch == '_' { + token.push(ch); + continue; + } + + flush_token(&mut token, &mut out); + out.push(ch); + } + flush_token(&mut token, &mut out); + + out +} + +fn action_value(action: &str, params: Value) -> Value { + serde_json::json!({ + "action": action, + "params": params, + }) +} + +#[cfg(test)] +#[path = "replay_inputs_test.rs"] +mod tests; diff --git a/crates/temper-server/src/state/dispatch/wasm/replay_inputs_test.rs b/crates/temper-server/src/state/dispatch/wasm/replay_inputs_test.rs new file mode 100644 index 00000000..96cb91a9 --- /dev/null +++ b/crates/temper-server/src/state/dispatch/wasm/replay_inputs_test.rs @@ -0,0 +1,48 @@ +use super::*; + +#[test] +fn extract_ots_actions_from_choice_arguments() { + let ots = serde_json::json!({ + "turns": [{ + "decisions": [{ + "choice": { + "arguments": { + "trajectory_actions": [ + {"action": "PromoteToCritical", "params": {"Reason": "prod"}}, + {"action": "Assign", "params": {"AgentId": "agent-2"}} + ] + } + } + }] + }] + }); + + let actions = extract_trajectory_actions_from_ots(&ots); + assert_eq!(actions.len(), 2); + assert_eq!( + actions[0].get("action").and_then(Value::as_str), + Some("PromoteToCritical") + ); +} + +#[test] +fn extract_ots_actions_from_user_code_message() { + let ots = serde_json::json!({ + "turns": [{ + "messages": [{ + "role": "user", + "content": { + "text": "temper.action('tenant-1', 'Issues', '11111111-1111-1111-1111-111111111111', 'Reassign', {'NewAssigneeId': 'agent-3'})" + } + }] + }] + }); + + let actions = extract_trajectory_actions_from_ots(&ots); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0]["action"], serde_json::json!("Reassign")); + assert_eq!( + actions[0]["params"]["NewAssigneeId"], + serde_json::json!("agent-3") + ); +}