diff --git a/apps/desktop/src-tauri/src/bin/codevetter.rs b/apps/desktop/src-tauri/src/bin/codevetter.rs index 7df6939e..36e97c66 100644 --- a/apps/desktop/src-tauri/src/bin/codevetter.rs +++ b/apps/desktop/src-tauri/src/bin/codevetter.rs @@ -117,6 +117,8 @@ async fn run_check(arguments: CheckArguments) -> Result { repo_path: arguments.repo_path, change: arguments.change, task: arguments.task, + standards_pack: None, + standards_context: None, spec_paths: arguments.spec_paths, selected_requirement_ids: arguments.selected_requirement_ids, review_agent: arguments.review_agent, @@ -182,6 +184,8 @@ async fn run_trex(arguments: TrexArguments) -> Result { change_kind: arguments.change_kind, change: arguments.change, preview_url: arguments.preview_url, + target_route: None, + target_goal: None, }, &db, app_data_dir, @@ -681,6 +685,7 @@ mod tests { routes: vec![TrexPreviewRoute { route: "/".into(), reason: "Required root smoke".into(), + goal: None, }], journeys: vec![SyntheticQaRunResult { loop_id: "generic-page-smoke".into(), @@ -719,10 +724,12 @@ mod tests { }; LocalCheckReceipt { schema_version: "codevetter.local-check/v1".into(), + request_id: None, run_id: "local-check-fixture".into(), ran_at: "2026-08-24T00:00:00Z".into(), repo_path: "/tmp/widget".into(), task: "Preserve behavior".into(), + standards_pack: Some("product-safety".into()), source: TrexSourceReceipt { kind: TrexChangeKind::Range, input: "main...HEAD".into(), @@ -1012,6 +1019,7 @@ mod tests { let preflight = LocalCheckPreflightReceipt { schema_version: "codevetter.local-check-preflight/v1".into(), + request_id: None, ran_at: "2026-08-29T00:00:00Z".into(), repo_path: passed.repo_path.clone(), task: passed.task.clone(), diff --git a/apps/desktop/src-tauri/src/commands/agent_memories.rs b/apps/desktop/src-tauri/src/commands/agent_memories.rs index cf2ee3b6..b442fce1 100644 --- a/apps/desktop/src-tauri/src/commands/agent_memories.rs +++ b/apps/desktop/src-tauri/src/commands/agent_memories.rs @@ -1,15 +1,17 @@ use serde::Serialize; use serde_json::Value; +use sha2::{Digest, Sha256}; use std::borrow::Cow; -use std::collections::{hash_map::DefaultHasher, HashSet}; +use std::collections::HashSet; use std::env; use std::fs; -use std::hash::{Hash, Hasher}; use std::io::Read; use std::path::{Path, PathBuf}; const MAX_READ_BYTES: u64 = 512 * 1024; const MAX_OUTPUT_CHARS: usize = 120_000; +const MAX_RECEIPT_SOURCES: usize = 128; +pub const MEMORY_RECEIPT_SCHEMA_VERSION: &str = "codevetter.memories/v1"; #[derive(Clone)] struct Candidate { @@ -20,27 +22,89 @@ struct Candidate { note: &'static str, } -#[derive(Clone, Serialize)] +#[derive(Clone, Debug, Serialize)] pub struct AgentMemorySource { - id: String, - tool: String, - label: String, - path: String, - exists: bool, - readable: bool, - file_size_bytes: Option, - modified_at: Option, - source_kind: String, - preview: String, - note: String, + pub id: String, + pub tool: String, + pub label: String, + pub path: String, + pub exists: bool, + pub readable: bool, + pub file_size_bytes: Option, + pub modified_at: Option, + pub source_kind: String, + pub preview: String, + pub note: String, } -#[derive(Serialize)] +#[derive(Debug, Serialize)] pub struct AgentMemoryDocument { - source: AgentMemorySource, - content: String, - truncated: bool, - extraction_note: String, + pub source: AgentMemorySource, + pub content: String, + pub truncated: bool, + pub extraction_note: String, +} + +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MemoryReceiptOperation { + List, + Read, + Diff, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct MemorySourceReceipt { + pub id: String, + pub tool: String, + pub label: String, + pub display_path: String, + pub exists: bool, + pub readable: bool, + pub file_size_bytes: Option, + pub modified_at: Option, + pub source_kind: String, + pub preview: String, + pub note: String, +} + +#[derive(Debug, Serialize)] +pub struct MemoryDocumentReceipt { + pub source_id: String, + pub content: String, + pub truncated: bool, + pub extraction_note: String, +} + +#[derive(Debug, Serialize)] +pub struct MemoryDiffReceipt { + pub source_id: String, + pub has_changes: bool, + pub status: String, + pub diff: String, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct MemoryReceiptLimits { + pub max_sources: usize, + pub max_read_bytes: u64, + pub max_output_chars: usize, + pub sources_truncated: bool, +} + +#[derive(Debug, Serialize)] +pub struct MemoryReceipt { + pub schema_version: String, + pub generated_at: String, + pub operation: MemoryReceiptOperation, + pub selected_source_id: Option, + pub candidate_locations_checked: usize, + pub sources_total: usize, + pub sources: Vec, + pub document: Option, + pub diff: Option, + pub limits: MemoryReceiptLimits, + pub limitations: Vec, } #[tauri::command] @@ -52,6 +116,135 @@ pub fn list_agent_memory_sources() -> Result, String> { Ok(out) } +pub fn run_memory_receipt( + operation: MemoryReceiptOperation, + source_id: Option<&str>, +) -> Result { + let mut all_sources = list_agent_memory_sources()?; + let candidate_locations_checked = all_sources.len(); + all_sources.retain(|source| source.exists); + all_sources.sort_by(|left, right| { + right + .readable + .cmp(&left.readable) + .then_with(|| right.exists.cmp(&left.exists)) + .then_with(|| left.tool.cmp(&right.tool)) + .then_with(|| left.label.cmp(&right.label)) + .then_with(|| left.path.cmp(&right.path)) + }); + let sources_total = all_sources.len(); + all_sources.truncate(MAX_RECEIPT_SOURCES); + let sources = all_sources + .iter() + .map(memory_source_receipt) + .collect::>(); + + let selected = match operation { + MemoryReceiptOperation::List => { + if source_id.is_some() { + return Err("memory list does not accept a source id".to_string()); + } + None + } + MemoryReceiptOperation::Read | MemoryReceiptOperation::Diff => { + let source_id = source_id + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "memory read and diff require one source id".to_string())?; + Some( + all_sources + .iter() + .find(|source| source.id == source_id) + .ok_or_else(|| { + "Memory source is unavailable or outside the bounded source catalog." + .to_string() + })?, + ) + } + }; + + let document = if operation == MemoryReceiptOperation::Read { + let selected = selected.expect("read operation has a selected source"); + let document = read_agent_memory_source(selected.path.clone())?; + Some(MemoryDocumentReceipt { + source_id: selected.id.clone(), + content: document.content, + truncated: document.truncated, + extraction_note: document.extraction_note, + }) + } else { + None + }; + let diff = if operation == MemoryReceiptOperation::Diff { + let selected = selected.expect("diff operation has a selected source"); + let diff = get_memory_file_git_diff(selected.path.clone())?; + Some(MemoryDiffReceipt { + source_id: selected.id.clone(), + has_changes: diff.has_changes, + status: diff.status, + diff: diff.diff, + }) + } else { + None + }; + + Ok(MemoryReceipt { + schema_version: MEMORY_RECEIPT_SCHEMA_VERSION.to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + operation, + selected_source_id: selected.map(|source| source.id.clone()), + candidate_locations_checked, + sources_total, + sources, + document, + diff, + limits: MemoryReceiptLimits { + max_sources: MAX_RECEIPT_SOURCES, + max_read_bytes: MAX_READ_BYTES, + max_output_chars: MAX_OUTPUT_CHARS, + sources_truncated: sources_total > MAX_RECEIPT_SOURCES, + }, + limitations: vec![ + "This surface is read-only and cannot edit, create, or delete memory sources." + .to_string(), + "Source selection uses an opaque bounded identity; absolute paths are not emitted by this receipt." + .to_string(), + "Secret-like lines are redacted heuristically; operators should still treat displayed memory as private." + .to_string(), + "Agent and MCP projections are unavailable; only the local UI and explicit CLI can read content." + .to_string(), + ], + }) +} + +fn memory_source_receipt(source: &AgentMemorySource) -> MemorySourceReceipt { + MemorySourceReceipt { + id: source.id.clone(), + tool: source.tool.clone(), + label: source.label.clone(), + display_path: receipt_display_path(Path::new(&source.path)), + exists: source.exists, + readable: source.readable, + file_size_bytes: source.file_size_bytes, + modified_at: source.modified_at.clone(), + source_kind: source.source_kind.clone(), + preview: source.preview.clone(), + note: source.note.clone(), + } +} + +fn receipt_display_path(path: &Path) -> String { + let display = display_path(path); + if Path::new(&display).is_absolute() { + let name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("memory-source"); + format!("/{name}") + } else { + display + } +} + #[tauri::command] pub fn read_agent_memory_source(path: String) -> Result { let requested = PathBuf::from(&path); @@ -694,9 +887,10 @@ fn display_path(path: &Path) -> String { } fn stable_id(path: &Path) -> String { - let mut hasher = DefaultHasher::new(); - path.to_string_lossy().hash(&mut hasher); - format!("{:x}", hasher.finish()) + format!( + "memory:sha256:{:x}", + Sha256::digest(path.to_string_lossy().as_bytes()) + ) } fn push_unique_path(paths: &mut Vec, path: PathBuf) { @@ -867,3 +1061,67 @@ fn redact_diff(diff: &str) -> String { .collect::>() .join("\n") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opaque_memory_identity_is_deterministic_and_does_not_embed_the_path() { + let path = Path::new("/private/example/.codex/memories/MEMORY.md"); + let first = stable_id(path); + let second = stable_id(path); + + assert_eq!(first, second); + assert!(first.starts_with("memory:sha256:")); + assert_eq!(first.len(), "memory:sha256:".len() + 64); + assert!(!first.contains(".codex")); + assert_ne!( + first, + stable_id(Path::new("/private/example/.codex/AGENTS.md")) + ); + } + + #[test] + fn receipt_projection_replaces_absolute_paths_with_display_paths() { + let source = AgentMemorySource { + id: "memory:sha256:fixture".to_string(), + tool: "Codex".to_string(), + label: "Codex memory".to_string(), + path: "/private/example/.codex/memories/MEMORY.md".to_string(), + exists: true, + readable: true, + file_size_bytes: Some(42), + modified_at: Some("2026-09-01T00:00:00Z".to_string()), + source_kind: "markdown".to_string(), + preview: "Verification evidence only".to_string(), + note: "Full Markdown memory registry.".to_string(), + }; + + let receipt = memory_source_receipt(&source); + assert_eq!(receipt.id, source.id); + assert_eq!(receipt.display_path, "/MEMORY.md"); + assert!(!serde_json::to_string(&receipt) + .unwrap() + .contains("\"path\"")); + } + + #[test] + fn content_and_diff_redaction_preserve_structure_without_secret_like_lines() { + let content = redact_content( + "# Working memory\nKeep runtime evidence.\napi_key = should-not-appear\nNext step.", + ); + assert!(content.contains("Keep runtime evidence.")); + assert!(content.contains("[redacted secret-like line]")); + assert!(!content.contains("should-not-appear")); + + let diff = redact_diff( + "diff --git a/MEMORY.md b/MEMORY.md\n@@ -1 +1 @@\n-api_key = old\n+api_key = new", + ); + assert!(diff.contains("diff --git")); + assert!(diff.contains("@@ -1 +1 @@")); + assert!(diff.contains("-[redacted secret-like line]")); + assert!(diff.contains("+[redacted secret-like line]")); + assert!(!diff.contains("api_key")); + } +} diff --git a/apps/desktop/src-tauri/src/commands/agent_terminal.rs b/apps/desktop/src-tauri/src/commands/agent_terminal.rs index 54c91aba..aeba61f0 100644 --- a/apps/desktop/src-tauri/src/commands/agent_terminal.rs +++ b/apps/desktop/src-tauri/src/commands/agent_terminal.rs @@ -569,10 +569,8 @@ fn start_agent_terminal_impl( metadata.clone(), ) { Ok(result) => return Ok(result), - Err(error) => { - eprintln!( - "Codex app-server unavailable for {session_id}; falling back to PTY: {error}" - ); + Err(_error) => { + eprintln!("Codex app-server unavailable; falling back to PTY"); } } } diff --git a/apps/desktop/src-tauri/src/commands/deterministic_review.rs b/apps/desktop/src-tauri/src/commands/deterministic_review.rs index 445d9f9c..61ba5966 100644 --- a/apps/desktop/src-tauri/src/commands/deterministic_review.rs +++ b/apps/desktop/src-tauri/src/commands/deterministic_review.rs @@ -1637,7 +1637,8 @@ mod tests { #[test] fn recorded_benchmark_never_emits_an_invalid_position_after_qualification() { - let benchmark = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../benchmark"); + let benchmark = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../benchmarks/public-catch-rate"); let raw_dir = benchmark.join("reviews-raw"); let mut raw_candidates = 0usize; let mut qualified_candidates = 0usize; @@ -1683,11 +1684,11 @@ mod tests { qualified_candidates += qualified.findings.len(); } assert!( - raw_candidates >= 29, + raw_candidates >= 27, "recorded corpus is unexpectedly small" ); assert!( - qualified_candidates >= 29, + qualified_candidates >= 27, "qualification removed too much evidence" ); } diff --git a/apps/desktop/src-tauri/src/commands/evidence_scope.rs b/apps/desktop/src-tauri/src/commands/evidence_scope.rs index 62d7221a..6645e9de 100644 --- a/apps/desktop/src-tauri/src/commands/evidence_scope.rs +++ b/apps/desktop/src-tauri/src/commands/evidence_scope.rs @@ -38,7 +38,7 @@ pub enum EvidenceScopeConsumer { Performance, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct EvidenceScopeInput { pub repo_path: String, pub kind: EvidenceScopeKind, @@ -657,20 +657,24 @@ mod tests { use super::*; use std::process::Command as StdCommand; + const SURFACE_PARITY_FIXTURE: &str = + include_str!("../../tests/fixtures/surface-parity/evidence-scope-v1.json"); + + fn surface_parity_fixture() -> serde_json::Value { + serde_json::from_str(SURFACE_PARITY_FIXTURE).expect("surface parity fixture") + } + fn fixture_repository() -> tempfile::TempDir { + let fixture = surface_parity_fixture(); let repo = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(repo.path().join("src/cart")).unwrap(); - std::fs::write(repo.path().join("vitest.config.ts"), "export default {};\n").unwrap(); - std::fs::write( - repo.path().join("src/cart/coupon.ts"), - "export const couponTotal = (value: number) => value;\n", - ) - .unwrap(); - std::fs::write( - repo.path().join("src/cart/coupon.test.ts"), - "import { couponTotal } from './coupon';\ntest('coupon total', () => couponTotal(2));\n", - ) - .unwrap(); + for (relative_path, content) in fixture["repository"]["files"] + .as_object() + .expect("fixture files") + { + let path = repo.path().join(relative_path); + std::fs::create_dir_all(path.parent().expect("fixture file parent")).unwrap(); + std::fs::write(path, content.as_str().expect("fixture file content")).unwrap(); + } for args in [ vec!["init", "-q"], vec!["add", "."], @@ -812,6 +816,62 @@ mod tests { assert!(portfolio.limitations[0].contains("bounded")); } + #[tokio::test] + async fn authoritative_resolver_matches_the_shared_surface_parity_fixture() { + let fixture = surface_parity_fixture(); + let request = &fixture["request"]; + let expected = &fixture["expected"]; + let repo = fixture_repository(); + let plan = resolve(EvidenceScopeInput { + repo_path: repo.path().to_string_lossy().into_owned(), + kind: serde_json::from_value(request["kind"].clone()).expect("fixture kind"), + value: request["value"].as_str().map(str::to_string), + consumer: serde_json::from_value(request["consumer"].clone()) + .expect("fixture consumer"), + }) + .await + .expect("surface parity plan"); + + assert_eq!(plan.schema_version, expected["schema_version"]); + assert_eq!(plan.status, expected["status"]); + assert_eq!(plan.candidates.len(), expected["candidate_count"]); + let candidate = plan.candidates.first().expect("fixture candidate"); + let expected_candidate = &expected["first_candidate"]; + assert_eq!(candidate.id, expected_candidate["id"]); + assert_eq!(candidate.adapter, expected_candidate["adapter"]); + assert_eq!(candidate.target, expected_candidate["target"]); + assert_eq!( + candidate.confidence_milli, + expected_candidate["confidence_milli"] + ); + assert_eq!( + candidate.testing_supported, + expected_candidate["testing_supported"] + ); + assert_eq!( + candidate.performance_supported, + expected_candidate["performance_supported"] + ); + assert!(plan + .limitations + .iter() + .any(|limitation| limitation.contains( + expected["limitation_contains"] + .as_str() + .expect("fixture limitation") + ))); + + let canonical: EvidenceScopePlan = + serde_json::from_value(fixture["canonical_receipt"].clone()) + .expect("canonical fixture receipt"); + assert_eq!(canonical.schema_version, plan.schema_version); + assert_eq!(canonical.kind, plan.kind); + assert_eq!(canonical.consumer, plan.consumer); + assert_eq!(canonical.status, plan.status); + assert_eq!(canonical.candidates[0].id, candidate.id); + assert_eq!(canonical.candidates[0].target, candidate.target); + } + #[tokio::test] async fn generic_flow_words_fail_closed() { let repo = fixture_repository(); diff --git a/apps/desktop/src-tauri/src/commands/history.rs b/apps/desktop/src-tauri/src/commands/history.rs index 004cf20b..5b0c10ab 100644 --- a/apps/desktop/src-tauri/src/commands/history.rs +++ b/apps/desktop/src-tauri/src/commands/history.rs @@ -1148,7 +1148,7 @@ fn estimate_cost_precise( /// cache-tier split — e.g. by-model aggregate rows that fall back to /// session-level totals without a per-model breakdown. Treats all /// cache-creation tokens as the default 5-minute tier. -fn estimate_cost( +pub(crate) fn estimate_cost( model: &str, total_input: i64, output_tokens: i64, @@ -1565,12 +1565,11 @@ fn upsert_adapter_summary_session( let archive_messages = summary.archive_messages.clone(); let parse_warnings = summary.parse_warnings.clone(); - for warning in &summary.parse_warnings { + if !summary.parse_warnings.is_empty() { log::warn!( - "{} session adapter warning for {}: {}", + "{} session adapter reported {} parse warning(s)", summary.adapter_id, - source_ref, - warning + summary.parse_warnings.len() ); } diff --git a/apps/desktop/src-tauri/src/commands/local_check.rs b/apps/desktop/src-tauri/src/commands/local_check.rs index 50ab6408..2224abd8 100644 --- a/apps/desktop/src-tauri/src/commands/local_check.rs +++ b/apps/desktop/src-tauri/src/commands/local_check.rs @@ -7,12 +7,14 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; +use rusqlite::OptionalExtension; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tokio::process::Command; use crate::{db, DbState}; +use super::cross_review; use super::evidence_scope::{ resolve_evidence_scope, EvidenceScopeCandidate, EvidenceScopeConsumer, EvidenceScopeInput, EvidenceScopeKind, EvidenceScopePlan, @@ -75,10 +77,14 @@ pub struct LocalCheckStages { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalCheckReceipt { pub schema_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, pub run_id: String, pub ran_at: String, pub repo_path: String, pub task: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub standards_pack: Option, pub source: TrexSourceReceipt, pub stages: LocalCheckStages, #[serde(skip_serializing_if = "Option::is_none")] @@ -90,6 +96,8 @@ pub struct LocalCheckReceipt { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalCheckPreflightReceipt { pub schema_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, pub ran_at: String, pub repo_path: String, pub task: String, @@ -113,6 +121,8 @@ pub struct LocalCheckInput { pub repo_path: PathBuf, pub change: String, pub task: String, + pub standards_pack: Option, + pub standards_context: Option, pub spec_paths: Vec, pub selected_requirement_ids: Vec, pub review_agent: String, @@ -156,10 +166,19 @@ where performance_selection_limitation, } = prepared; let diff_range = format!("{}...{}", source.base_sha, source.head_sha); - let review_task = compose_review_task(&input.task, spec_packet.as_ref()); + let mut review_task = compose_review_task(&input.task, spec_packet.as_ref()); + if let Some(context) = input + .standards_context + .as_deref() + .map(str::trim) + .filter(|context| !context.is_empty()) + { + review_task.push_str("\n\n"); + review_task.push_str(context); + } let app_data_dir = default_app_data_dir()?; - let connection = - db::init_db(app_data_dir).map_err(|error| format!("open CodeVetter database: {error}"))?; + let connection = db::init_db(app_data_dir.clone()) + .map_err(|error| format!("open CodeVetter database: {error}"))?; let db = DbState(std::sync::Arc::new(std::sync::Mutex::new(connection))); on_progress(LocalCheckProgress { @@ -223,6 +242,7 @@ where &review_task, &input.review_agent, review_runtime_context, + |stage, state| on_progress(LocalCheckProgress { stage, state }), ) .await; on_progress(progress_for_stage("review", &review)); @@ -273,18 +293,101 @@ where stage: "done", state: verdict_name(verdict), }); - Ok(LocalCheckReceipt { + let receipt = LocalCheckReceipt { schema_version: "codevetter.local-check/v1".into(), + request_id: None, run_id: format!("local-check-{}", uuid::Uuid::new_v4()), ran_at: chrono::Utc::now().to_rfc3339(), repo_path: repo_text, task: input.task, + standards_pack: input.standards_pack, source, stages, spec_coverage, verdict, limitations, + }; + let connection = db::init_db(app_data_dir) + .map_err(|error| format!("reopen CodeVetter database for run receipt: {error}"))?; + persist_local_check_receipt(&connection, &receipt)?; + Ok(receipt) +} + +pub fn persist_local_check_receipt( + connection: &rusqlite::Connection, + receipt: &LocalCheckReceipt, +) -> Result<(), String> { + let receipt_json = serde_json::to_string(receipt) + .map_err(|error| format!("serialize local check receipt for persistence: {error}"))?; + connection + .execute( + "INSERT OR REPLACE INTO local_check_runs( + run_id, schema_version, repo_path, base_sha, head_sha, + verdict, task, receipt_json, ran_at + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params![ + receipt.run_id, + receipt.schema_version, + receipt.repo_path, + receipt.source.base_sha, + receipt.source.head_sha, + verdict_name(receipt.verdict), + receipt.task, + receipt_json, + receipt.ran_at, + ], + ) + .map_err(|error| format!("persist local check receipt: {error}"))?; + Ok(()) +} + +pub fn list_local_check_receipts( + connection: &rusqlite::Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let limit = limit.clamp(1, 100) as i64; + let sql = if repo_path.is_some() { + "SELECT receipt_json FROM local_check_runs + WHERE repo_path = ?1 ORDER BY ran_at DESC LIMIT ?2" + } else { + "SELECT receipt_json FROM local_check_runs + ORDER BY ran_at DESC LIMIT ?2" + }; + let mut statement = connection + .prepare(sql) + .map_err(|error| format!("prepare local check history: {error}"))?; + let decode = |row: &rusqlite::Row<'_>| -> rusqlite::Result { row.get(0) }; + let rows = match repo_path { + Some(repo_path) => statement.query_map(rusqlite::params![repo_path, limit], decode), + None => statement.query_map(rusqlite::params![rusqlite::types::Null, limit], decode), + } + .map_err(|error| format!("read local check history: {error}"))?; + rows.map(|row| { + let json = row.map_err(|error| format!("read local check receipt row: {error}"))?; + serde_json::from_str(&json) + .map_err(|error| format!("decode stored local check receipt: {error}")) }) + .collect() +} + +pub fn get_local_check_receipt( + connection: &rusqlite::Connection, + repo_path: &str, + run_id: &str, +) -> Result { + let receipt_json = connection + .query_row( + "SELECT receipt_json FROM local_check_runs + WHERE repo_path = ?1 AND run_id = ?2", + rusqlite::params![repo_path, run_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| format!("read local check receipt: {error}"))? + .ok_or_else(|| "Local-check receipt was not found in this repository scope".to_string())?; + serde_json::from_str(&receipt_json) + .map_err(|error| format!("decode stored local check receipt: {error}")) } pub async fn preflight_local_check( @@ -313,6 +416,17 @@ pub async fn preflight_local_check( ) }); let mut limitations = Vec::new(); + let missing_review_executors = if input.review_agent == "cross" { + cross_review::missing_executors() + } else { + Vec::new() + }; + if !missing_review_executors.is_empty() { + limitations.push(format!( + "Cross-review requires both configured executors before either pass starts; missing: {}", + missing_review_executors.join(", ") + )); + } if correctness_target.is_none() { limitations.push( test_plan @@ -344,7 +458,8 @@ pub async fn preflight_local_check( ); } } - let status = if correctness_target.is_some() + let status = if missing_review_executors.is_empty() + && correctness_target.is_some() && spec_coverage.as_ref().is_none_or(|coverage| { coverage.summary.total_requirements > 0 && coverage.summary.selected_for_execution > 0 }) { @@ -355,6 +470,7 @@ pub async fn preflight_local_check( Ok(LocalCheckPreflightReceipt { schema_version: "codevetter.local-check-preflight/v1".into(), + request_id: None, ran_at: chrono::Utc::now().to_rfc3339(), repo_path: repo_text, task: input.task.clone(), @@ -441,8 +557,11 @@ fn validate_input(input: &LocalCheckInput) -> Result<(), String> { if input.task.trim().is_empty() || input.task.len() > 2_000 { return Err("Task must contain between 1 and 2,000 characters".into()); } - if !matches!(input.review_agent.as_str(), "claude" | "gemini" | "codex") { - return Err("Review agent must be `claude`, `gemini`, or `codex`".into()); + if !matches!( + input.review_agent.as_str(), + "claude" | "gemini" | "codex" | "cross" + ) { + return Err("Review agent must be `claude`, `gemini`, `codex`, or `cross`".into()); } if !(2..=10).contains(&input.samples) { return Err("Performance samples must be between 2 and 10".into()); @@ -578,15 +697,31 @@ fn git_text(repo: &Path, arguments: &[&str]) -> Result { .map_err(|_| "Git returned non-UTF-8 checkout evidence".into()) } -async fn run_review_stage( +async fn run_review_stage( db: DbState, repo_path: &str, diff_range: &str, task: &str, agent: &str, runtime_context: Vec, -) -> LocalCheckStage { + on_cross_progress: F, +) -> LocalCheckStage +where + F: FnMut(&'static str, &'static str), +{ let started = Instant::now(); + if agent == "cross" { + return run_cross_review_stage( + db, + repo_path, + diff_range, + task, + runtime_context, + started, + on_cross_progress, + ) + .await; + } match run_cli_review_core( db, repo_path.to_string(), @@ -599,61 +734,185 @@ async fn run_review_stage( ) .await { - Ok(evidence) => { - let readiness_complete = evidence - .get("review_readiness") - .and_then(|value| value.get("status")) - .and_then(Value::as_str) - == Some("ready") - && evidence.get("review_status").and_then(Value::as_str) == Some("completed"); - let actionable = evidence - .get("findings") - .and_then(Value::as_array) - .is_some_and(|findings| { - findings.iter().any(|finding| { - matches!( - finding.get("severity").and_then(Value::as_str), - Some("critical" | "high") - ) - }) - }); - let limitations = if readiness_complete { - Vec::new() - } else { - evidence - .get("review_readiness") - .and_then(|value| value.get("limitations")) - .and_then(Value::as_array) - .map(|values| { - values - .iter() - .filter_map(Value::as_str) - .map(ToOwned::to_owned) - .collect::>() - }) - .filter(|values| !values.is_empty()) - .unwrap_or_else(|| { - vec!["Review context or execution coverage was incomplete".to_string()] - }) - }; - LocalCheckStage { - status: if !readiness_complete { - LocalCheckStatus::NoConfidence - } else if actionable { - LocalCheckStatus::NeedsAttention - } else { - LocalCheckStatus::Completed - }, - duration_ms: elapsed_ms(started), - target: None, - evidence, - limitations, - } - } + Ok(evidence) => review_stage_from_evidence(started, evidence), Err(error) => no_confidence_stage(started, None, error), } } +async fn run_cross_review_stage( + db: DbState, + repo_path: &str, + diff_range: &str, + task: &str, + runtime_context: Vec, + started: Instant, + mut on_progress: F, +) -> LocalCheckStage +where + F: FnMut(&'static str, &'static str), +{ + let missing = cross_review::missing_executors(); + if !missing.is_empty() { + let receipt = cross_review::incomplete_after_pass( + None, + "preflight", + &format!("missing configured executors: {}", missing.join(", ")), + ); + return review_stage_from_evidence(started, cross_review::project_stage_evidence(receipt)); + } + let policy_binding = match cross_review::coordinator_policy_binding( + repo_path, + diff_range, + task, + &runtime_context, + ) { + Ok(binding) => binding, + Err(error) => { + return review_stage_from_evidence( + started, + cross_review::project_stage_evidence(cross_review::incomplete_after_pass( + None, + "policy_binding", + &error, + )), + ); + } + }; + let run_pass = |agent: &str, db: DbState, context: Vec| { + run_cli_review_core( + db, + repo_path.to_string(), + diff_range.to_string(), + "Local repository change".into(), + task.to_string(), + Some(agent.to_string()), + Some(context), + None, + ) + }; + on_progress("review_claude", "running"); + let mut claude = match run_pass("claude", db.clone(), runtime_context.clone()).await { + Ok(evidence) => evidence, + Err(error) => { + on_progress("review_claude", "no_confidence"); + let receipt = cross_review::incomplete_after_pass(None, "claude", &error); + return review_stage_from_evidence( + started, + cross_review::project_stage_evidence(receipt), + ); + } + }; + if let Err(error) = cross_review::attach_coordinator_binding(&mut claude, &policy_binding) { + return review_stage_from_evidence( + started, + cross_review::project_stage_evidence(cross_review::incomplete_after_pass( + None, + "claude_binding", + &error, + )), + ); + } + on_progress("review_claude", "completed"); + on_progress("review_codex", "running"); + let mut codex = match run_pass("codex", db.clone(), runtime_context).await { + Ok(evidence) => evidence, + Err(error) => { + on_progress("review_codex", "no_confidence"); + let receipt = + cross_review::incomplete_after_pass(Some(("claude", claude)), "codex", &error); + return review_stage_from_evidence( + started, + cross_review::project_stage_evidence(receipt), + ); + } + }; + if let Err(error) = cross_review::attach_coordinator_binding(&mut codex, &policy_binding) { + return review_stage_from_evidence( + started, + cross_review::project_stage_evidence(cross_review::incomplete_after_pass( + Some(("claude", claude)), + "codex_binding", + &error, + )), + ); + } + on_progress("review_codex", "completed"); + let receipt = match cross_review::reconcile_complete(claude, codex) { + Ok(receipt) => receipt, + Err(error) => { + return review_stage_from_evidence( + started, + cross_review::project_stage_evidence(cross_review::incomplete_after_pass( + None, + "reconciliation", + &error, + )), + ); + } + }; + let mut evidence = cross_review::project_stage_evidence(receipt); + if let Err(error) = + cross_review::persist_composite_review(&db, repo_path, diff_range, None, &mut evidence) + { + return no_confidence_stage( + started, + None, + format!("Could not persist the cross-review composite: {error}"), + ); + } + review_stage_from_evidence(started, evidence) +} + +fn review_stage_from_evidence(started: Instant, evidence: Value) -> LocalCheckStage { + let readiness_complete = evidence + .pointer("/review_readiness/status") + .and_then(Value::as_str) + == Some("ready") + && evidence.get("review_status").and_then(Value::as_str) == Some("completed"); + let actionable = evidence + .get("findings") + .and_then(Value::as_array) + .is_some_and(|findings| { + findings.iter().any(|finding| { + matches!( + finding.get("severity").and_then(Value::as_str), + Some("critical" | "high") + ) + }) + }); + let limitations = if readiness_complete { + Vec::new() + } else { + evidence + .pointer("/review_readiness/limitations") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect::>() + }) + .filter(|values| !values.is_empty()) + .unwrap_or_else(|| { + vec!["Review context or execution coverage was incomplete".to_string()] + }) + }; + LocalCheckStage { + status: if !readiness_complete { + LocalCheckStatus::NoConfidence + } else if actionable { + LocalCheckStatus::NeedsAttention + } else { + LocalCheckStatus::Completed + }, + duration_ms: elapsed_ms(started), + target: None, + evidence, + limitations, + } +} + fn runtime_stage_review_context(kind: &str, stage: &LocalCheckStage) -> Value { let evidence_status = match stage.status { LocalCheckStatus::Passed => "pass", @@ -806,6 +1065,24 @@ async fn run_runtime_stage( } } +pub async fn rerun_fix_correctness_target( + repo_path: &Path, + target: Option, + timeout_ms: u64, +) -> LocalCheckStage { + run_runtime_stage( + repo_path, + "run", + target, + 2, + 0, + timeout_ms, + None, + Some("The source verification receipt has no correctness target to recheck".into()), + ) + .await +} + fn runtime_stage_status(operation: &str, evidence: &Value) -> LocalCheckStatus { if operation == "run" { return match evidence @@ -1433,4 +1710,61 @@ mod tests { LocalCheckStatus::NoConfidence ); } + + #[test] + fn persisted_local_checks_round_trip_in_reverse_chronological_order() { + let connection = rusqlite::Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + let receipt = |run_id: &str, repo_path: &str, ran_at: &str| LocalCheckReceipt { + schema_version: "codevetter.local-check/v1".into(), + request_id: None, + run_id: run_id.into(), + ran_at: ran_at.into(), + repo_path: repo_path.into(), + task: format!("Verify {run_id}"), + standards_pack: None, + source: TrexSourceReceipt { + kind: super::super::trex_preview::TrexChangeKind::Range, + input: "main...HEAD".into(), + base_sha: "a".repeat(40), + head_sha: "b".repeat(40), + commits: vec!["b".repeat(40)], + changed_paths: vec!["src/main.rs".into()], + }, + stages: LocalCheckStages { + review: stage(LocalCheckStatus::Completed), + correctness: stage(LocalCheckStatus::Passed), + performance: stage(LocalCheckStatus::NoConfidence), + optimization: stage(LocalCheckStatus::NoConfidence), + }, + spec_coverage: None, + verdict: LocalCheckVerdict::PassedWithLimits, + limitations: vec!["Fixture limitation".into()], + }; + persist_local_check_receipt( + &connection, + &receipt("run-old", "/tmp/repo", "2026-08-30T00:00:00Z"), + ) + .expect("old receipt"); + persist_local_check_receipt( + &connection, + &receipt("run-new", "/tmp/repo", "2026-08-31T00:00:00Z"), + ) + .expect("new receipt"); + persist_local_check_receipt( + &connection, + &receipt("run-other", "/tmp/other", "2026-09-01T00:00:00Z"), + ) + .expect("other receipt"); + + let rows = + list_local_check_receipts(&connection, Some("/tmp/repo"), 10).expect("stored history"); + assert_eq!( + rows.iter() + .map(|row| row.run_id.as_str()) + .collect::>(), + vec!["run-new", "run-old"] + ); + assert_eq!(rows[0].limitations, vec!["Fixture limitation"]); + } } diff --git a/apps/desktop/src-tauri/src/commands/local_usage.rs b/apps/desktop/src-tauri/src/commands/local_usage.rs index 938f0ca8..88815baf 100644 --- a/apps/desktop/src-tauri/src/commands/local_usage.rs +++ b/apps/desktop/src-tauri/src/commands/local_usage.rs @@ -1,6 +1,7 @@ use crate::db::queries; use crate::DbState; -use chrono::Utc; +use chrono::{Duration as ChronoDuration, Local, NaiveDate, Utc}; +use rusqlite::Connection; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -119,6 +120,41 @@ pub struct LocalUsageFailure { pub message: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DevinUsageModel { + pub model: String, + pub sessions: i64, + pub generated_tokens: i64, + pub cache_read_tokens: i64, + pub cost_usd: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DevinUsageWindow { + pub window: String, + pub since: Option, + pub sessions: i64, + pub generated_tokens: i64, + pub cache_read_tokens: i64, + pub cost_usd: f64, + pub models: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DevinUsageSummary { + pub status: String, + pub source: String, + pub sessions: i64, + pub generated_tokens: i64, + pub cache_read_tokens: i64, + pub output_tokens: i64, + pub cost_usd: f64, + pub models: Vec, + #[serde(default)] + pub windows: Vec, + pub limitations: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct LocalUsageReport { pub status: String, @@ -130,6 +166,8 @@ pub struct LocalUsageReport { pub monthly: Vec, pub sessions: Vec, pub totals: LocalUsageTotals, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub devin: Option, } #[derive(Debug, Deserialize)] @@ -155,11 +193,11 @@ struct RawPeriod { #[serde(default)] agents: Vec, #[serde(flatten)] - totals: RawTotals, + _totals: RawTotals, #[serde(default)] metadata: RawMetadata, - #[serde(default)] - model_breakdowns: Vec, + #[serde(default, rename = "modelBreakdowns")] + _model_breakdowns: Vec, #[serde(default, rename = "modelsUsed")] _models_used: Vec, period: String, @@ -256,10 +294,178 @@ pub async fn get_local_usage_report( refresh: Option, timezone: Option, ) -> Result { - let roots = codex_roots(&db)?; - let timezone = normalize_timezone(timezone.as_deref()); + let roots = { + let connection = db.0.lock().map_err(|error| error.to_string())?; + codex_roots(Some(&connection))? + }; + let mut report = + get_local_usage_report_for_roots(roots, refresh.unwrap_or(false), timezone.as_deref()) + .await?; + let connection = db.0.lock().map_err(|error| error.to_string())?; + report.devin = Some(project_devin_usage(&connection)?); + Ok(report) +} + +/// Read the local usage report without requiring Tauri state. +/// +/// Transport adapters may provide an existing CodeVetter connection so imported +/// Codex roots remain consistent with the desktop app. Passing `None` keeps the +/// operation read-only and falls back to environment/default roots. +pub async fn get_headless_local_usage_report( + connection: Option<&Connection>, + refresh: bool, + timezone: Option<&str>, +) -> Result { + let roots = codex_roots(connection)?; + let mut report = get_local_usage_report_for_roots(roots, refresh, timezone).await?; + report.devin = connection.map(project_devin_usage).transpose()?; + Ok(report) +} + +fn project_devin_usage(connection: &Connection) -> Result { + project_devin_usage_at(connection, Local::now().date_naive()) +} + +fn project_devin_usage_at( + connection: &Connection, + today: NaiveDate, +) -> Result { + let row = queries::get_agent_usage_breakdown(connection) + .map_err(|error| error.to_string())? + .into_iter() + .find(|row| row.agent_type == "devin"); + let has_row = row.is_some(); + let excluded_agents = [ + "claude-code", + "codex", + "cursor", + "grok", + "google", + "openai", + "openrouter", + ] + .map(str::to_string); + let row = row.unwrap_or(queries::AgentUsageRow { + agent_type: "devin".into(), + sessions: 0, + real_input_tokens: 0, + cache_read_tokens: 0, + output_tokens: 0, + week_real_input_tokens: 0, + week_output_tokens: 0, + cost: 0.0, + }); + let mut windows = Vec::with_capacity(4); + for (window, days) in [ + ("1w", Some(7_i64)), + ("30d", Some(30_i64)), + ("90d", Some(90_i64)), + ("all", None), + ] { + let since = days.map(|days| { + (today - ChronoDuration::days(days - 1)) + .format("%Y-%m-%d") + .to_string() + }); + let models = project_devin_models(connection, since.as_deref(), &excluded_agents)?; + let (sessions, generated_tokens, cache_read_tokens, cost_usd) = match since.as_deref() { + Some(since) => { + let rows = queries::get_agent_usage_by_day_since(connection, since) + .map_err(|error| error.to_string())?; + let (generated, cache, cost) = rows + .into_iter() + .filter(|row| row.agent_type == "devin") + .fold((0_i64, 0_i64, 0.0_f64), |totals, row| { + ( + totals.0.saturating_add(row.generated), + totals.1.saturating_add(row.cache), + totals.2 + row.cost, + ) + }); + let sessions = + queries::get_agent_session_count_since(connection, "devin", Some(since)) + .map_err(|error| error.to_string())?; + (sessions, generated, cache, cost) + } + None => ( + row.sessions, + row.real_input_tokens.saturating_add(row.output_tokens), + row.cache_read_tokens, + row.cost, + ), + }; + windows.push(DevinUsageWindow { + window: window.into(), + since, + sessions, + generated_tokens, + cache_read_tokens, + cost_usd, + models, + }); + } + let models = windows + .iter() + .find(|window| window.window == "all") + .map(|window| window.models.clone()) + .unwrap_or_default(); + let status = if has_row || !models.is_empty() { + "ready" + } else { + "empty" + }; + Ok(DevinUsageSummary { + status: status.into(), + source: "CodeVetter SQLite · indexed Devin sessions.db".into(), + sessions: row.sessions, + generated_tokens: row.real_input_tokens.saturating_add(row.output_tokens), + cache_read_tokens: row.cache_read_tokens, + output_tokens: row.output_tokens, + cost_usd: row.cost, + models, + windows, + limitations: vec![ + "Devin remains separate from ccusage totals.".into(), + "This local history is not live quota telemetry.".into(), + ], + }) +} + +fn project_devin_models( + connection: &Connection, + since: Option<&str>, + excluded_agents: &[String], +) -> Result, String> { + queries::get_usage_by_model( + connection, + super::history::estimate_cost, + since, + None, + None, + excluded_agents, + ) + .map_err(|error| error.to_string()) + .map(|rows| { + rows.into_iter() + .map(|model| DevinUsageModel { + model: model.model, + sessions: model.sessions, + generated_tokens: model.generated, + cache_read_tokens: model.cache, + cost_usd: model.cost, + }) + .collect() + }) +} + +async fn get_local_usage_report_for_roots( + roots: Vec, + refresh: bool, + timezone: Option<&str>, +) -> Result { + let timezone = normalize_timezone(timezone); let mut cache = cache().lock().await; - if !refresh.unwrap_or(false) { + if !refresh { if let (Some(report), Some(cached_at)) = (&cache.report, cache.cached_at) { if cached_at.elapsed() < CACHE_TTL && report.provenance.timezone == timezone { return Ok(report.clone()); @@ -285,7 +491,7 @@ pub async fn get_local_usage_report( } } -fn codex_roots(db: &State<'_, DbState>) -> Result, String> { +fn codex_roots(connection: Option<&Connection>) -> Result, String> { let mut roots = BTreeSet::new(); if let Ok(value) = std::env::var("CODEX_HOME") { roots.extend( @@ -306,15 +512,16 @@ fn codex_roots(db: &State<'_, DbState>) -> Result, String> { ); } } - let conn = db.0.lock().map_err(|error| error.to_string())?; - if let Ok(Some(raw)) = queries::get_preference(&conn, "codex_usage_import_roots") { - if let Ok(imports) = serde_json::from_str::>(&raw) { - roots.extend( - imports - .into_iter() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty() && !value.contains(',')), - ); + if let Some(connection) = connection { + if let Ok(Some(raw)) = queries::get_preference(connection, "codex_usage_import_roots") { + if let Ok(imports) = serde_json::from_str::>(&raw) { + roots.extend( + imports + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty() && !value.contains(',')), + ); + } } } Ok(roots.into_iter().collect()) @@ -577,6 +784,7 @@ fn normalize_report( monthly, sessions, totals, + devin: None, }) } @@ -681,6 +889,7 @@ fn unavailable_report( monthly: Vec::new(), sessions: Vec::new(), totals: LocalUsageTotals::default(), + devin: None, } } @@ -803,6 +1012,68 @@ mod tests { assert_eq!(report.totals, LocalUsageTotals::default()); assert!(report.daily.is_empty()); assert!(report.provenance.pricing_complete); + assert!(report.devin.is_none()); + } + + #[test] + fn projects_devin_as_a_separate_local_source() { + let connection = Connection::open_in_memory().unwrap(); + crate::db::schema::run_migrations(&connection).unwrap(); + connection + .execute_batch( + "INSERT INTO cc_projects (id, display_name, dir_path, created_at) + VALUES ('devin-project', 'Devin', '/fixture/devin', '2026-09-01T00:00:00Z'); + INSERT INTO cc_sessions ( + id, project_id, agent_type, model_used, total_input_tokens, + total_output_tokens, cache_read_tokens, cache_creation_tokens, + estimated_cost_usd, last_message + ) VALUES ( + 'devin-session', 'devin-project', 'devin', 'glm-5.2', 1200, + 300, 200, 100, 0.0042, '2026-09-01T00:00:00Z' + ); + INSERT INTO cc_sessions ( + id, project_id, agent_type, model_used, total_input_tokens, + total_output_tokens, cache_read_tokens, cache_creation_tokens, + estimated_cost_usd, last_message + ) VALUES ( + 'devin-old', 'devin-project', 'devin', 'glm-5.2', 600, + 100, 50, 0, 0.0020, '2026-05-01T00:00:00Z' + ); + INSERT INTO cc_session_days (session_id, day, msg_count) VALUES + ('devin-session', '2026-09-01', 10), + ('devin-old', '2026-05-01', 5);", + ) + .unwrap(); + + let today = NaiveDate::from_ymd_opt(2026, 9, 1).unwrap(); + let summary = project_devin_usage_at(&connection, today).unwrap(); + + assert_eq!(summary.status, "ready"); + assert_eq!(summary.sessions, 2); + assert_eq!(summary.generated_tokens, 1850); + assert_eq!(summary.cache_read_tokens, 250); + assert_eq!(summary.output_tokens, 400); + assert_eq!(summary.models[0].model, "glm-5.2"); + let week = summary + .windows + .iter() + .find(|window| window.window == "1w") + .unwrap(); + assert_eq!(week.since.as_deref(), Some("2026-08-26")); + assert_eq!(week.sessions, 1); + assert_eq!(week.generated_tokens, 1200); + assert_eq!(week.cache_read_tokens, 200); + let all = summary + .windows + .iter() + .find(|window| window.window == "all") + .unwrap(); + assert_eq!(all.sessions, 2); + assert_eq!(all.generated_tokens, summary.generated_tokens); + assert!(summary + .limitations + .iter() + .any(|limitation| limitation.contains("not live quota"))); } #[test] diff --git a/apps/desktop/src-tauri/src/commands/mcp_access.rs b/apps/desktop/src-tauri/src/commands/mcp_access.rs index 2201d670..096ee329 100644 --- a/apps/desktop/src-tauri/src/commands/mcp_access.rs +++ b/apps/desktop/src-tauri/src/commands/mcp_access.rs @@ -46,6 +46,26 @@ pub struct McpRepositorySettings { pub recent_audit: Vec, } +pub const MCP_SETTINGS_SCHEMA_VERSION: &str = "codevetter.mcp-settings/v1"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum McpSettingsOperation { + Read, + Enable, + Disable, + ClearAudit, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct McpSettingsReceipt { + pub schema_version: String, + pub generated_at: String, + pub operation: McpSettingsOperation, + pub cleared_audit_rows: usize, + pub settings: McpRepositorySettings, +} + pub fn canonical_repo_path(repo_path: &str) -> Result { Path::new(repo_path) .canonicalize() @@ -242,7 +262,7 @@ fn git_head(repo_path: &str) -> Option { .filter(|value| !value.is_empty()) } -fn load_mcp_repository_settings( +pub fn load_mcp_repository_settings( repo_path: String, db: &DbState, ) -> Result { @@ -355,7 +375,7 @@ pub async fn get_mcp_repository_settings( .map_err(|_| "MCP settings worker failed".to_string())? } -fn update_mcp_repository_enabled( +pub fn update_mcp_repository_enabled( repo_path: String, enabled: bool, db: &DbState, @@ -410,7 +430,7 @@ pub async fn set_mcp_repository_enabled( .map_err(|_| "MCP settings worker failed".to_string())? } -fn delete_mcp_access_audit(repo_path: String, db: &DbState) -> Result { +pub fn delete_mcp_access_audit(repo_path: String, db: &DbState) -> Result { let canonical = canonical_repo_path(&repo_path)?; let connection = db.0.lock() @@ -425,6 +445,29 @@ fn delete_mcp_access_audit(repo_path: String, db: &DbState) -> Result Result { + let (settings, cleared_audit_rows) = match operation { + McpSettingsOperation::Read => (load_mcp_repository_settings(repo_path, db)?, 0), + McpSettingsOperation::Enable => (update_mcp_repository_enabled(repo_path, true, db)?, 0), + McpSettingsOperation::Disable => (update_mcp_repository_enabled(repo_path, false, db)?, 0), + McpSettingsOperation::ClearAudit => { + let cleared = delete_mcp_access_audit(repo_path.clone(), db)?; + (load_mcp_repository_settings(repo_path, db)?, cleared) + } + }; + Ok(McpSettingsReceipt { + schema_version: MCP_SETTINGS_SCHEMA_VERSION.to_string(), + generated_at: Utc::now().to_rfc3339(), + operation, + cleared_audit_rows, + settings, + }) +} + #[tauri::command] pub async fn clear_mcp_access_audit( repo_path: String, diff --git a/apps/desktop/src-tauri/src/commands/mcp_access/tests.rs b/apps/desktop/src-tauri/src/commands/mcp_access/tests.rs index 26b09f2a..d3c38aec 100644 --- a/apps/desktop/src-tauri/src/commands/mcp_access/tests.rs +++ b/apps/desktop/src-tauri/src/commands/mcp_access/tests.rs @@ -134,3 +134,43 @@ fn settings_preview_creates_a_stable_disabled_scope() { assert_eq!(first.client_config, second.client_config); assert!(first.client_config.is_some()); } + +#[test] +fn shared_mcp_settings_receipt_preserves_scope_and_authority_transitions() { + let fixture = tempfile::tempdir().expect("fixture"); + let repo = fixture.path().join("repo"); + std::fs::create_dir(&repo).expect("repo"); + let repo_path = repo + .canonicalize() + .expect("canonical repo") + .to_string_lossy() + .to_string(); + let connection = Connection::open(fixture.path().join("codevetter.db")).expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + connection + .execute( + "INSERT INTO history_graph_repositories ( + repo_path, repository_fingerprint, indexed_head, status, + created_at, updated_at + ) VALUES (?1, 'fixture', 'indexed-head', 'ready', ?2, ?2)", + params![repo_path, Utc::now().to_rfc3339()], + ) + .expect("history"); + let db = DbState(Arc::new(Mutex::new(connection))); + + let read = run_mcp_settings_operation(repo_path.clone(), McpSettingsOperation::Read, &db) + .expect("read"); + assert_eq!(read.schema_version, MCP_SETTINGS_SCHEMA_VERSION); + assert!(!read.settings.enabled); + assert!(read.settings.client_config.is_some()); + + let enabled = run_mcp_settings_operation(repo_path.clone(), McpSettingsOperation::Enable, &db) + .expect("enable"); + assert!(enabled.settings.enabled); + assert_eq!(enabled.settings.repo_id, read.settings.repo_id); + + let disabled = + run_mcp_settings_operation(repo_path, McpSettingsOperation::Disable, &db).expect("disable"); + assert!(!disabled.settings.enabled); + assert_eq!(disabled.settings.repo_id, read.settings.repo_id); +} diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 76a5152a..ee8e79dd 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -10,12 +10,15 @@ pub mod business_rule_archaeology; pub mod cli_install; pub mod cli_stream; pub mod codex_app_server; +pub mod cross_review; pub mod deterministic_review; pub mod differential_verification; pub mod dora; pub mod evidence_pattern; pub mod evidence_scope; pub mod files; +pub mod fix_attempt; +pub mod fix_packet; pub mod git; pub mod git_metadata; pub mod graph_trust; @@ -24,6 +27,7 @@ pub mod history_evidence; pub mod history_graph; pub mod history_query; pub mod history_read; +pub mod history_roots; pub mod history_summary_graph; pub mod intel; pub mod local_check; @@ -32,16 +36,24 @@ pub mod local_usage; pub mod managed_work; pub mod mcp_access; pub mod native_agent_island; +pub mod native_settings; pub mod observability; +pub mod onboarding; +pub mod ops_status; pub(crate) mod outcome_risk_calibration; #[cfg(test)] mod perf_bench; pub mod performance_bridge; pub mod preferences; pub mod procedure_events; +pub mod qa_workspace; +pub mod repo_query; pub mod repo_workspace; pub mod resources; pub mod review; +pub mod review_intent; +pub mod rubric_settings; +pub mod run_history; pub mod sandbox; pub mod scenario_compiler_bridge; pub(crate) mod secret_policy; @@ -53,6 +65,7 @@ pub mod spec_coverage; pub mod structural_graph; pub mod synthetic_qa; pub mod taste; +pub mod tool_collectors; pub mod trex_preview; pub mod trex_watcher; pub mod unpack; diff --git a/apps/desktop/src-tauri/src/commands/observability.rs b/apps/desktop/src-tauri/src/commands/observability.rs index 54ae62d0..e3a9bd87 100644 --- a/apps/desktop/src-tauri/src/commands/observability.rs +++ b/apps/desktop/src-tauri/src/commands/observability.rs @@ -11,7 +11,7 @@ use std::time::Duration; -use rusqlite::params; +use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; use tauri::State; @@ -91,10 +91,15 @@ pub struct SendNotificationInput { #[tauri::command] pub async fn get_billing_config(db: State<'_, DbState>) -> Result { - Ok(BillingConfig { - anthropic_configured: read_pref(&db, PREF_ANTHROPIC_ADMIN).is_some(), - openai_configured: read_pref(&db, PREF_OPENAI_ADMIN).is_some(), - }) + let conn = db.0.lock().map_err(|error| error.to_string())?; + Ok(billing_config_from_connection(&conn)) +} + +pub fn billing_config_from_connection(conn: &Connection) -> BillingConfig { + BillingConfig { + anthropic_configured: read_pref_from_connection(conn, PREF_ANTHROPIC_ADMIN).is_some(), + openai_configured: read_pref_from_connection(conn, PREF_OPENAI_ADMIN).is_some(), + } } #[tauri::command] @@ -361,8 +366,15 @@ pub async fn get_agent_observability( db: State<'_, DbState>, window_days: Option, ) -> Result { + let conn = db.0.lock().map_err(|error| error.to_string())?; + Ok(agent_observability_from_connection(&conn, window_days)) +} + +pub fn agent_observability_from_connection( + conn: &Connection, + window_days: Option, +) -> AgentObservability { let window = window_days.unwrap_or(30); - let conn = db.0.lock().map_err(|e| e.to_string())?; let mut rows: Vec = Vec::new(); // ── Reviews (status, duration from started_at→completed_at). @@ -504,26 +516,32 @@ pub async fn get_agent_observability( } } - Ok(AgentObservability { + AgentObservability { rows, window_days: window, - }) + } } // ─── Webhook notifications ────────────────────────────────────────────────── #[tauri::command] pub async fn get_webhook_config(db: State<'_, DbState>) -> Result { - let url = read_pref(&db, PREF_NOTIF_WEBHOOK); - let flavor = read_pref(&db, PREF_NOTIF_FLAVOR).unwrap_or_else(|| "slack".to_string()); - Ok(WebhookConfig { + let conn = db.0.lock().map_err(|error| error.to_string())?; + Ok(webhook_config_from_connection(&conn)) +} + +pub fn webhook_config_from_connection(conn: &Connection) -> WebhookConfig { + let url = read_pref_from_connection(conn, PREF_NOTIF_WEBHOOK); + let flavor = + read_pref_from_connection(conn, PREF_NOTIF_FLAVOR).unwrap_or_else(|| "slack".to_string()); + WebhookConfig { configured: url.is_some(), url_preview: url.as_ref().map(|u| { let head: String = u.chars().take(40).collect(); format!("{head}…") }), flavor, - }) + } } #[tauri::command] @@ -636,6 +654,10 @@ fn severity_color(sev: &str) -> i64 { fn read_pref(db: &State<'_, DbState>, key: &str) -> Option { let conn = db.0.lock().ok()?; + read_pref_from_connection(&conn, key) +} + +fn read_pref_from_connection(conn: &Connection, key: &str) -> Option { conn.query_row( "SELECT value FROM preferences WHERE key = ?1", params![key], diff --git a/apps/desktop/src-tauri/src/commands/performance_bridge.rs b/apps/desktop/src-tauri/src/commands/performance_bridge.rs index e2e44511..765105ad 100644 --- a/apps/desktop/src-tauri/src/commands/performance_bridge.rs +++ b/apps/desktop/src-tauri/src/commands/performance_bridge.rs @@ -4,7 +4,7 @@ //! argument arrays. The Node runtime remains the single source of truth for //! planning, profiling, diagnosis, and paired verification contracts. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Component, Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; @@ -13,6 +13,7 @@ use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use sysinfo::{Pid, ProcessesToUpdate, System}; use tauri::{AppHandle, Emitter, Manager, State}; use tokio::io::AsyncReadExt; use tokio::process::Command; @@ -88,6 +89,16 @@ pub struct PerformanceCleanupReceipt { pub temporary_profiles_retained: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct PerformanceResourceReceipt { + pub sampler: Option, + pub sample_interval_ms: u64, + pub samples: u32, + pub peak_rss_bytes: Option, + pub peak_processes: Option, + pub limitations: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PerformanceRunReceipt { pub schema_version: u32, @@ -99,6 +110,87 @@ pub struct PerformanceRunReceipt { pub result: Value, pub stderr_summary: Option, pub cleanup: PerformanceCleanupReceipt, + #[serde(default)] + pub resources: PerformanceResourceReceipt, +} + +struct PerformanceResourceSampler { + root_pid: Option, + system: System, + samples: u32, + peak_rss_bytes: u64, + peak_processes: u32, +} + +impl PerformanceResourceSampler { + fn new(root_pid: Option) -> Self { + Self { + root_pid: root_pid.map(Pid::from_u32), + system: System::new(), + samples: 0, + peak_rss_bytes: 0, + peak_processes: 0, + } + } + + fn sample(&mut self) { + let Some(root_pid) = self.root_pid else { + return; + }; + self.system.refresh_processes(ProcessesToUpdate::All, true); + let process_ids = owned_process_tree(&self.system, root_pid); + if process_ids.is_empty() { + return; + } + let rss_bytes = process_ids + .iter() + .filter_map(|pid| self.system.process(*pid)) + .map(|process| process.memory()) + .sum(); + self.samples = self.samples.saturating_add(1); + self.peak_rss_bytes = self.peak_rss_bytes.max(rss_bytes); + self.peak_processes = self + .peak_processes + .max(process_ids.len().try_into().unwrap_or(u32::MAX)); + } + + fn receipt(self) -> PerformanceResourceReceipt { + let sampled = self.samples > 0; + PerformanceResourceReceipt { + sampler: sampled.then(|| "sysinfo_owned_process_tree".to_string()), + sample_interval_ms: 75, + samples: self.samples, + peak_rss_bytes: sampled.then_some(self.peak_rss_bytes), + peak_processes: sampled.then_some(self.peak_processes), + limitations: vec![if sampled { + "RSS and process counts are periodic owned-process-tree samples; short-lived peaks between samples may be missed." + .to_string() + } else { + "Owned-process resource sampling was unavailable for this run.".to_string() + }], + } + } +} + +fn owned_process_tree(system: &System, root_pid: Pid) -> HashSet { + if system.process(root_pid).is_none() { + return HashSet::new(); + } + let mut process_ids = HashSet::from([root_pid]); + loop { + let before = process_ids.len(); + for (pid, process) in system.processes() { + if process + .parent() + .is_some_and(|parent| process_ids.contains(&parent)) + { + process_ids.insert(*pid); + } + } + if process_ids.len() == before { + return process_ids; + } + } } #[derive(Debug, Clone, Serialize)] @@ -131,8 +223,9 @@ pub async fn run_local_performance( registry: registry.inner(), request_id: validated.request_id.clone(), }; + let cli_path = resolve_cli_path(&app)?; emit_progress(&app, &validated, "started"); - let receipt = execute(&app, &validated, cancellation).await; + let receipt = execute(&validated, cancellation, cli_path).await; emit_progress( &app, &validated, @@ -148,6 +241,14 @@ pub async fn run_local_performance( receipt } +pub async fn run_headless_performance( + input: PerformanceRunInput, +) -> Result { + let validated = validate_input(input)?; + let cli_path = resolve_headless_cli_path()?; + execute(&validated, Arc::new(AtomicBool::new(false)), cli_path).await +} + #[tauri::command] pub fn cancel_local_performance( registry: State<'_, PerformanceRunRegistry>, @@ -190,12 +291,11 @@ fn emit_progress(app: &AppHandle, input: &PerformanceRunInput, stage: &'static s } async fn execute( - app: &AppHandle, input: &PerformanceRunInput, cancellation: Arc, + cli_path: PathBuf, ) -> Result { let started = Instant::now(); - let cli_path = resolve_cli_path(app)?; let args = build_arguments(input)?; ensure_node_available().await?; @@ -212,6 +312,7 @@ async fn execute( let mut child = command .spawn() .map_err(|error| format!("Could not start the local performance runtime: {error}"))?; + let mut resource_sampler = PerformanceResourceSampler::new(child.id()); let stdout = child .stdout @@ -227,6 +328,7 @@ async fn execute( let deadline = tokio::time::Instant::now() + overall_timeout; let mut cancelled = false; let status = loop { + resource_sampler.sample(); if cancellation.load(Ordering::SeqCst) { cancelled = true; child @@ -247,6 +349,7 @@ async fn execute( })?; let stdout_bytes = stdout_task.await.map_err(join_error)??; let stderr_bytes = stderr_task.await.map_err(join_error)??; + let resources = resource_sampler.receipt(); return Ok(no_confidence_receipt( input, started, @@ -254,6 +357,7 @@ async fn execute( "The bounded desktop performance operation timed out.", &stderr_bytes, Some(&stdout_bytes), + resources, )); } if let Some(status) = child @@ -267,6 +371,7 @@ async fn execute( let stdout_bytes = stdout_task.await.map_err(join_error)??; let stderr_bytes = stderr_task.await.map_err(join_error)??; + let resources = resource_sampler.receipt(); if cancelled { return Ok(PerformanceRunReceipt { schema_version: 1, @@ -282,6 +387,7 @@ async fn execute( }), stderr_summary: sanitize_summary(&stderr_bytes, &input.repo_path), cleanup: cleanup_receipt(), + resources, }); } @@ -291,6 +397,7 @@ async fn execute( elapsed_ms(started), &stdout_bytes, &stderr_bytes, + resources, ) } @@ -300,6 +407,7 @@ fn receipt_from_output( duration_ms: u64, stdout: &[u8], stderr: &[u8], + resources: PerformanceResourceReceipt, ) -> Result { let mut result: Value = serde_json::from_slice(stdout).map_err(|_| { "The local performance runtime returned malformed or excessive output".to_string() @@ -320,6 +428,7 @@ fn receipt_from_output( result, stderr_summary: sanitize_summary(stderr, &input.repo_path), cleanup: cleanup_receipt(), + resources, }) } @@ -525,12 +634,8 @@ fn build_arguments(input: &PerformanceRunInput) -> Result, String> { } fn resolve_cli_path(app: &AppHandle) -> Result { - let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../../scripts/runtime-failure-capsule/cli.mjs"); - if source.is_file() { - return source - .canonicalize() - .map_err(|error| format!("Could not resolve the performance runtime: {error}")); + if let Ok(source) = resolve_source_cli_path() { + return Ok(source); } let bundled = app .path() @@ -543,6 +648,34 @@ fn resolve_cli_path(app: &AppHandle) -> Result { Ok(bundled) } +fn resolve_headless_cli_path() -> Result { + if let Ok(source) = resolve_source_cli_path() { + return Ok(source); + } + let executable = std::env::current_exe() + .map_err(|error| format!("Could not resolve the CodeVetter executable: {error}"))?; + let bundled = executable + .parent() + .and_then(Path::parent) + .map(|contents| contents.join("Resources/runtime-failure-capsule/cli.mjs")) + .ok_or_else(|| "The packaged local performance runtime is unavailable".to_string())?; + if !bundled.is_file() { + return Err("The packaged local performance runtime is unavailable".to_string()); + } + Ok(bundled) +} + +fn resolve_source_cli_path() -> Result { + let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../scripts/runtime-failure-capsule/cli.mjs"); + if !source.is_file() { + return Err("The source performance runtime is unavailable".to_string()); + } + source + .canonicalize() + .map_err(|error| format!("Could not resolve the performance runtime: {error}")) +} + async fn ensure_node_available() -> Result<(), String> { let status = Command::new("node") .arg("--version") @@ -575,6 +708,7 @@ fn no_confidence_receipt( message: &str, stderr: &[u8], stdout: Option<&[u8]>, + resources: PerformanceResourceReceipt, ) -> PerformanceRunReceipt { PerformanceRunReceipt { schema_version: 1, @@ -591,6 +725,7 @@ fn no_confidence_receipt( }), stderr_summary: sanitize_summary(stderr, &input.repo_path), cleanup: cleanup_receipt(), + resources, } } @@ -814,15 +949,40 @@ mod tests { "limitations": ["Exact fixture scope only."] }); let stdout = serde_json::to_vec(&runtime_result).unwrap(); - let receipt = receipt_from_output(&validated, Some(0), 17, &stdout, b"").unwrap(); + let resources = PerformanceResourceReceipt { + sampler: Some("fixture".into()), + sample_interval_ms: 75, + samples: 2, + peak_rss_bytes: Some(1_048_576), + peak_processes: Some(2), + limitations: vec!["Fixture sample.".into()], + }; + let receipt = + receipt_from_output(&validated, Some(0), 17, &stdout, b"", resources.clone()).unwrap(); assert_eq!(receipt.result, runtime_result); assert_eq!(receipt.operation, PerformanceOperation::Plan); assert_eq!(receipt.state, "succeeded"); assert!(receipt.cleanup.owned_process_reaped); - assert!( - receipt_from_output(&validated, Some(0), 0, b"not-json", b"") - .unwrap_err() - .contains("malformed") - ); + assert_eq!(receipt.resources.peak_rss_bytes, Some(1_048_576)); + assert!(receipt_from_output( + &validated, + Some(0), + 0, + b"not-json", + b"", + PerformanceResourceReceipt::default(), + ) + .unwrap_err() + .contains("malformed")); + } + + #[test] + fn resource_sampler_records_the_owned_process_tree() { + let mut sampler = PerformanceResourceSampler::new(Some(std::process::id())); + sampler.sample(); + let receipt = sampler.receipt(); + assert!(receipt.samples >= 1); + assert!(receipt.peak_rss_bytes.is_some_and(|bytes| bytes > 0)); + assert!(receipt.peak_processes.is_some_and(|count| count >= 1)); } } diff --git a/apps/desktop/src-tauri/src/commands/repo_workspace.rs b/apps/desktop/src-tauri/src/commands/repo_workspace.rs index efbc48de..bfe917a1 100644 --- a/apps/desktop/src-tauri/src/commands/repo_workspace.rs +++ b/apps/desktop/src-tauri/src/commands/repo_workspace.rs @@ -4,7 +4,7 @@ use crate::commands::dora; use crate::commands::intel; use crate::commands::unpack; use crate::commands::unpack_scan::{emit_unpack_scan_progress, ScanProgress, ScanProgressCallback}; -use crate::commands::unpack_scan_profile::{emit_unpack_scan_profile, UnpackScanProfiler}; +use crate::commands::unpack_scan_profile::emit_unpack_scan_profile; use crate::DbState; use serde::{Deserialize, Serialize}; use std::process::Command as StdCommand; @@ -76,15 +76,6 @@ fn display_name_from_path(repo_path: &str) -> String { .to_string() } -fn touch_unpack_at(conn: &rusqlite::Connection, repo_path: &str, at: &str) -> Result<(), String> { - conn.execute( - "UPDATE repo_projects SET last_unpack_at = ?2 WHERE repo_path = ?1", - rusqlite::params![repo_path, at], - ) - .map_err(|e| e.to_string())?; - Ok(()) -} - fn touch_intel_at(conn: &rusqlite::Connection, repo_path: &str, at: &str) -> Result<(), String> { conn.execute( "UPDATE repo_projects SET last_intel_at = ?2 WHERE repo_path = ?1", @@ -312,57 +303,37 @@ pub async fn save_unpack_scan_snapshot( .await .map_err(|e| format!("inventory scan task join error: {e}"))??; - let inventory = build.inventory; - emit_unpack_scan_profile(&app, &report_id, &inventory.repo_path, &build.profile); - - let mut persist_profiler = UnpackScanProfiler::new("local_scan_persist"); + let repo_path = build.inventory.repo_path.clone(); + let files_scanned = build.inventory.files_scanned; + let needs_enrichment = unpack::inventory_needs_enrichment(&build.inventory); + emit_unpack_scan_profile(&app, &report_id, &repo_path, &build.profile); emit_unpack_scan_progress( &app, &report_id, - &inventory.repo_path, - &format!("Saved snapshot · {} files scanned", inventory.files_scanned), - inventory.files_scanned, + &repo_path, + &format!("Saving snapshot · {files_scanned} files scanned"), + files_scanned, ); - let inventory_json = serde_json::to_string(&inventory).map_err(|e| e.to_string())?; - persist_profiler.step("serialize", "JSON serialize (inventory → SQLite)"); - let now = chrono::Utc::now().to_rfc3339(); - let conn = conn_lock(&db)?; + let receipt = + unpack::persist_unpack_scan_snapshot_from_connection(&conn, report_id.clone(), build)?; + drop(conn); + for profile in &receipt.profiles { + if profile.stage == "local_scan_persist" { + emit_unpack_scan_profile(&app, &report_id, &repo_path, profile); + } + } + emit_unpack_scan_progress( + &app, + &report_id, + &repo_path, + &format!("Saved snapshot · {files_scanned} files scanned"), + files_scanned, + ); - crate::db::with_busy_retry( - || { - conn.execute( - "INSERT INTO repo_unpacked_reports - (id, repo_path, repo_name, commit_sha, status, inventory_json, - files_scanned, files_skipped, bytes_scanned, started_at, completed_at, created_at) - VALUES (?1, ?2, ?3, ?4, 'scan_only', ?5, ?6, ?7, ?8, ?9, ?9, ?9)", - rusqlite::params![ - report_id, - inventory.repo_path, - inventory.repo_name, - inventory.commit_sha, - inventory_json, - inventory.files_scanned as i64, - inventory.files_skipped as i64, - inventory.bytes_scanned as i64, - now, - ], - ) - }, - 15, - ) - .map_err(|e| e.to_string())?; - persist_profiler.step("db_insert", "SQLite insert"); - - touch_unpack_at(&conn, &inventory.repo_path, &now)?; - persist_profiler.step("touch_project", "Update repo project metadata"); - - let persist_profile = persist_profiler.finish(); - emit_unpack_scan_profile(&app, &report_id, &inventory.repo_path, &persist_profile); - - if unpack::inventory_needs_enrichment(&inventory) { + if needs_enrichment { let db_arc = db.0.clone(); let app_bg = app.clone(); let report_id_bg = report_id.clone(); @@ -375,13 +346,7 @@ pub async fn save_unpack_scan_snapshot( }); } - Ok(serde_json::json!({ - "report_id": report_id, - "status": "scan_only", - "inventory": unpack::trim_inventory_for_client(inventory), - "created_at": now, - "profiles": [build.profile, persist_profile], - })) + serde_json::to_value(receipt).map_err(|error| error.to_string()) } fn truncate_scan_path(path: &str) -> String { diff --git a/apps/desktop/src-tauri/src/commands/review.rs b/apps/desktop/src-tauri/src/commands/review.rs index e4feff91..0f6c2da8 100644 --- a/apps/desktop/src-tauri/src/commands/review.rs +++ b/apps/desktop/src-tauri/src/commands/review.rs @@ -89,7 +89,7 @@ pub async fn cancel_cli_review(repo_path: String) -> Result { /// usual user-install locations (asdf shims, bun, pnpm, npm global, homebrew, /// `~/.local/bin`) and returns the first match. Falls back to the bare name /// so the existing PATH lookup still runs if none match. -fn resolve_cli_path(name: &str) -> String { +pub(crate) fn resolve_cli_path(name: &str) -> String { // First, honor PATH if it works if let Ok(path_var) = std::env::var("PATH") { for dir in std::env::split_paths(&path_var) { @@ -2580,6 +2580,15 @@ pub async fn run_cli_review_core( context_delivery: "internal".to_string(), limitations: readiness_limitations, }; + let intent_diagnostic = crate::commands::review_intent::build_review_intent_diagnostic( + &change_description, + &changed_files, + &findings_val, + &qa_runs, + review_manifest.complete_coverage, + ); + let intent_diagnostic_json = + serde_json::to_value(&intent_diagnostic).unwrap_or_else(|_| json!({})); let summary = parsed .get("summary") @@ -2750,6 +2759,7 @@ pub async fn run_cli_review_core( "evidence_procedure_steps": evidence_procedure_steps_json.clone(), "coordinator_failed": coordinator_failed, "review_readiness": review_readiness, + "intent_diagnostic": intent_diagnostic_json.clone(), "review_manifest": review_manifest.clone(), }) .to_string(), @@ -2796,6 +2806,7 @@ pub async fn run_cli_review_core( "coordinator_used": plan.uses_coordinator, "review_status": review_status, "review_readiness": review_readiness, + "intent_diagnostic": intent_diagnostic_json, "review_memory_graph": review_memory_graph_json, "trusted_graph_context": trusted_graph_context_json, "qa_evidence": qa_evidence_json, @@ -3495,9 +3506,9 @@ mod tests { use super::*; /// Generate CodeVetter's public-benchmark comparator outputs by running - /// every `benchmark/cases//` through the REAL production review + /// every `benchmarks/public-catch-rate/cases//` through the REAL production review /// pipeline (risk tiers, specialists, coordinator, dedup) headlessly. - /// Raw pipeline output lands in `benchmark/reviews-raw/.codevetter.raw.json`; + /// Raw pipeline output lands in `benchmarks/public-catch-rate/reviews-raw/.codevetter.raw.json`; /// ground-truth mapping is a separate, human-checked step. Requires the /// `claude` CLI on PATH and burns real quota — hence ignored. #[test] @@ -4173,6 +4184,10 @@ mod tests { #[tokio::test] async fn review_executor_is_bounded_and_rejects_malformed_output() { let temp = tempfile::tempdir().expect("temp"); + // This test validates parsing and byte bounds, not the timeout path. + // Leave enough process-start budget when the full filesystem-heavy + // suite runs concurrently on CI; timeout behavior has a separate test. + let fixture_timeout = Duration::from_secs(5); let valid = executable_script( &temp, "valid", @@ -4183,7 +4198,7 @@ mod tests { "claude", temp.path().to_string_lossy().into_owned(), "review".into(), - Duration::from_secs(1), + fixture_timeout, 1024, ) .await @@ -4196,7 +4211,7 @@ mod tests { "claude", temp.path().to_string_lossy().into_owned(), "review".into(), - Duration::from_secs(1), + fixture_timeout, 1024, ) .await @@ -4209,7 +4224,7 @@ mod tests { "claude", temp.path().to_string_lossy().into_owned(), "review".into(), - Duration::from_secs(1), + fixture_timeout, 64, ) .await diff --git a/apps/desktop/src-tauri/src/commands/sandbox.rs b/apps/desktop/src-tauri/src/commands/sandbox.rs index 2da9570d..2ca5a701 100644 --- a/apps/desktop/src-tauri/src/commands/sandbox.rs +++ b/apps/desktop/src-tauri/src/commands/sandbox.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::sync::Arc; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; @@ -158,14 +159,33 @@ pub async fn run_branch_sandbox_inner( app: AppHandle, db: &DbState, input: SandboxRunInput, +) -> Result { + let emitter: SandboxStepEmitter = Arc::new(move |step| { + let _ = app.emit(STEP_EVENT, step); + }); + run_branch_sandbox_with_emitter(db, input, emitter).await +} + +/// Headless sandbox entry point for the CLI/native bridge. The canonical +/// sandbox logic remains Rust-owned; only transient Tauri progress events are +/// omitted when no webview is present. +pub async fn run_branch_sandbox_headless( + db: &DbState, + input: SandboxRunInput, +) -> Result { + run_branch_sandbox_with_emitter(db, input, Arc::new(|_| {})).await +} + +type SandboxStepEmitter = Arc; + +async fn run_branch_sandbox_with_emitter( + db: &DbState, + input: SandboxRunInput, + emit: SandboxStepEmitter, ) -> Result { let started = Instant::now(); let run_id = uuid::Uuid::new_v4().to_string(); - let emit = |s: SandboxStep| { - let _ = app.emit(STEP_EVENT, s); - }; - emit(SandboxStep::Phase { phase: "setup".into(), detail: Some(format!("branch={}", input.branch)), @@ -195,7 +215,7 @@ pub async fn run_branch_sandbox_inner( phase: "install".into(), detail: Some("npm install".into()), }); - if let Err(e) = run_npm_install(&worktree_path).await { + if let Err(e) = run_node_install(&worktree_path).await { let _ = remove_worktree(&input.repo_path, &worktree_path); return Ok(failed_result( run_id, @@ -261,9 +281,9 @@ pub async fn run_branch_sandbox_inner( project_dir: None, // server is already up; don't re-launch }; let brain = CliBrain::new(input.options.provider.clone(), None); - let app_clone = app.clone(); + let emit_agent = emit.clone(); let result = run_with_brain(agent_input, brain, move |step| { - let _ = app_clone.emit(STEP_EVENT, SandboxStep::Agent { step: step.clone() }); + emit_agent(SandboxStep::Agent { step: step.clone() }); }) .await; match result { @@ -442,15 +462,113 @@ async fn has_node_modules(dir: &Path) -> bool { tokio::fs::metadata(dir.join("node_modules")).await.is_ok() } -async fn run_npm_install(dir: &Path) -> Result<(), String> { - let out = Command::new("npm") - .args(["install", "--no-audit", "--no-fund", "--prefer-offline"]) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NodePackageManager { + Pnpm, + Npm, + YarnBerry, + YarnClassic, + Bun, +} + +impl NodePackageManager { + fn run_script(self, script: &str) -> String { + match (self, script) { + (Self::Pnpm, "test") => "pnpm test".to_string(), + (Self::Npm, "test") => "npm test --silent".to_string(), + (Self::YarnBerry | Self::YarnClassic, "test") => "yarn test".to_string(), + (Self::Bun, "test") => "bun test".to_string(), + (Self::Pnpm, _) => format!("pnpm run {script}"), + (Self::Npm, _) => format!("npm run {script} --silent"), + (Self::YarnBerry | Self::YarnClassic, _) => format!("yarn {script}"), + (Self::Bun, _) => format!("bun run {script}"), + } + } +} + +async fn detect_node_package_manager( + dir: &Path, + package_json: Option<&Value>, +) -> NodePackageManager { + if let Some(name) = package_json + .and_then(|value| value.get("packageManager")) + .and_then(Value::as_str) + .and_then(|value| value.split('@').next()) + { + match name { + "pnpm" => return NodePackageManager::Pnpm, + "yarn" => { + return if tokio::fs::metadata(dir.join(".yarnrc.yml")).await.is_ok() { + NodePackageManager::YarnBerry + } else { + NodePackageManager::YarnClassic + }; + } + "bun" => return NodePackageManager::Bun, + "npm" => return NodePackageManager::Npm, + _ => {} + } + } + if tokio::fs::metadata(dir.join("pnpm-lock.yaml")) + .await + .is_ok() + { + NodePackageManager::Pnpm + } else if tokio::fs::metadata(dir.join("yarn.lock")).await.is_ok() { + if tokio::fs::metadata(dir.join(".yarnrc.yml")).await.is_ok() { + NodePackageManager::YarnBerry + } else { + NodePackageManager::YarnClassic + } + } else if tokio::fs::metadata(dir.join("bun.lock")).await.is_ok() + || tokio::fs::metadata(dir.join("bun.lockb")).await.is_ok() + { + NodePackageManager::Bun + } else { + NodePackageManager::Npm + } +} + +async fn run_node_install(dir: &Path) -> Result<(), String> { + let package_json = tokio::fs::read_to_string(dir.join("package.json")) + .await + .ok() + .and_then(|contents| serde_json::from_str::(&contents).ok()); + let manager = detect_node_package_manager(dir, package_json.as_ref()).await; + let (program, args): (&str, Vec<&str>) = match manager { + NodePackageManager::Pnpm => ( + "pnpm", + vec!["install", "--frozen-lockfile", "--prefer-offline"], + ), + NodePackageManager::Npm + if tokio::fs::metadata(dir.join("package-lock.json")) + .await + .is_ok() => + { + ( + "npm", + vec!["ci", "--no-audit", "--no-fund", "--prefer-offline"], + ) + } + NodePackageManager::Npm => ( + "npm", + vec!["install", "--no-audit", "--no-fund", "--prefer-offline"], + ), + NodePackageManager::YarnBerry => ("yarn", vec!["install", "--immutable"]), + NodePackageManager::YarnClassic => ("yarn", vec!["install", "--frozen-lockfile"]), + NodePackageManager::Bun => ("bun", vec!["install", "--frozen-lockfile"]), + }; + let out = Command::new(program) + .args(&args) .current_dir(dir) .output() .await - .map_err(|e| format!("spawn npm install: {e}"))?; + .map_err(|e| format!("spawn {program} install: {e}"))?; if !out.status.success() { - return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + return Err(format!( + "{program} install failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); } Ok(()) } @@ -461,19 +579,22 @@ const TEST_TIMEOUT_SECS: u64 = 600; const LOG_TAIL_BYTES: usize = 8 * 1024; pub(crate) async fn discover_test_command(dir: &Path) -> Option { - // 1) package.json scripts.test wins. + // 1) Prefer the repository's declared package manager and strongest + // repository-owned verification script. The closed order avoids running + // arbitrary package scripts selected from untrusted names. if let Ok(contents) = tokio::fs::read_to_string(dir.join("package.json")).await { if let Ok(v) = serde_json::from_str::(&contents) { - if let Some(script) = v - .get("scripts") - .and_then(|s| s.get("test")) - .and_then(|t| t.as_str()) - { - if !script.trim().is_empty() - && !script.contains("Error: no test specified") - && !script.contains("echo \"Error: no test") - { - return Some("npm test --silent".to_string()); + let manager = detect_node_package_manager(dir, Some(&v)).await; + if let Some(scripts) = v.get("scripts").and_then(Value::as_object) { + for script_name in ["test", "test:unit", "check", "lint", "typecheck"] { + if let Some(script) = scripts.get(script_name).and_then(Value::as_str) { + if !script.trim().is_empty() + && !script.contains("Error: no test specified") + && !script.contains("echo \"Error: no test") + { + return Some(manager.run_script(script_name)); + } + } } } } @@ -902,6 +1023,69 @@ mod tests { cleanup(&dir); } + #[tokio::test] + async fn discovers_pnpm_workspace_safe_check_without_a_test_alias() { + let dir = tempdir(); + tokio::fs::write( + dir.join("package.json"), + r#"{ + "name":"workspace", + "packageManager":"pnpm@10.33.2", + "scripts": { "verify": "node verify-cli.mjs", "lint": "biome check ." } + }"#, + ) + .await + .unwrap(); + tokio::fs::write(dir.join("pnpm-lock.yaml"), "lockfileVersion: '9.0'\n") + .await + .unwrap(); + assert_eq!( + discover_test_command(&dir).await.as_deref(), + Some("pnpm run lint") + ); + cleanup(&dir); + } + + #[tokio::test] + async fn explicit_test_script_precedes_generic_workspace_checks() { + let dir = tempdir(); + tokio::fs::write( + dir.join("package.json"), + r#"{ + "name":"workspace", + "packageManager":"pnpm@10.33.2", + "scripts": { "test": "vitest run", "lint": "biome check ." } + }"#, + ) + .await + .unwrap(); + assert_eq!( + discover_test_command(&dir).await.as_deref(), + Some("pnpm test") + ); + cleanup(&dir); + } + + #[tokio::test] + async fn unit_test_alias_precedes_static_checks() { + let dir = tempdir(); + tokio::fs::write( + dir.join("package.json"), + r#"{ + "name":"workspace", + "packageManager":"pnpm@10.33.2", + "scripts": { "test:unit": "vitest run", "lint": "biome check ." } + }"#, + ) + .await + .unwrap(); + assert_eq!( + discover_test_command(&dir).await.as_deref(), + Some("pnpm run test:unit") + ); + cleanup(&dir); + } + #[tokio::test] async fn skips_default_npm_init_test_placeholder() { let dir = tempdir(); diff --git a/apps/desktop/src-tauri/src/commands/scenario_compiler_bridge.rs b/apps/desktop/src-tauri/src/commands/scenario_compiler_bridge.rs index 20e86d1f..fa73890d 100644 --- a/apps/desktop/src-tauri/src/commands/scenario_compiler_bridge.rs +++ b/apps/desktop/src-tauri/src/commands/scenario_compiler_bridge.rs @@ -45,22 +45,22 @@ pub enum ScenarioCompilerAction { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct ContextSelection { - capabilities: Vec, - auth_profiles: Vec, - states: Vec, - routes: Vec, - include_request_policy: bool, - examples: Vec, + pub capabilities: Vec, + pub auth_profiles: Vec, + pub states: Vec, + pub routes: Vec, + pub include_request_policy: bool, + pub examples: Vec, } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct ProviderSelection { - kind: String, - provider: String, - model: String, - cost_class: String, - paid_approved: bool, + pub kind: String, + pub provider: String, + pub model: String, + pub cost_class: String, + pub paid_approved: bool, } #[derive(Debug, Deserialize, Serialize)] @@ -160,6 +160,13 @@ pub struct ScenarioCompilerActionResult { pub async fn run_scenario_compiler_action( repo_path: String, action: ScenarioCompilerAction, +) -> Result { + run_scenario_compiler_action_headless(repo_path, action).await +} + +pub async fn run_scenario_compiler_action_headless( + repo_path: String, + action: ScenarioCompilerAction, ) -> Result { let (arguments, expected_action, deadline) = action_arguments(&action)?; let references = arguments.iter().map(String::as_str).collect::>(); diff --git a/apps/desktop/src-tauri/src/commands/session_retention.rs b/apps/desktop/src-tauri/src/commands/session_retention.rs index a3e8dab4..4b7b70e6 100644 --- a/apps/desktop/src-tauri/src/commands/session_retention.rs +++ b/apps/desktop/src-tauri/src/commands/session_retention.rs @@ -47,6 +47,25 @@ pub struct SessionRetentionPlan { pub created_at: String, } +pub const SESSION_RETENTION_SCHEMA_VERSION: &str = "codevetter.session-retention/v1"; + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SessionRetentionOperation { + Plan, + Apply, + Checkpoint, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionRetentionReceipt { + pub schema_version: String, + pub generated_at: String, + pub operation: SessionRetentionOperation, + pub plan: Option, + pub result: Option, +} + #[derive(Debug, Clone)] struct SessionArchiveStat { session_id: String, @@ -62,9 +81,7 @@ pub async fn plan_session_retention( ) -> Result { validate_policy(&policy)?; let conn = db.0.lock().map_err(|error| error.to_string())?; - let plan = build_plan(&conn, policy)?; - persist_plan(&conn, &plan)?; - Ok(plan) + plan_session_retention_with_connection(&conn, policy) } #[tauri::command] @@ -74,7 +91,7 @@ pub async fn apply_session_retention( ) -> Result { let plan_id = bounded_id(&plan_id, "plan id")?; let mut conn = db.0.lock().map_err(|error| error.to_string())?; - apply_plan(&mut conn, &plan_id) + apply_session_retention_with_connection(&mut conn, &plan_id) } #[tauri::command] @@ -127,9 +144,35 @@ pub async fn compact_session_archive( vacuum: Option, ) -> Result { let conn = db.0.lock().map_err(|error| error.to_string())?; + compact_session_archive_with_connection(&conn, vacuum.unwrap_or(false)) +} + +pub fn plan_session_retention_with_connection( + connection: &Connection, + policy: SessionRetentionPolicy, +) -> Result { + validate_policy(&policy)?; + let plan = build_plan(connection, policy)?; + persist_plan(connection, &plan)?; + Ok(plan) +} + +pub fn apply_session_retention_with_connection( + connection: &mut Connection, + plan_id: &str, +) -> Result { + let plan_id = bounded_id(plan_id, "plan id")?; + apply_plan(connection, &plan_id) +} + +pub fn compact_session_archive_with_connection( + connection: &Connection, + vacuum: bool, +) -> Result { + let conn = connection; conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);") .map_err(|error| error.to_string())?; - if vacuum.unwrap_or(false) { + if vacuum { conn.execute_batch("VACUUM;") .map_err(|error| error.to_string())?; } @@ -152,7 +195,7 @@ pub async fn compact_session_archive( params![ event_id, run_id, - json!({ "vacuum": vacuum.unwrap_or(false) }).to_string(), + json!({ "vacuum": vacuum }).to_string(), created_at ], ) @@ -161,11 +204,51 @@ pub async fn compact_session_archive( Ok(json!({ "checkpointed": true, - "vacuumed": vacuum.unwrap_or(false), + "vacuumed": vacuum, "createdAt": created_at, })) } +pub fn run_session_retention_operation( + connection: &mut Connection, + operation: SessionRetentionOperation, + policy: Option, + plan_id: Option<&str>, + vacuum: bool, +) -> Result { + let (plan, result) = match operation { + SessionRetentionOperation::Plan => { + let policy = + policy.ok_or_else(|| "Retention planning requires a policy".to_string())?; + ( + Some(plan_session_retention_with_connection(connection, policy)?), + None, + ) + } + SessionRetentionOperation::Apply => { + let plan_id = + plan_id.ok_or_else(|| "Retention apply requires a plan id".to_string())?; + ( + None, + Some(apply_session_retention_with_connection( + connection, plan_id, + )?), + ) + } + SessionRetentionOperation::Checkpoint => ( + None, + Some(compact_session_archive_with_connection(connection, vacuum)?), + ), + }; + Ok(SessionRetentionReceipt { + schema_version: SESSION_RETENTION_SCHEMA_VERSION.to_string(), + generated_at: Utc::now().to_rfc3339(), + operation, + plan, + result, + }) +} + fn validate_policy(policy: &SessionRetentionPolicy) -> Result<(), String> { if policy.max_age_days.is_none() && policy.max_archive_bytes.is_none() { return Err("Set an age or archive-size limit".to_string()); @@ -726,4 +809,27 @@ mod tests { assert_eq!(plan.candidates.len(), 1); assert_eq!(plan.candidate_rows, 1_003); } + + #[test] + fn shared_operation_receipt_preserves_preview_without_touching_source_sessions() { + let mut conn = fixture(); + let receipt = run_session_retention_operation( + &mut conn, + SessionRetentionOperation::Plan, + Some(SessionRetentionPolicy { + max_age_days: Some(30), + max_archive_bytes: None, + }), + None, + false, + ) + .expect("receipt"); + assert_eq!(receipt.schema_version, SESSION_RETENTION_SCHEMA_VERSION); + assert_eq!(receipt.operation, SessionRetentionOperation::Plan); + assert_eq!(receipt.plan.as_ref().expect("plan").candidate_rows, 3); + let source_sessions: i64 = conn + .query_row("SELECT COUNT(*) FROM cc_sessions", [], |row| row.get(0)) + .expect("session count"); + assert_eq!(source_sessions, 4); + } } diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs b/apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs index 196eba54..c147d044 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/query/mod.rs @@ -31,7 +31,7 @@ struct StructuralGraphQueryIndex { static QUERY_INDEXES: OnceLock>>> = OnceLock::new(); -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum GraphDirection { Incoming, @@ -221,5 +221,14 @@ pub use projection::{ pub use search::{explain, neighbors, resolve_node, search, search_page}; pub use traversal::{impact, shortest_path}; +/// Materialize the bounded query index without running a synthetic search. +/// +/// Native clients use this through the read-only repository query worker so +/// the first intentional query does not pay index construction latency. The +/// cache and all ranking semantics remain owned by this canonical module. +pub fn prepare_search_index(snapshot: &StructuralGraphSnapshot) { + let _ = query_index(snapshot); +} + #[cfg(test)] mod tests; diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/service.rs b/apps/desktop/src-tauri/src/commands/structural_graph/service.rs index 06883a44..14e30491 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/service.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/service.rs @@ -7,8 +7,9 @@ use super::{ StructuralGraphMetadata, }, storage::{ - list_snapshot_summaries, load_latest_snapshot, load_snapshot_by_id, - StructuralGraphStoredSummary, + list_snapshot_summaries, load_edges_by_ids, load_interactive_snapshot_by_id, + load_latest_snapshot, load_latest_snapshot_summary, load_search_snapshot_by_id, + load_snapshot_by_id, load_traversal_edges_by_snapshot_id, StructuralGraphStoredSummary, }, types::StructuralGraphSnapshot, }; @@ -76,6 +77,40 @@ impl<'a> StructuralGraphReadService<'a> { .ok_or_else(|| "Canonical structural graph snapshot is unavailable".to_string()) } + pub fn search_snapshot_by_id( + &self, + snapshot_id: &str, + ) -> Result { + load_search_snapshot_by_id(self.connection, &self.repo_path, snapshot_id) + .map_err(|error| error.to_string())? + .ok_or_else(|| "Canonical structural graph snapshot is unavailable".to_string()) + } + + pub fn interactive_snapshot_by_id( + &self, + snapshot_id: &str, + ) -> Result { + load_interactive_snapshot_by_id(self.connection, &self.repo_path, snapshot_id) + .map_err(|error| error.to_string())? + .ok_or_else(|| "Canonical structural graph snapshot is unavailable".to_string()) + } + + pub fn traversal_edges_by_snapshot_id( + &self, + snapshot_id: &str, + ) -> Result, String> { + load_traversal_edges_by_snapshot_id(self.connection, snapshot_id) + .map_err(|error| error.to_string()) + } + + pub fn edges_by_ids( + &self, + snapshot_id: &str, + edge_ids: &[String], + ) -> Result, String> { + load_edges_by_ids(self.connection, snapshot_id, edge_ids).map_err(|error| error.to_string()) + } + pub fn status(&self) -> Result { self.status_with_current_head(self.current_head.clone()) } @@ -84,7 +119,7 @@ impl<'a> StructuralGraphReadService<'a> { &self, current_head: Option, ) -> Result { - let snapshot = load_latest_snapshot(self.connection, &self.repo_path) + let snapshot = load_latest_snapshot_summary(self.connection, &self.repo_path) .map_err(|error| error.to_string())?; Ok(match snapshot { Some(snapshot) => StructuralGraphReadStatus { @@ -94,12 +129,12 @@ impl<'a> StructuralGraphReadService<'a> { indexed_head: snapshot.repo_head.clone(), snapshot_id: Some(snapshot.id.clone()), schema_version: Some(snapshot.schema_version), - engine_id: Some(snapshot.engine.id.clone()), - engine_version: Some(snapshot.engine.version.clone()), + engine_id: Some(snapshot.engine_id.clone()), + engine_version: Some(snapshot.engine_version.clone()), created_at: Some(snapshot.created_at.clone()), indexed_files: snapshot.coverage.indexed_files, - node_count: snapshot.nodes.len(), - edge_count: snapshot.edges.len(), + node_count: snapshot.node_count, + edge_count: snapshot.edge_count, truncated: snapshot.truncated, }, None => StructuralGraphReadStatus { @@ -120,6 +155,10 @@ impl<'a> StructuralGraphReadService<'a> { }) } + pub fn current_head(&self) -> Option { + self.current_head.clone() + } + pub fn metadata(&self) -> Result { let mut metadata = query::metadata(&self.snapshot()?); metadata.freshness.stale = self @@ -174,6 +213,12 @@ impl<'a> StructuralGraphReadService<'a> { self.search_page(text, filter, limit, None) } + pub fn prepare_search_index(&self) -> Result<(), String> { + let snapshot = self.snapshot()?; + query::prepare_search_index(&snapshot); + Ok(()) + } + pub fn search_page( &self, text: &str, @@ -270,7 +315,8 @@ mod tests { use crate::commands::structural_graph::{ storage::persist_snapshot, types::{ - StructuralGraphCoverage, StructuralGraphEngineInfo, StructuralGraphSnapshot, + GraphOrigin, GraphTrust, StructuralGraphCoverage, StructuralGraphEdge, + StructuralGraphEngineInfo, StructuralGraphNode, StructuralGraphSnapshot, STRUCTURAL_GRAPH_SCHEMA_VERSION, }, }; @@ -296,8 +342,30 @@ mod tests { ignore_fingerprint: None, coverage: StructuralGraphCoverage::default(), files: Vec::new(), - nodes: Vec::new(), - edges: Vec::new(), + nodes: vec![StructuralGraphNode { + id: "node:verify".to_string(), + kind: "function".to_string(), + label: "verify_change".to_string(), + qualified_name: Some("verification::verify_change".to_string()), + path: Some("src/verify.rs".to_string()), + detail: Some("Canonical verification entrypoint".to_string()), + language: Some("rust".to_string()), + community_id: None, + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: Vec::new(), + }], + edges: vec![StructuralGraphEdge { + id: "edge:self".to_string(), + from: "node:verify".to_string(), + to: "node:verify".to_string(), + kind: "references".to_string(), + evidence: "fixture".to_string(), + trust: GraphTrust::Extracted, + origin: GraphOrigin::Resolution, + sources: Vec::new(), + candidates: Vec::new(), + }], metrics: Vec::new(), clone_groups: Vec::new(), communities: Vec::new(), @@ -315,7 +383,29 @@ mod tests { "snapshot" ); let overview = service.overview(10).expect("overview"); - assert_eq!(overview.nodes.len(), 0); + assert_eq!(overview.nodes.len(), 1); assert_eq!(overview.context.freshness.stale, Some(false)); + let search_snapshot = service + .search_snapshot_by_id("snapshot") + .expect("search projection"); + assert_eq!(search_snapshot.nodes.len(), 1); + assert!(search_snapshot.edges.is_empty()); + let interactive_snapshot = service + .interactive_snapshot_by_id("snapshot") + .expect("interactive projection"); + assert_eq!(interactive_snapshot.nodes.len(), 1); + assert_eq!(interactive_snapshot.edges.len(), 1); + assert!(interactive_snapshot.metrics.is_empty()); + assert!(interactive_snapshot.files.is_empty()); + let traversal_edges = service + .traversal_edges_by_snapshot_id("snapshot") + .expect("compact traversal edges"); + assert_eq!(traversal_edges.len(), 1); + assert!(traversal_edges[0].evidence.is_empty()); + let hydrated_edges = service + .edges_by_ids("snapshot", &["edge:self".to_string()]) + .expect("bounded hydrated edges"); + assert_eq!(hydrated_edges[0].evidence, "fixture"); + assert_eq!(service.snapshot_by_id("snapshot").unwrap().edges.len(), 1); } } diff --git a/apps/desktop/src-tauri/src/commands/structural_graph/storage.rs b/apps/desktop/src-tauri/src/commands/structural_graph/storage.rs index aa858234..54b71db8 100644 --- a/apps/desktop/src-tauri/src/commands/structural_graph/storage.rs +++ b/apps/desktop/src-tauri/src/commands/structural_graph/storage.rs @@ -376,7 +376,150 @@ pub fn load_snapshot_by_id( repo_path: &str, snapshot_id: &str, ) -> Result, StructuralGraphError> { - let metadata = connection + hydrate_snapshot( + connection, + load_snapshot_metadata_by_id(connection, repo_path, snapshot_id)?, + ) +} + +/// Load only the canonical fields required by structural search. +/// +/// Traversal, explanation, and impact continue to hydrate the full snapshot. +/// The persistent native query worker uses this projection so an interactive +/// search does not retain unrelated edges, metrics, files, or diagnostics. +pub fn load_search_snapshot_by_id( + connection: &Connection, + repo_path: &str, + snapshot_id: &str, +) -> Result, StructuralGraphError> { + hydrate_search_snapshot( + connection, + load_snapshot_metadata_by_id(connection, repo_path, snapshot_id)?, + ) +} + +/// Load the canonical nodes and edges required by interactive graph queries. +/// +/// Unlike the full snapshot this omits metrics, clones, communities, files, +/// and diagnostics. The native query worker can therefore retain traversal +/// capability without holding analysis-only payloads for its whole lifetime. +pub fn load_interactive_snapshot_by_id( + connection: &Connection, + repo_path: &str, + snapshot_id: &str, +) -> Result, StructuralGraphError> { + hydrate_interactive_snapshot( + connection, + load_snapshot_metadata_by_id(connection, repo_path, snapshot_id)?, + ) +} + +/// Load the compact edge fields needed by canonical traversal algorithms. +/// Result edges are hydrated separately, after bounds have reduced the set. +pub fn load_traversal_edges_by_snapshot_id( + connection: &Connection, + snapshot_id: &str, +) -> Result, StructuralGraphError> { + let mut statement = connection + .prepare( + "SELECT id, from_id, to_id, kind, trust + FROM structural_graph_edges WHERE snapshot_id = ?1 + ORDER BY id", + ) + .map_err(storage_error("prepare structural graph traversal edges"))?; + let edges = statement + .query_map(params![snapshot_id], |row| { + Ok(StructuralGraphEdge { + id: row.get(0)?, + from: row.get(1)?, + to: row.get(2)?, + kind: row.get(3)?, + evidence: String::new(), + trust: GraphTrust::from_storage(&row.get::<_, String>(4)?), + origin: GraphOrigin::LegacyMetadata, + sources: Vec::new(), + candidates: Vec::new(), + }) + }) + .map_err(storage_error("query structural graph traversal edges"))? + .collect::, _>>() + .map_err(storage_error("read structural graph traversal edges"))?; + Ok(edges) +} + +/// Hydrate only bounded result edges after traversal has selected their IDs. +pub fn load_edges_by_ids( + connection: &Connection, + snapshot_id: &str, + edge_ids: &[String], +) -> Result, StructuralGraphError> { + if edge_ids.is_empty() { + return Ok(Vec::new()); + } + let mut edges = Vec::new(); + for chunk in edge_ids.chunks(250) { + let placeholders = (0..chunk.len()) + .map(|index| format!("?{}", index + 2)) + .collect::>() + .join(", "); + let sql = format!( + "SELECT id, from_id, to_id, kind, evidence, trust, origin, candidates_json + FROM structural_graph_edges + WHERE snapshot_id = ?1 AND id IN ({placeholders})" + ); + let mut values = Vec::with_capacity(chunk.len() + 1); + values.push(rusqlite::types::Value::Text(snapshot_id.to_string())); + values.extend(chunk.iter().cloned().map(rusqlite::types::Value::Text)); + let mut statement = connection + .prepare(&sql) + .map_err(storage_error("prepare bounded structural graph edges"))?; + let rows = statement + .query_map(rusqlite::params_from_iter(values.iter()), |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + )) + }) + .map_err(storage_error("query bounded structural graph edges"))? + .collect::, _>>() + .map_err(storage_error("read bounded structural graph edges"))?; + let mut sources = load_sources_for_targets(connection, snapshot_id, "edge", chunk)?; + edges.extend( + rows.into_iter() + .map( + |(id, from, to, kind, evidence, trust, origin, candidates_json)| { + Ok(StructuralGraphEdge { + sources: sources.remove(&id).unwrap_or_default(), + id, + from, + to, + kind, + evidence, + trust: GraphTrust::from_storage(&trust), + origin: GraphOrigin::from_storage(&origin), + candidates: from_json(&candidates_json, "edge candidates")?, + }) + }, + ) + .collect::, StructuralGraphError>>()?, + ); + } + edges.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(edges) +} + +fn load_snapshot_metadata_by_id( + connection: &Connection, + repo_path: &str, + snapshot_id: &str, +) -> Result, StructuralGraphError> { + connection .query_row( "SELECT id, repo_path, repo_head, schema_version, engine_json, cursor, ignore_fingerprint, coverage_json, truncated, created_at @@ -399,8 +542,7 @@ pub fn load_snapshot_by_id( }, ) .optional() - .map_err(storage_error("load structural graph snapshot by id"))?; - hydrate_snapshot(connection, metadata) + .map_err(storage_error("load structural graph snapshot by id")) } type SnapshotMetadata = ( @@ -461,6 +603,94 @@ fn hydrate_snapshot( })) } +fn hydrate_search_snapshot( + connection: &Connection, + metadata: Option, +) -> Result, StructuralGraphError> { + let Some(( + id, + stored_repo_path, + repo_head, + schema_version, + engine_json, + cursor, + ignore_fingerprint, + coverage_json, + truncated, + created_at, + )) = metadata + else { + return Ok(None); + }; + if schema_version != STRUCTURAL_GRAPH_SCHEMA_VERSION { + return Err(StructuralGraphError::UnsupportedSchema(schema_version)); + } + let mut sources = load_node_source_map(connection, &id)?; + Ok(Some(StructuralGraphSnapshot { + schema_version, + nodes: load_nodes(connection, &id, &mut sources)?, + edges: Vec::new(), + metrics: Vec::new(), + clone_groups: Vec::new(), + communities: Vec::new(), + files: Vec::new(), + diagnostics: Vec::new(), + id, + repo_path: stored_repo_path, + repo_head, + created_at, + engine: from_json(&engine_json, "engine")?, + cursor, + ignore_fingerprint, + coverage: from_json(&coverage_json, "coverage")?, + truncated: truncated != 0, + })) +} + +fn hydrate_interactive_snapshot( + connection: &Connection, + metadata: Option, +) -> Result, StructuralGraphError> { + let Some(( + id, + stored_repo_path, + repo_head, + schema_version, + engine_json, + cursor, + ignore_fingerprint, + coverage_json, + truncated, + created_at, + )) = metadata + else { + return Ok(None); + }; + if schema_version != STRUCTURAL_GRAPH_SCHEMA_VERSION { + return Err(StructuralGraphError::UnsupportedSchema(schema_version)); + } + let mut sources = load_node_edge_source_map(connection, &id)?; + Ok(Some(StructuralGraphSnapshot { + schema_version, + nodes: load_nodes(connection, &id, &mut sources)?, + edges: load_edges(connection, &id, &mut sources)?, + metrics: Vec::new(), + clone_groups: Vec::new(), + communities: Vec::new(), + files: Vec::new(), + diagnostics: Vec::new(), + id, + repo_path: stored_repo_path, + repo_head, + created_at, + engine: from_json(&engine_json, "engine")?, + cursor, + ignore_fingerprint, + coverage: from_json(&coverage_json, "coverage")?, + truncated: truncated != 0, + })) +} + pub fn load_latest_snapshot_summary( connection: &Connection, repo_path: &str, @@ -713,6 +943,142 @@ fn load_source_map( Ok(sources) } +fn load_sources_for_targets( + connection: &Connection, + snapshot_id: &str, + target_kind: &str, + target_ids: &[String], +) -> Result>, StructuralGraphError> { + if target_ids.is_empty() { + return Ok(HashMap::new()); + } + let placeholders = (0..target_ids.len()) + .map(|index| format!("?{}", index + 3)) + .collect::>() + .join(", "); + let sql = format!( + "SELECT target_id, path, start_line, start_column, end_line, end_column, excerpt + FROM structural_graph_sources + WHERE snapshot_id = ?1 AND target_kind = ?2 AND target_id IN ({placeholders}) + ORDER BY target_id, ordinal" + ); + let mut values = Vec::with_capacity(target_ids.len() + 2); + values.push(rusqlite::types::Value::Text(snapshot_id.to_string())); + values.push(rusqlite::types::Value::Text(target_kind.to_string())); + values.extend(target_ids.iter().cloned().map(rusqlite::types::Value::Text)); + let mut statement = connection + .prepare(&sql) + .map_err(storage_error("prepare bounded structural graph sources"))?; + let rows = statement + .query_map(rusqlite::params_from_iter(values.iter()), |row| { + Ok(( + row.get::<_, String>(0)?, + GraphSourceAnchor { + path: row.get(1)?, + start_line: row.get(2)?, + start_column: row.get(3)?, + end_line: row.get(4)?, + end_column: row.get(5)?, + excerpt: row.get(6)?, + }, + )) + }) + .map_err(storage_error("query bounded structural graph sources"))? + .collect::, _>>() + .map_err(storage_error("read bounded structural graph sources"))?; + let mut sources = HashMap::new(); + for (target_id, source) in rows { + sources + .entry(target_id) + .or_insert_with(Vec::new) + .push(source); + } + Ok(sources) +} + +fn load_node_source_map( + connection: &Connection, + snapshot_id: &str, +) -> Result>, StructuralGraphError> { + let mut statement = connection + .prepare( + "SELECT target_id, path, start_line, start_column, + end_line, end_column, excerpt + FROM structural_graph_sources + WHERE snapshot_id = ?1 AND target_kind = 'node' + ORDER BY target_id, ordinal", + ) + .map_err(storage_error("prepare structural graph node sources"))?; + let rows = statement + .query_map(params![snapshot_id], |row| { + Ok(( + row.get::<_, String>(0)?, + GraphSourceAnchor { + path: row.get(1)?, + start_line: row.get(2)?, + start_column: row.get(3)?, + end_line: row.get(4)?, + end_column: row.get(5)?, + excerpt: row.get(6)?, + }, + )) + }) + .map_err(storage_error("query structural graph node sources"))? + .collect::, _>>() + .map_err(storage_error("read structural graph node sources"))?; + let mut sources = HashMap::new(); + for (target_id, source) in rows { + sources + .entry(("node".to_string(), target_id)) + .or_insert_with(Vec::new) + .push(source); + } + Ok(sources) +} + +fn load_node_edge_source_map( + connection: &Connection, + snapshot_id: &str, +) -> Result>, StructuralGraphError> { + let mut statement = connection + .prepare( + "SELECT target_kind, target_id, path, start_line, start_column, + end_line, end_column, excerpt + FROM structural_graph_sources + WHERE snapshot_id = ?1 AND target_kind IN ('node', 'edge') + ORDER BY target_kind, target_id, ordinal", + ) + .map_err(storage_error( + "prepare structural graph interactive sources", + ))?; + let rows = statement + .query_map(params![snapshot_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + GraphSourceAnchor { + path: row.get(2)?, + start_line: row.get(3)?, + start_column: row.get(4)?, + end_line: row.get(5)?, + end_column: row.get(6)?, + excerpt: row.get(7)?, + }, + )) + }) + .map_err(storage_error("query structural graph interactive sources"))? + .collect::, _>>() + .map_err(storage_error("read structural graph interactive sources"))?; + let mut sources = HashMap::new(); + for (target_kind, target_id, source) in rows { + sources + .entry((target_kind, target_id)) + .or_insert_with(Vec::new) + .push(source); + } + Ok(sources) +} + fn load_nodes( connection: &Connection, snapshot_id: &str, diff --git a/apps/desktop/src-tauri/src/commands/synthetic_qa.rs b/apps/desktop/src-tauri/src/commands/synthetic_qa.rs index 21cca02a..fe4b43c8 100644 --- a/apps/desktop/src-tauri/src/commands/synthetic_qa.rs +++ b/apps/desktop/src-tauri/src/commands/synthetic_qa.rs @@ -457,13 +457,17 @@ fn scan_playwright_specs(root: &Path) -> Vec { out } +pub fn discover_playwright_specs_headless(root: &Path) -> Vec { + scan_playwright_specs(root) +} + #[tauri::command] pub async fn discover_playwright_specs(repo_path: String) -> Result { let root = PathBuf::from(repo_path.trim()); if !root.is_dir() { return Err("repo_path must be an existing directory".into()); } - let specs = scan_playwright_specs(&root); + let specs = discover_playwright_specs_headless(&root); Ok(json!({ "specs": specs })) } diff --git a/apps/desktop/src-tauri/src/commands/trex_preview.rs b/apps/desktop/src-tauri/src/commands/trex_preview.rs index 9a8cabcf..b569f924 100644 --- a/apps/desktop/src-tauri/src/commands/trex_preview.rs +++ b/apps/desktop/src-tauri/src/commands/trex_preview.rs @@ -54,6 +54,10 @@ pub struct TrexPreviewRunInput { pub change_kind: TrexChangeKind, pub change: String, pub preview_url: String, + #[serde(default)] + pub target_route: Option, + #[serde(default)] + pub target_goal: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -87,6 +91,8 @@ pub struct TrexPreviewIdentity { pub struct TrexPreviewRoute { pub route: String, pub reason: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub goal: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -174,7 +180,12 @@ pub async fn execute_trex_preview( } TrexChangeKind::Range => resolve_range(&input.repo_path, input.change.trim()).await?, }; - let (routes, mut limitations) = derive_routes(&source.changed_paths); + let (mut routes, mut limitations) = derive_routes(&source.changed_paths); + apply_selected_target( + &mut routes, + input.target_route.as_deref(), + input.target_goal.as_deref(), + )?; let preview = probe_preview_identity(&preview_url, &source.head_sha).await?; let run_id = format!("trex-preview-{}", uuid::Uuid::new_v4()); let artifact_dir = app_data_dir.join("synthetic-qa").join(&run_id); @@ -265,10 +276,12 @@ async fn run_preview_journeys( preview_url.to_string(), Some("generic-page-smoke".into()), Some("playwright_builtin".into()), - Some(format!( - "T-Rex change-preview smoke selected from {}", - selected.reason - )), + Some(selected.goal.clone().unwrap_or_else(|| { + format!( + "T-Rex change-preview smoke selected from {}", + selected.reason + ) + })), None, Some("none".into()), None, @@ -356,18 +369,16 @@ async fn run_preview_journeys( .join(route_artifact_name(&selected.route)) .join("failure.jpg"); if std::fs::create_dir_all(screenshot.parent().unwrap_or(artifact_dir)).is_ok() - { - if browser + && browser .snapshot(SnapshotOpts { screenshot_path: Some(&screenshot), max_elements: 0, }) .await .is_ok() - { - screenshot_path = Some(screenshot.to_string_lossy().into_owned()); - artifacts.push(screenshot.to_string_lossy().into_owned()); - } + { + screenshot_path = Some(screenshot.to_string_lossy().into_owned()); + artifacts.push(screenshot.to_string_lossy().into_owned()); } } let duration_ms = started.elapsed().as_millis() as u64; @@ -376,10 +387,12 @@ async fn run_preview_journeys( journeys.push(SyntheticQaRunResult { loop_id: "generic-page-smoke".into(), route: selected.route.clone(), - goal: format!( - "T-Rex change-preview smoke selected from {}", - selected.reason - ), + goal: selected.goal.clone().unwrap_or_else(|| { + format!( + "T-Rex change-preview smoke selected from {}", + selected.reason + ) + }), pass, notes: if pass { format!( @@ -745,6 +758,7 @@ fn derive_routes(changed_paths: &[String]) -> (Vec, Vec (Vec, Vec (Vec, Vec, + route: Option<&str>, + goal: Option<&str>, +) -> Result<(), String> { + let Some(route) = route.map(str::trim).filter(|route| !route.is_empty()) else { + return Ok(()); + }; + if !route.starts_with('/') || route.starts_with("//") || route.chars().count() > 240 { + return Err("target_route must be a bounded browser path beginning with /".into()); + } + let goal = goal.map(str::trim).filter(|goal| !goal.is_empty()); + if goal.is_some_and(|goal| goal.chars().count() > 500) { + return Err("target_goal must be at most 500 characters".into()); + } + routes.retain(|candidate| candidate.route != route); + let selected = TrexPreviewRoute { + route: route.to_string(), + reason: "Selected QA workflow target".into(), + goal: goal.map(ToOwned::to_owned), + }; + let index = usize::from( + routes + .first() + .is_some_and(|candidate| candidate.route == "/"), + ); + routes.insert(index, selected); + routes.truncate(MAX_ROUTES); + Ok(()) +} + enum RouteDerivation { Route(String), Dynamic, @@ -1264,6 +1310,29 @@ mod tests { assert!(limitations.iter().any(|item| item.contains("[id]"))); } + #[test] + fn selected_qa_target_is_bounded_deduplicated_and_keeps_the_user_goal() { + let (mut routes, _) = derive_routes(&["src/pages/checkout.tsx".into()]); + apply_selected_target( + &mut routes, + Some("/checkout"), + Some("Complete guest checkout"), + ) + .expect("selected target"); + assert_eq!(routes[0].route, "/"); + assert_eq!(routes[1].route, "/checkout"); + assert_eq!(routes[1].reason, "Selected QA workflow target"); + assert_eq!(routes[1].goal.as_deref(), Some("Complete guest checkout")); + assert_eq!( + routes + .iter() + .filter(|route| route.route == "/checkout") + .count(), + 1 + ); + assert!(apply_selected_target(&mut routes, Some("https://unsafe.test"), None).is_err()); + } + #[test] fn execution_plans_and_command_output_are_bounded() { let paths = (0..12) @@ -1332,6 +1401,7 @@ mod tests { let routes = vec![TrexPreviewRoute { route: "/".into(), reason: "root".into(), + goal: None, }]; let passing = vec![passing_journey("/")]; assert_eq!( @@ -1386,6 +1456,7 @@ mod tests { routes: vec![TrexPreviewRoute { route: "/settings".into(), reason: "Derived from src/pages/settings.tsx".into(), + goal: None, }], journeys: vec![passing_journey("/settings")], verdict: TrexPreviewVerdict::PassedWithLimits, diff --git a/apps/desktop/src-tauri/src/commands/trex_watcher.rs b/apps/desktop/src-tauri/src/commands/trex_watcher.rs index 2a37eba4..0e87dd19 100644 --- a/apps/desktop/src-tauri/src/commands/trex_watcher.rs +++ b/apps/desktop/src-tauri/src/commands/trex_watcher.rs @@ -24,7 +24,9 @@ use tauri::{AppHandle, Manager, State}; use tokio::process::Command; use tokio::sync::oneshot; -use crate::commands::sandbox::{run_branch_sandbox_inner, SandboxOptions, SandboxRunInput}; +use crate::commands::sandbox::{ + run_branch_sandbox_headless, run_branch_sandbox_inner, SandboxOptions, SandboxRunInput, +}; use crate::DbState; const PREF_GITHUB_TOKEN: &str = "github_token"; @@ -89,6 +91,274 @@ pub struct StartTrexWatcherInput { pub base_branch: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrexWatcherReceipt { + pub schema_version: u8, + pub operation: String, + pub watcher: Option, + pub watchers: Vec, + pub runs: Vec, + pub inspected_prs: u32, + pub skipped_unchanged: u32, + pub message: String, +} + +impl TrexWatcherReceipt { + fn empty(operation: &str, message: impl Into) -> Self { + Self { + schema_version: 1, + operation: operation.to_string(), + watcher: None, + watchers: vec![], + runs: vec![], + inspected_prs: 0, + skipped_unchanged: 0, + message: message.into(), + } + } +} + +// ─── Headless CLI/native bridge ───────────────────────────────────────────── + +pub fn enable_trex_watcher_headless( + db: &DbState, + input: StartTrexWatcherInput, +) -> Result { + let repo_path = canonical_repo_path(&input.repo_path)?; + let interval = input + .interval_secs + .unwrap_or(DEFAULT_INTERVAL_SECS) + .max(MIN_INTERVAL_SECS); + upsert_watcher_row(db, &repo_path, interval, true, input.base_branch.as_deref())?; + let watcher = read_watcher_row(db, &repo_path)? + .ok_or_else(|| "watcher row missing after upsert".to_string())?; + let mut receipt = TrexWatcherReceipt::empty( + "enable", + "Watcher configuration saved. The host app owns scheduling while it is open.", + ); + receipt.watcher = Some(watcher); + Ok(receipt) +} + +pub fn disable_trex_watcher_headless( + db: &DbState, + repo_path: &str, +) -> Result { + let repo_path = canonical_repo_path(repo_path)?; + let existing = read_watcher_row(db, &repo_path)? + .ok_or_else(|| format!("no watcher registered for {repo_path}"))?; + set_watcher_enabled(db, &repo_path, false)?; + let mut receipt = TrexWatcherReceipt::empty("disable", "Watcher scheduling disabled."); + receipt.watcher = Some(TrexWatcher { + enabled: false, + ..existing + }); + Ok(receipt) +} + +pub fn list_trex_watchers_headless(db: &DbState) -> Result { + let watchers = list_watchers(db)?; + let mut receipt = TrexWatcherReceipt::empty( + "list", + format!("{} watcher configuration(s)", watchers.len()), + ); + receipt.watchers = watchers; + Ok(receipt) +} + +pub fn list_trex_pr_runs_headless( + db: &DbState, + repo_path: Option<&str>, + limit: u32, +) -> Result { + let canonical = repo_path.map(canonical_repo_path).transpose()?; + let runs = list_pr_runs(db, canonical.as_deref(), limit.clamp(1, 100))?; + let mut receipt = TrexWatcherReceipt::empty("runs", format!("{} watcher run(s)", runs.len())); + receipt.runs = runs; + Ok(receipt) +} + +/// Run one complete watcher poll in the foreground. This is intentionally not +/// a daemon: native macOS owns its app-lifetime schedule and each CLI process +/// remains alive until every newly discovered PR run has persisted a receipt. +pub async fn poll_trex_watcher_headless( + db: &DbState, + repo_path: &str, +) -> Result { + let repo_path = canonical_repo_path(repo_path)?; + let watcher = read_watcher_row(db, &repo_path)? + .ok_or_else(|| format!("no watcher registered for {repo_path}"))?; + set_last_polled(db, &repo_path)?; + let prs = match list_open_prs(&repo_path).await { + Ok(prs) => prs, + Err(error) => { + set_last_error(db, &repo_path, &error)?; + return Err(error); + } + }; + + let inspected_prs = prs.len().min(MAX_PRS_PER_TICK) as u32; + let mut skipped_unchanged = 0; + let mut runs = Vec::new(); + for pr in prs.into_iter().take(MAX_PRS_PER_TICK) { + if !pr_head_requires_run( + latest_pr_run_sha(db, &repo_path, pr.number)?.as_deref(), + &pr.head_sha, + ) { + skipped_unchanged += 1; + continue; + } + let run = execute_pr_headless(db, &watcher, pr).await; + insert_pr_run(db, &run)?; + runs.push(run); + } + + let mut receipt = TrexWatcherReceipt::empty( + "poll", + format!( + "Inspected {inspected_prs} open PR(s); completed {} new run(s); skipped {skipped_unchanged} unchanged.", + runs.len() + ), + ); + receipt.watcher = read_watcher_row(db, &repo_path)?; + receipt.runs = runs; + receipt.inspected_prs = inspected_prs; + receipt.skipped_unchanged = skipped_unchanged; + Ok(receipt) +} + +/// Explicitly rerun one currently open PR even when its head SHA already has a +/// retained receipt. Automatic polls remain deduplicated; this separate command +/// is the recovery boundary for infrastructure-limited attempts. +pub async fn retry_trex_watcher_headless( + db: &DbState, + repo_path: &str, + pr_number: i64, +) -> Result { + if pr_number <= 0 { + return Err("watcher retry requires a positive PR number".to_string()); + } + let repo_path = canonical_repo_path(repo_path)?; + let watcher = read_watcher_row(db, &repo_path)? + .ok_or_else(|| format!("no watcher registered for {repo_path}"))?; + set_last_polled(db, &repo_path)?; + let pr = list_open_prs(&repo_path) + .await? + .into_iter() + .find(|pr| pr.number == pr_number) + .ok_or_else(|| format!("PR #{pr_number} is not currently open for {repo_path}"))?; + let run = execute_pr_headless(db, &watcher, pr).await; + insert_pr_run(db, &run)?; + + let mut receipt = TrexWatcherReceipt::empty( + "retry", + format!( + "Retried PR #{} at exact head {} and persisted one replacement attempt.", + run.pr_number, run.head_sha + ), + ); + receipt.watcher = read_watcher_row(db, &repo_path)?; + receipt.runs = vec![run]; + receipt.inspected_prs = 1; + Ok(receipt) +} + +fn pr_head_requires_run(latest_persisted_sha: Option<&str>, incoming_sha: &str) -> bool { + latest_persisted_sha != Some(incoming_sha) +} + +fn canonical_repo_path(repo_path: &str) -> Result { + let path = std::fs::canonicalize(repo_path) + .map_err(|error| format!("repository {repo_path} is unavailable: {error}"))?; + if !path.join(".git").exists() { + return Err(format!("repository {repo_path} has no .git directory")); + } + Ok(path.to_string_lossy().into_owned()) +} + +async fn execute_pr_headless(db: &DbState, watcher: &TrexWatcher, pr: OpenPr) -> TrexPrRun { + let token = resolve_github_token(db).await; + let remote = remote_owner_repo(&watcher.repo_path).await.ok(); + if let (Some(token), Some((owner, repo))) = (token.as_deref(), remote.as_ref()) { + let _ = post_status( + token, + owner, + repo, + &pr.head_sha, + "pending", + "T-Rex sandbox running…", + None, + ) + .await; + } + + let started = std::time::Instant::now(); + let result = match materialize_pr_head(&watcher.repo_path, pr.number, &pr.head_sha).await { + Ok(()) => { + run_branch_sandbox_headless( + db, + SandboxRunInput { + repo_path: watcher.repo_path.clone(), + branch: pr.head_sha.clone(), + base_branch: watcher.base_branch.clone(), + review_id: None, + options: SandboxOptions::default(), + }, + ) + .await + } + Err(error) => Err(format!( + "PR #{} head {} could not be materialized: {error}", + pr.number, pr.head_sha + )), + }; + let duration_ms = started.elapsed().as_millis() as i64; + let (verdict, confidence, summary) = match result { + Ok(result) => (result.verdict, result.confidence, result.summary), + Err(error) => ( + "BLOCK".to_string(), + 0.0, + format!("T-Rex sandbox failed to run: {error}"), + ), + }; + let (status_state, status_error) = match (token.as_deref(), remote.as_ref()) { + (Some(token), Some((owner, repo))) => { + let state = verdict_to_gh_state(&verdict); + match post_status( + token, + owner, + repo, + &pr.head_sha, + state, + &truncate_for_status(&summary), + None, + ) + .await + { + Ok(()) => (Some(state.to_string()), None), + Err(error) => (None, Some(error)), + } + } + _ => ( + None, + Some("missing github_token or remote — status not posted".to_string()), + ), + }; + TrexPrRun { + id: uuid::Uuid::new_v4().to_string(), + repo_path: watcher.repo_path.clone(), + pr_number: pr.number, + head_sha: pr.head_sha, + verdict, + confidence, + summary, + status_state, + status_error, + duration_ms, + ran_at: chrono::Utc::now().to_rfc3339(), + } +} + // ─── Tauri commands ───────────────────────────────────────────────────────── #[tauri::command] @@ -271,8 +541,6 @@ async fn tick_once( for pr in prs.into_iter().take(MAX_PRS_PER_TICK) { let pr_number = pr.number; let head_sha = pr.head_sha; - let head_ref = pr.head_ref.clone(); - // Skip if a previous tick already kicked this PR and it's still running. if let Ok(mut s) = in_flight.lock() { if s.contains(&pr_number) { @@ -298,7 +566,7 @@ async fn tick_once( let in_flight_c = in_flight.clone(); runtime_spawn(async move { - let token = read_github_token(&db_c); + let token = resolve_github_token(&db_c).await; let remote = remote_owner_repo(&repo_path_c).await.ok(); if let (Some(tok), Some((owner, repo))) = (token.as_deref(), remote.as_ref()) { let _ = post_status( @@ -314,14 +582,21 @@ async fn tick_once( } let started = std::time::Instant::now(); - let input = SandboxRunInput { - repo_path: repo_path_c.clone(), - branch: head_ref, - base_branch: base_c, - review_id: None, - options: SandboxOptions::default(), + let run = match materialize_pr_head(&repo_path_c, pr_number, &head_sha).await { + Ok(()) => { + let input = SandboxRunInput { + repo_path: repo_path_c.clone(), + branch: head_sha.clone(), + base_branch: base_c, + review_id: None, + options: SandboxOptions::default(), + }; + run_branch_sandbox_inner(app_c.clone(), &db_c, input).await + } + Err(error) => Err(format!( + "PR #{pr_number} head {head_sha} could not be materialized: {error}" + )), }; - let run = run_branch_sandbox_inner(app_c.clone(), &db_c, input).await; let duration_ms = started.elapsed().as_millis() as i64; let (verdict, confidence, summary, error) = match &run { @@ -379,7 +654,6 @@ async fn tick_once( struct OpenPr { number: i64, - head_ref: String, head_sha: String, } @@ -391,7 +665,7 @@ async fn list_open_prs(repo_path: &str) -> Result, String> { "--state", "open", "--json", - "number,headRefName,headRefOid", + "number,headRefOid", "--limit", "30", ]) @@ -411,27 +685,75 @@ async fn list_open_prs(repo_path: &str) -> Result, String> { let mut out = Vec::with_capacity(arr.len()); for item in arr { let number = item.get("number").and_then(|x| x.as_i64()).unwrap_or(0); - let head_ref = item - .get("headRefName") - .and_then(|x| x.as_str()) - .unwrap_or("") - .to_string(); let head_sha = item .get("headRefOid") .and_then(|x| x.as_str()) .unwrap_or("") .to_string(); - if number > 0 && !head_ref.is_empty() && !head_sha.is_empty() { - out.push(OpenPr { - number, - head_ref, - head_sha, - }); + if number > 0 && is_full_git_sha(&head_sha) { + out.push(OpenPr { number, head_sha }); } } Ok(out) } +fn is_full_git_sha(value: &str) -> bool { + value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +/// Make GitHub's immutable PR head commit available to the local object database +/// without changing the user's branch, index, working tree, or durable refs. +async fn materialize_pr_head( + repo_path: &str, + pr_number: i64, + head_sha: &str, +) -> Result<(), String> { + if pr_number <= 0 || !is_full_git_sha(head_sha) { + return Err("GitHub returned an invalid pull-request identity".to_string()); + } + if git_commit_exists(repo_path, head_sha).await? { + return Ok(()); + } + + let pull_ref = format!("+refs/pull/{pr_number}/head"); + let output = Command::new("git") + .args([ + "fetch", + "--no-tags", + "--no-write-fetch-head", + "origin", + &pull_ref, + ]) + .current_dir(repo_path) + .output() + .await + .map_err(|error| format!("git fetch {pull_ref}: {error}"))?; + if !output.status.success() { + return Err(format!( + "git fetch {pull_ref} failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + if git_commit_exists(repo_path, head_sha).await? { + Ok(()) + } else { + Err(format!( + "fetched PR #{pr_number}, but GitHub's declared head {head_sha} is unavailable" + )) + } +} + +async fn git_commit_exists(repo_path: &str, head_sha: &str) -> Result { + let commit = format!("{head_sha}^{{commit}}"); + let output = Command::new("git") + .args(["cat-file", "-e", &commit]) + .current_dir(repo_path) + .output() + .await + .map_err(|error| format!("git cat-file {head_sha}: {error}"))?; + Ok(output.status.success()) +} + async fn remote_owner_repo(repo_path: &str) -> Result<(String, String), String> { let output = Command::new("git") .args(["remote", "get-url", "origin"]) @@ -583,6 +905,16 @@ fn set_last_polled(db: &DbState, repo_path: &str) -> Result<(), String> { Ok(()) } +fn set_last_error(db: &DbState, repo_path: &str, error: &str) -> Result<(), String> { + let conn = db.0.lock().map_err(|e| e.to_string())?; + conn.execute( + "UPDATE trex_watchers SET last_error = ?1 WHERE repo_path = ?2", + params![error, repo_path], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + fn read_watcher_row(db: &DbState, repo_path: &str) -> Result, String> { let conn = db.0.lock().map_err(|e| e.to_string())?; let result = conn.query_row( @@ -719,6 +1051,33 @@ fn list_pr_runs( Ok(rows) } +async fn resolve_github_token(db: &DbState) -> Option { + let saved = read_github_token(db); + let gh_env = std::env::var("GH_TOKEN").ok(); + let github_env = std::env::var("GITHUB_TOKEN").ok(); + if let Some(token) = first_non_empty_token([saved, gh_env, github_env]) { + return Some(token); + } + + let output = Command::new("gh") + .args(["auth", "token"]) + .output() + .await + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|token| !token.is_empty()) +} + +fn first_non_empty_token(candidates: impl IntoIterator>) -> Option { + candidates + .into_iter() + .flatten() + .find(|candidate| !candidate.trim().is_empty()) +} + fn read_github_token(db: &DbState) -> Option { let conn = db.0.lock().ok()?; read_pref(&conn, PREF_GITHUB_TOKEN) @@ -738,6 +1097,38 @@ fn read_pref(conn: &Connection, key: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use std::path::Path; + + fn test_db() -> DbState { + let connection = Connection::open_in_memory().expect("open test database"); + connection + .execute_batch( + "CREATE TABLE trex_watchers ( + repo_path TEXT PRIMARY KEY, + interval_secs INTEGER NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + base_branch TEXT, + last_polled_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE trex_pr_runs ( + id TEXT PRIMARY KEY, + repo_path TEXT NOT NULL, + pr_number INTEGER NOT NULL, + head_sha TEXT NOT NULL, + verdict TEXT NOT NULL, + confidence REAL NOT NULL, + summary TEXT NOT NULL, + status_state TEXT, + status_error TEXT, + duration_ms INTEGER NOT NULL DEFAULT 0, + ran_at TEXT NOT NULL DEFAULT (datetime('now')) + );", + ) + .expect("create watcher schema"); + DbState(Arc::new(Mutex::new(connection))) + } #[test] fn owner_repo_from_https() { @@ -785,4 +1176,185 @@ mod tests { assert_eq!(out.chars().count(), 138); // 137 + '…' assert!(out.ends_with('…')); } + + #[test] + fn headless_configuration_is_persisted_and_disable_is_explicit() { + let repository = tempfile::tempdir().expect("temporary repository"); + std::fs::create_dir(repository.path().join(".git")).expect("fake git directory"); + let db = test_db(); + + let enabled = enable_trex_watcher_headless( + &db, + StartTrexWatcherInput { + repo_path: repository.path().to_string_lossy().into_owned(), + interval_secs: Some(10), + base_branch: Some("main".to_string()), + }, + ) + .expect("enable watcher"); + let watcher = enabled.watcher.expect("watcher receipt"); + assert!(watcher.enabled); + assert_eq!(watcher.interval_secs, MIN_INTERVAL_SECS); + assert_eq!(watcher.base_branch.as_deref(), Some("main")); + + let listed = list_trex_watchers_headless(&db).expect("list watchers"); + assert_eq!(listed.watchers.len(), 1); + assert_eq!(listed.schema_version, 1); + + let disabled = + disable_trex_watcher_headless(&db, &watcher.repo_path).expect("disable watcher"); + assert_eq!(disabled.operation, "disable"); + assert!(!disabled.watcher.expect("disabled watcher").enabled); + } + + #[test] + fn watcher_runs_new_and_updated_pr_heads_but_skips_unchanged_heads() { + assert!(pr_head_requires_run(None, "new-head")); + assert!(pr_head_requires_run(Some("previous-head"), "updated-head")); + assert!(!pr_head_requires_run(Some("same-head"), "same-head")); + } + + #[test] + fn watcher_accepts_only_exact_git_commit_identities() { + assert!(is_full_git_sha("0123456789abcdef0123456789abcdef01234567")); + assert!(is_full_git_sha("ABCDEF0123456789ABCDEF0123456789ABCDEF01")); + assert!(!is_full_git_sha("main")); + assert!(!is_full_git_sha("0123456789abcdef0123456789abcdef0123456")); + assert!(!is_full_git_sha( + "../../0123456789abcdef0123456789abcdef0123" + )); + } + + #[test] + fn watcher_token_resolution_ignores_empty_candidates() { + assert_eq!( + first_non_empty_token([ + None, + Some(" ".to_string()), + Some("gho_fixture".to_string()), + ]), + Some("gho_fixture".to_string()) + ); + assert_eq!(first_non_empty_token([None, Some(String::new())]), None); + } + + #[tokio::test] + async fn watcher_materializes_an_exact_pr_head_without_changing_the_checkout() { + let remote = tempfile::tempdir().expect("bare remote"); + run_git(remote.path(), &["init", "--bare"]); + + let seed = tempfile::tempdir().expect("seed repository"); + run_git(seed.path(), &["init"]); + run_git(seed.path(), &["config", "user.name", "CodeVetter Test"]); + run_git( + seed.path(), + &["config", "user.email", "codevetter@example.test"], + ); + std::fs::write(seed.path().join("fixture.txt"), "main\n").expect("main fixture"); + run_git(seed.path(), &["add", "fixture.txt"]); + run_git(seed.path(), &["commit", "-m", "main"]); + run_git(seed.path(), &["branch", "-M", "main"]); + run_git( + seed.path(), + &["remote", "add", "origin", &remote.path().to_string_lossy()], + ); + run_git(seed.path(), &["push", "origin", "main"]); + run_git(remote.path(), &["symbolic-ref", "HEAD", "refs/heads/main"]); + + std::fs::write(seed.path().join("fixture.txt"), "pull request\n").expect("PR fixture"); + run_git(seed.path(), &["commit", "-am", "pull request"]); + let head_sha = git_stdout(seed.path(), &["rev-parse", "HEAD"]); + run_git(seed.path(), &["push", "origin", "HEAD:refs/pull/7/head"]); + + let checkout_parent = tempfile::tempdir().expect("checkout parent"); + let checkout = checkout_parent.path().join("checkout"); + let remote_url = format!("file://{}", remote.path().display()); + run_git( + checkout_parent.path(), + &[ + "clone", + "--branch", + "main", + "--single-branch", + &remote_url, + &checkout.to_string_lossy(), + ], + ); + let before_head = git_stdout(&checkout, &["rev-parse", "HEAD"]); + assert!(!git_commit_exists(&checkout.to_string_lossy(), &head_sha) + .await + .expect("inspect missing PR head")); + + materialize_pr_head(&checkout.to_string_lossy(), 7, &head_sha) + .await + .expect("materialize exact PR head"); + + assert!(git_commit_exists(&checkout.to_string_lossy(), &head_sha) + .await + .expect("inspect fetched PR head")); + assert_eq!(git_stdout(&checkout, &["rev-parse", "HEAD"]), before_head); + assert!(git_stdout(&checkout, &["status", "--porcelain"]).is_empty()); + assert!(!checkout.join(".git").join("FETCH_HEAD").exists()); + } + + fn run_git(cwd: &Path, args: &[&str]) { + let output = std::process::Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .expect("run git command"); + assert!( + output.status.success(), + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + } + + fn git_stdout(cwd: &Path, args: &[&str]) -> String { + let output = std::process::Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .expect("run git command"); + assert!( + output.status.success(), + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() + } + + #[test] + fn headless_run_listing_is_bounded_and_repo_scoped() { + let repository = tempfile::tempdir().expect("temporary repository"); + std::fs::create_dir(repository.path().join(".git")).expect("fake git directory"); + let repo_path = canonical_repo_path(&repository.path().to_string_lossy()) + .expect("canonical repository"); + let db = test_db(); + for index in 0..3 { + insert_pr_run( + &db, + &TrexPrRun { + id: format!("run-{index}"), + repo_path: repo_path.clone(), + pr_number: 42, + head_sha: format!("sha-{index}"), + verdict: "APPROVE".to_string(), + confidence: 1.0, + summary: "qualified".to_string(), + status_state: Some("success".to_string()), + status_error: None, + duration_ms: 10, + ran_at: format!("2026-09-01T00:00:0{index}Z"), + }, + ) + .expect("insert run"); + } + let receipt = + list_trex_pr_runs_headless(&db, Some(&repo_path), 2).expect("list bounded runs"); + assert_eq!(receipt.runs.len(), 2); + assert!(receipt.runs.iter().all(|run| run.repo_path == repo_path)); + } } diff --git a/apps/desktop/src-tauri/src/commands/unpack.rs b/apps/desktop/src-tauri/src/commands/unpack.rs index e3c419c1..3af1691e 100644 --- a/apps/desktop/src-tauri/src/commands/unpack.rs +++ b/apps/desktop/src-tauri/src/commands/unpack.rs @@ -31,6 +31,7 @@ use crate::commands::unpack_scan::{ use crate::commands::unpack_snapshot::build_snapshot_commit_range; use crate::db::queries; use crate::DbState; +use serde::{Deserialize, Serialize}; #[allow(unused_imports)] use serde_json::{json, Value}; use std::collections::HashMap; @@ -47,6 +48,53 @@ const CLIENT_ALL_FILES_LIMIT: usize = 512; pub use crate::commands::unpack_types::*; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UnpackReportSummary { + pub id: String, + pub repo_path: String, + pub repo_name: String, + pub commit_sha: Option, + pub status: String, + pub error_message: Option, + pub agent_used: Option, + pub model_used: Option, + pub files_scanned: i64, + pub files_skipped: i64, + pub runtime_ms: Option, + pub cost_usd: Option, + pub started_at: Option, + pub completed_at: Option, + pub created_at: String, + pub analysis_ready: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UnpackExportReceipt { + pub schema_version: String, + pub report_id: String, + pub format: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct UnpackReportRecord { + #[serde(flatten)] + pub summary: UnpackReportSummary, + pub inventory_json: Option, + pub report_json: Option, + pub bytes_scanned: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnpackScanReceipt { + pub schema_version: String, + pub report_id: String, + pub status: String, + pub created_at: String, + pub inventory: RepoInventory, + pub profiles: Vec, +} + // ─── Tauri commands ───────────────────────────────────────────────────────── fn emit_unpack_progress( @@ -290,9 +338,17 @@ pub async fn list_repo_unpack_reports( limit: Option, ) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; - let limit = limit.unwrap_or(50); + let rows = list_repo_unpack_reports_from_connection(&conn, repo_path.as_deref(), limit)?; + Ok(json!({ "reports": rows })) +} - let rows: Vec = if let Some(path) = repo_path { +pub fn list_repo_unpack_reports_from_connection( + conn: &rusqlite::Connection, + repo_path: Option<&str>, + limit: Option, +) -> Result, String> { + let limit = limit.unwrap_or(50).clamp(1, 100); + let rows = if let Some(path) = repo_path { let mut stmt = conn .prepare( "SELECT id, repo_path, repo_name, commit_sha, status, error_message, @@ -308,7 +364,8 @@ pub async fn list_repo_unpack_reports( let iter = stmt .query_map(rusqlite::params![path, limit], row_to_summary) .map_err(|e| e.to_string())?; - iter.filter_map(Result::ok).collect() + iter.collect::, _>>() + .map_err(|e| e.to_string())? } else { let mut stmt = conn .prepare( @@ -324,16 +381,23 @@ pub async fn list_repo_unpack_reports( let iter = stmt .query_map(rusqlite::params![limit], row_to_summary) .map_err(|e| e.to_string())?; - iter.filter_map(Result::ok).collect() + iter.collect::, _>>() + .map_err(|e| e.to_string())? }; - - Ok(json!({ "reports": rows })) + Ok(rows) } #[tauri::command] pub async fn get_repo_unpack_report(db: State<'_, DbState>, id: String) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; + serde_json::to_value(get_repo_unpack_report_from_connection(&conn, &id)?) + .map_err(|e| e.to_string()) +} +pub fn get_repo_unpack_report_from_connection( + conn: &rusqlite::Connection, + id: &str, +) -> Result { let mut row = conn .query_row( "SELECT id, repo_path, repo_name, commit_sha, status, error_message, @@ -343,39 +407,14 @@ pub async fn get_repo_unpack_report(db: State<'_, DbState>, id: String) -> Resul FROM repo_unpacked_reports WHERE id = ?1", rusqlite::params![id], - |r| { - Ok(json!({ - "id": r.get::<_, String>(0)?, - "repo_path": r.get::<_, String>(1)?, - "repo_name": r.get::<_, String>(2)?, - "commit_sha": r.get::<_, Option>(3)?, - "status": r.get::<_, String>(4)?, - "error_message": r.get::<_, Option>(5)?, - "agent_used": r.get::<_, Option>(6)?, - "model_used": r.get::<_, Option>(7)?, - "inventory_json": r.get::<_, Option>(8)?, - "report_json": r.get::<_, Option>(9)?, - "files_scanned": r.get::<_, i64>(10)?, - "files_skipped": r.get::<_, i64>(11)?, - "bytes_scanned": r.get::<_, i64>(12)?, - "runtime_ms": r.get::<_, Option>(13)?, - "cost_usd": r.get::<_, Option>(14)?, - "started_at": r.get::<_, Option>(15)?, - "completed_at": r.get::<_, Option>(16)?, - "created_at": r.get::<_, String>(17)?, - })) - }, + row_to_record, ) .map_err(|e| format!("Report not found: {e}"))?; - if let Some(inv_json) = row - .get("inventory_json") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { + if let Some(inv_json) = row.inventory_json.as_deref().filter(|s| !s.is_empty()) { if let Ok(inv) = serde_json::from_str::(inv_json) { if let Ok(trimmed) = serde_json::to_string(&trim_inventory_for_client(inv)) { - row["inventory_json"] = json!(trimmed); + row.inventory_json = Some(trimmed); } } } @@ -402,6 +441,14 @@ pub async fn compare_unpack_snapshot_commits( .map_err(|e| format!("snapshot comparison task join error: {e}"))? } +pub fn compare_unpack_snapshot_commits_headless( + repo_path: &str, + base_commit: &str, + head_commit: &str, +) -> Result { + build_snapshot_commit_range(repo_path, base_commit, head_commit, 24) +} + #[tauri::command] pub async fn get_unpack_outcome_evidence( db: State<'_, DbState>, @@ -438,6 +485,28 @@ pub async fn export_repo_unpack_report( format: String, ) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; + serde_json::to_value(export_repo_unpack_report_from_connection( + &conn, &id, &format, + )?) + .map_err(|error| error.to_string()) +} + +pub fn export_repo_unpack_report_from_connection( + conn: &rusqlite::Connection, + id: &str, + format: &str, +) -> Result { + let id = id.trim(); + if id.is_empty() || id.len() > 128 || id.chars().any(char::is_control) { + return Err("report_id must contain 1-128 non-control characters".to_string()); + } + let format = format.trim(); + if !matches!( + format, + "markdown" | "html" | "repo_graph_json" | "agent_context_markdown" | "repo_memory_markdown" + ) { + return Err(format!("Unsupported Repo Unpack export format '{format}'")); + } let (repo_name, report_json, inventory_json, created_at, agent_used, model_used) = conn .query_row( "SELECT repo_name, report_json, inventory_json, created_at, @@ -474,7 +543,7 @@ pub async fn export_repo_unpack_report( inventory.as_ref(), ); - let content = match format.as_str() { + let content = match format { "html" => render_html(&repo_name, &body), "repo_graph_json" => { let Some(inventory) = inventory.as_ref() else { @@ -506,7 +575,7 @@ pub async fn export_repo_unpack_report( history_files.extend(inventory.all_files.iter().take(100).cloned()); let history_files = history_files.into_iter().take(100).collect::>(); let temporal_history = crate::commands::history_query::build_review_history_slice( - &conn, + conn, &inventory.repo_path, &history_files, ) @@ -524,10 +593,16 @@ pub async fn export_repo_unpack_report( }; render_repo_memory_markdown(&repo_name, &created_at, inventory, Some(&report)) } - _ => body, + "markdown" => body, + _ => unreachable!("export format is validated before rendering"), }; - Ok(json!({ "content": content, "format": format })) + Ok(UnpackExportReceipt { + schema_version: "codevetter.unpack-export/v1".to_string(), + report_id: id.to_string(), + format: format.to_string(), + content, + }) } // ─── Inventory builder (deterministic) ────────────────────────────────────── @@ -859,6 +934,91 @@ pub fn build_inventory_with_progress( }) } +/// Persist one deterministic inventory build without depending on Tauri state. +/// +/// Tauri and the standalone CLI both call this boundary so native scan receipts +/// retain one identity, schema, and SQLite write path. Agent synthesis remains a +/// separate, explicitly invoked operation. +pub fn persist_unpack_scan_snapshot_from_connection( + conn: &rusqlite::Connection, + report_id: String, + build: InventoryBuildResult, +) -> Result { + let report_id = report_id.trim().to_string(); + if report_id.is_empty() || report_id.len() > 128 || report_id.chars().any(char::is_control) { + return Err("report_id must contain 1-128 non-control characters".to_string()); + } + + let InventoryBuildResult { + inventory, + profile: build_profile, + } = build; + let mut persist_profiler = + super::unpack_scan_profile::UnpackScanProfiler::new("local_scan_persist"); + let inventory_json = serde_json::to_string(&inventory).map_err(|error| error.to_string())?; + persist_profiler.step("serialize", "JSON serialize (inventory → SQLite)"); + let created_at = chrono::Utc::now().to_rfc3339(); + + crate::db::with_busy_retry( + || { + conn.execute( + "INSERT INTO repo_unpacked_reports + (id, repo_path, repo_name, commit_sha, status, inventory_json, + files_scanned, files_skipped, bytes_scanned, started_at, completed_at, created_at) + VALUES (?1, ?2, ?3, ?4, 'scan_only', ?5, ?6, ?7, ?8, ?9, ?9, ?9)", + rusqlite::params![ + &report_id, + &inventory.repo_path, + &inventory.repo_name, + &inventory.commit_sha, + &inventory_json, + inventory.files_scanned as i64, + inventory.files_skipped as i64, + inventory.bytes_scanned as i64, + &created_at, + ], + ) + }, + 15, + ) + .map_err(|error| error.to_string())?; + persist_profiler.step("db_insert", "SQLite insert"); + + conn.execute( + "UPDATE repo_projects SET last_unpack_at = ?2 WHERE repo_path = ?1", + rusqlite::params![&inventory.repo_path, &created_at], + ) + .map_err(|error| error.to_string())?; + persist_profiler.step("touch_project", "Update repo project metadata"); + + Ok(UnpackScanReceipt { + schema_version: "codevetter.unpack-scan/v1".to_string(), + report_id, + status: "scan_only".to_string(), + created_at, + inventory: trim_inventory_for_client(inventory), + profiles: vec![build_profile, persist_profiler.finish()], + }) +} + +pub fn scan_and_persist_unpack_snapshot( + conn: &rusqlite::Connection, + repo_path: &str, + report_id: Option, + progress: Option, +) -> Result { + let repo_path = repo_path.trim(); + if repo_path.is_empty() { + return Err("repo_path is required".to_string()); + } + let build = build_inventory_with_progress(repo_path, progress, InventoryBuildProfile::Full)?; + persist_unpack_scan_snapshot_from_connection( + conn, + report_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()), + build, + ) +} + fn read_git_metadata(root: &Path) -> (Option, Option, Option) { if let Some(metadata) = read_git_metadata_from_files(root) { return metadata; @@ -1777,25 +1937,51 @@ fn mark_unpack_failed( } } -fn row_to_summary(r: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(json!({ - "id": r.get::<_, String>(0)?, - "repo_path": r.get::<_, String>(1)?, - "repo_name": r.get::<_, String>(2)?, - "commit_sha": r.get::<_, Option>(3)?, - "status": r.get::<_, String>(4)?, - "error_message": r.get::<_, Option>(5)?, - "agent_used": r.get::<_, Option>(6)?, - "model_used": r.get::<_, Option>(7)?, - "files_scanned": r.get::<_, i64>(8)?, - "files_skipped": r.get::<_, i64>(9)?, - "runtime_ms": r.get::<_, Option>(10)?, - "cost_usd": r.get::<_, Option>(11)?, - "started_at": r.get::<_, Option>(12)?, - "completed_at": r.get::<_, Option>(13)?, - "created_at": r.get::<_, String>(14)?, - "analysis_ready": r.get::<_, bool>(15)?, - })) +fn row_to_summary(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(UnpackReportSummary { + id: r.get(0)?, + repo_path: r.get(1)?, + repo_name: r.get(2)?, + commit_sha: r.get(3)?, + status: r.get(4)?, + error_message: r.get(5)?, + agent_used: r.get(6)?, + model_used: r.get(7)?, + files_scanned: r.get(8)?, + files_skipped: r.get(9)?, + runtime_ms: r.get(10)?, + cost_usd: r.get(11)?, + started_at: r.get(12)?, + completed_at: r.get(13)?, + created_at: r.get(14)?, + analysis_ready: r.get(15)?, + }) +} + +fn row_to_record(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(UnpackReportRecord { + summary: UnpackReportSummary { + id: r.get(0)?, + repo_path: r.get(1)?, + repo_name: r.get(2)?, + commit_sha: r.get(3)?, + status: r.get(4)?, + error_message: r.get(5)?, + agent_used: r.get(6)?, + model_used: r.get(7)?, + files_scanned: r.get(10)?, + files_skipped: r.get(11)?, + runtime_ms: r.get(13)?, + cost_usd: r.get(14)?, + started_at: r.get(15)?, + completed_at: r.get(16)?, + created_at: r.get(17)?, + analysis_ready: r.get::<_, Option>(9)?.is_some(), + }, + inventory_json: r.get(8)?, + report_json: r.get(9)?, + bytes_scanned: r.get(12)?, + }) } #[cfg(test)] diff --git a/apps/desktop/src-tauri/src/commands/unpack_tests.rs b/apps/desktop/src-tauri/src/commands/unpack_tests.rs index 4f4b2392..f6d187eb 100644 --- a/apps/desktop/src-tauri/src/commands/unpack_tests.rs +++ b/apps/desktop/src-tauri/src/commands/unpack_tests.rs @@ -922,3 +922,141 @@ fn opportunistic_unpack_db_lock_does_not_wait() { drop(guard); assert!(lock_unpack_db(&db, true).is_ok()); } + +#[test] +fn stored_unpack_projection_preserves_identity_and_bounds_inventory_payload() { + let connection = rusqlite::Connection::open_in_memory().expect("memory db"); + connection + .execute_batch( + "CREATE TABLE repo_unpacked_reports ( + id TEXT PRIMARY KEY, repo_path TEXT NOT NULL, repo_name TEXT NOT NULL, + commit_sha TEXT, status TEXT NOT NULL, error_message TEXT, + agent_used TEXT, model_used TEXT, inventory_json TEXT, report_json TEXT, + files_scanned INTEGER NOT NULL, files_skipped INTEGER NOT NULL, + bytes_scanned INTEGER NOT NULL, runtime_ms INTEGER, cost_usd REAL, + started_at TEXT, completed_at TEXT, created_at TEXT NOT NULL + );", + ) + .expect("schema"); + let mut inventory = minimal_inventory(); + inventory.files_scanned = 700; + inventory.all_files = (0..700) + .map(|index| format!("src/file-{index}.rs")) + .collect(); + connection + .execute( + "INSERT INTO repo_unpacked_reports VALUES + (?1, ?2, ?3, ?4, 'scan_only', NULL, NULL, NULL, ?5, NULL, + 700, 3, 42000, 12, NULL, ?6, ?6, ?6)", + rusqlite::params![ + "snapshot-1", + "/tmp/demo", + "demo", + "1234567890abcdef", + serde_json::to_string(&inventory).expect("inventory"), + "2026-08-31T00:00:00Z", + ], + ) + .expect("insert"); + + let summaries = + list_repo_unpack_reports_from_connection(&connection, Some("/tmp/demo"), Some(200)) + .expect("summaries"); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].id, "snapshot-1"); + assert!(!summaries[0].analysis_ready); + + let record = + get_repo_unpack_report_from_connection(&connection, "snapshot-1").expect("snapshot record"); + assert_eq!( + record.summary.commit_sha.as_deref(), + Some("1234567890abcdef") + ); + let projected: RepoInventory = serde_json::from_str( + record + .inventory_json + .as_deref() + .expect("inventory projection"), + ) + .expect("projected inventory"); + assert!(projected.all_files.is_empty()); + assert!(projected.all_files_capped); + assert_eq!(projected.files_scanned, 700); +} + +#[test] +fn shared_unpack_scan_persistence_emits_one_bounded_receipt_for_cli_and_tauri() { + let connection = rusqlite::Connection::open_in_memory().expect("memory db"); + connection + .execute_batch( + "CREATE TABLE repo_unpacked_reports ( + id TEXT PRIMARY KEY, repo_path TEXT NOT NULL, repo_name TEXT NOT NULL, + commit_sha TEXT, status TEXT NOT NULL, error_message TEXT, + agent_used TEXT, model_used TEXT, inventory_json TEXT, report_json TEXT, + files_scanned INTEGER NOT NULL, files_skipped INTEGER NOT NULL, + bytes_scanned INTEGER NOT NULL, runtime_ms INTEGER, cost_usd REAL, + started_at TEXT, completed_at TEXT, created_at TEXT NOT NULL + ); + CREATE TABLE repo_projects ( + repo_path TEXT PRIMARY KEY, + last_unpack_at TEXT + ); + INSERT INTO repo_projects (repo_path) VALUES ('/tmp/demo');", + ) + .expect("schema"); + let mut inventory = minimal_inventory(); + inventory.files_scanned = 700; + inventory.all_files = (0..700) + .map(|index| format!("src/file-{index}.rs")) + .collect(); + let mut profiler = crate::commands::unpack_scan_profile::UnpackScanProfiler::new("full_scan"); + profiler.step("fixture", "Fixture scan"); + + let receipt = persist_unpack_scan_snapshot_from_connection( + &connection, + "scan-shared-1".to_string(), + InventoryBuildResult { + inventory, + profile: profiler.finish(), + }, + ) + .expect("persist shared scan"); + + assert_eq!(receipt.schema_version, "codevetter.unpack-scan/v1"); + assert_eq!(receipt.report_id, "scan-shared-1"); + assert_eq!(receipt.status, "scan_only"); + assert!(receipt.inventory.all_files.is_empty()); + assert!(receipt.inventory.all_files_capped); + assert_eq!(receipt.profiles.len(), 2); + assert_eq!(receipt.profiles[0].stage, "full_scan"); + assert_eq!(receipt.profiles[1].stage, "local_scan_persist"); + let export = export_repo_unpack_report_from_connection( + &connection, + "scan-shared-1", + "repo_memory_markdown", + ) + .expect("export shared scan"); + assert_eq!(export.schema_version, "codevetter.unpack-export/v1"); + assert_eq!(export.report_id, "scan-shared-1"); + assert_eq!(export.format, "repo_memory_markdown"); + assert!(export.content.contains("Repo Memory")); + assert!( + export_repo_unpack_report_from_connection(&connection, "scan-shared-1", "pdf").is_err() + ); + assert_eq!( + list_repo_unpack_reports_from_connection(&connection, Some("/tmp/demo"), Some(10)) + .expect("list persisted scan")[0] + .id, + "scan-shared-1" + ); + assert!(persist_unpack_scan_snapshot_from_connection( + &connection, + "\n".to_string(), + InventoryBuildResult { + inventory: minimal_inventory(), + profile: crate::commands::unpack_scan_profile::UnpackScanProfiler::new("full_scan") + .finish(), + }, + ) + .is_err()); +} diff --git a/apps/desktop/src-tauri/src/commands/warm_verification_bridge.rs b/apps/desktop/src-tauri/src/commands/warm_verification_bridge.rs index a26ad61f..49aa134c 100644 --- a/apps/desktop/src-tauri/src/commands/warm_verification_bridge.rs +++ b/apps/desktop/src-tauri/src/commands/warm_verification_bridge.rs @@ -736,6 +736,23 @@ pub async fn prepare_differential_verification( reference_revision: String, candidate_kind: String, candidate_revision: Option, +) -> Result { + prepare_differential_verification_headless( + repo_path, + run_id, + reference_revision, + candidate_kind, + candidate_revision, + ) + .await +} + +pub async fn prepare_differential_verification_headless( + repo_path: String, + run_id: String, + reference_revision: String, + candidate_kind: String, + candidate_revision: Option, ) -> Result { if !valid_id(&run_id) || !valid_bounded_text(&reference_revision) { return Err("Differential run identity or reference is invalid".into()); @@ -916,6 +933,12 @@ async fn run_differential_cli( #[tauri::command] pub async fn get_warm_verification_daemon_health( repo_path: String, +) -> Result, String> { + get_warm_verification_daemon_health_headless(repo_path).await +} + +pub async fn get_warm_verification_daemon_health_headless( + repo_path: String, ) -> Result, String> { let package = find_verify_package(&repo_path)?; let output = execute_verify( @@ -940,6 +963,12 @@ pub async fn get_warm_verification_daemon_health( #[tauri::command] pub async fn start_warm_verification_daemon(repo_path: String) -> Result { + start_warm_verification_daemon_headless(repo_path).await +} + +pub async fn start_warm_verification_daemon_headless( + repo_path: String, +) -> Result { let package = find_verify_package(&repo_path)?; let output = execute_verify( &package, @@ -954,6 +983,12 @@ pub async fn start_warm_verification_daemon(repo_path: String) -> Result Result { + stop_warm_verification_daemon_headless(repo_path).await +} + +pub async fn stop_warm_verification_daemon_headless( + repo_path: String, +) -> Result { let value = run_cli(&repo_path, &["daemon", "stop"], STOP_TIMEOUT).await?; let active_run_ids = response_payload(value, "shutdown_ack", "active_run_ids")?; let active_run_ids: Vec = serde_json::from_value(active_run_ids) @@ -970,6 +1005,15 @@ pub async fn run_warm_changed_verification( repo_path: String, detailed_capture: bool, run_id: String, +) -> Result { + run_warm_changed_verification_headless(db.inner(), repo_path, detailed_capture, run_id).await +} + +pub async fn run_warm_changed_verification_headless( + db: &DbState, + repo_path: String, + detailed_capture: bool, + run_id: String, ) -> Result { if !valid_id(&run_id) { return Err("Run identity is invalid".into()); @@ -1009,6 +1053,25 @@ pub async fn run_differential_verification( reference_revision: String, candidate_kind: String, candidate_revision: Option, +) -> Result { + run_differential_verification_headless( + db.inner(), + repo_path, + run_id, + reference_revision, + candidate_kind, + candidate_revision, + ) + .await +} + +pub async fn run_differential_verification_headless( + db: &DbState, + repo_path: String, + run_id: String, + reference_revision: String, + candidate_kind: String, + candidate_revision: Option, ) -> Result { if !valid_id(&run_id) || !valid_bounded_text(&reference_revision) { return Err("Differential run identity or reference is invalid".into()); @@ -1050,6 +1113,13 @@ pub async fn run_differential_verification( pub async fn cleanup_differential_verification_artifacts( repo_path: String, dry_run: bool, +) -> Result { + cleanup_differential_verification_artifacts_headless(repo_path, dry_run).await +} + +pub async fn cleanup_differential_verification_artifacts_headless( + repo_path: String, + dry_run: bool, ) -> Result { let command = if dry_run { vec!["differential", "cleanup", "--dry-run"] @@ -1071,6 +1141,13 @@ pub async fn cleanup_differential_verification_artifacts( pub async fn cancel_warm_verification_run( repo_path: String, run_id: String, +) -> Result { + cancel_warm_verification_run_headless(repo_path, run_id).await +} + +pub async fn cancel_warm_verification_run_headless( + repo_path: String, + run_id: String, ) -> Result { if !valid_id(&run_id) { return Err("Run identity is invalid".into()); @@ -1086,6 +1163,13 @@ pub async fn cancel_warm_verification_run( pub async fn cancel_differential_verification_run( repo_path: String, run_id: String, +) -> Result { + cancel_differential_verification_run_headless(repo_path, run_id).await +} + +pub async fn cancel_differential_verification_run_headless( + repo_path: String, + run_id: String, ) -> Result { if !valid_id(&run_id) { return Err("Differential run identity is invalid".into()); @@ -1114,6 +1198,13 @@ pub async fn cancel_differential_verification_run( pub async fn cleanup_warm_verification_artifacts( repo_path: String, dry_run: bool, +) -> Result { + cleanup_warm_verification_artifacts_headless(repo_path, dry_run).await +} + +pub async fn cleanup_warm_verification_artifacts_headless( + repo_path: String, + dry_run: bool, ) -> Result { let command = if dry_run { vec!["cleanup", "--dry-run"] @@ -1132,6 +1223,12 @@ pub async fn cleanup_warm_verification_artifacts( #[tauri::command] pub async fn get_current_warm_verification_identity( repo_path: String, +) -> Result { + get_current_warm_verification_identity_headless(repo_path).await +} + +pub async fn get_current_warm_verification_identity_headless( + repo_path: String, ) -> Result { let value = run_cli(&repo_path, &["current"], STOP_TIMEOUT).await?; let identity: CurrentWarmVerificationIdentity = serde_json::from_value(value) diff --git a/apps/desktop/src-tauri/src/commands/xray.rs b/apps/desktop/src-tauri/src/commands/xray.rs index f402920b..bb320778 100644 --- a/apps/desktop/src-tauri/src/commands/xray.rs +++ b/apps/desktop/src-tauri/src/commands/xray.rs @@ -425,6 +425,13 @@ fn build(conn: &rusqlite::Connection, request: XrayRequest) -> Result Result { + build(conn, request) +} + fn scan_payload(payload: &AgentPrXray) -> Vec { let serialized = serde_json::to_string(payload).unwrap_or_default(); let lower = serialized.to_ascii_lowercase(); @@ -717,12 +724,11 @@ pub async fn build_agent_pr_xray( request: XrayRequest, ) -> Result { let conn = db.0.lock().map_err(|error| error.to_string())?; - build(&conn, request) + build_agent_pr_xray_from_connection(&conn, request) } -#[tauri::command] -pub async fn save_agent_pr_xray( - db: State<'_, DbState>, +pub fn save_agent_pr_xray_to_path( + conn: &rusqlite::Connection, request: SaveXrayRequest, ) -> Result { let path = Path::new(request.path.trim()); @@ -744,10 +750,7 @@ pub async fn save_agent_pr_xray( .parent() .ok_or("X-Ray destination needs a parent directory")?; fs::canonicalize(parent).map_err(|_| "X-Ray destination directory is unavailable")?; - let result = { - let conn = db.0.lock().map_err(|error| error.to_string())?; - build(&conn, request.xray)? - }; + let result = build_agent_pr_xray_from_connection(conn, request.xray)?; if !result.eligible { return Err(format!( "X-Ray export is blocked: {}", @@ -774,6 +777,15 @@ pub async fn save_agent_pr_xray( Ok(path.to_string_lossy().into_owned()) } +#[tauri::command] +pub async fn save_agent_pr_xray( + db: State<'_, DbState>, + request: SaveXrayRequest, +) -> Result { + let conn = db.0.lock().map_err(|error| error.to_string())?; + save_agent_pr_xray_to_path(&conn, request) +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/desktop/src-tauri/src/db/queries.rs b/apps/desktop/src-tauri/src/db/queries.rs index fb59c079..314d5d08 100644 --- a/apps/desktop/src-tauri/src/db/queries.rs +++ b/apps/desktop/src-tauri/src/db/queries.rs @@ -3107,6 +3107,17 @@ pub fn get_agent_usage_by_day( let since = (Local::now().date_naive() - Duration::days(days.max(1) - 1)) .format("%Y-%m-%d") .to_string(); + get_agent_usage_by_day_since(conn, &since) +} + +/// Per-day, per-agent usage at or after an explicit local-calendar date. +/// +/// The explicit boundary keeps UI/CLI projections and deterministic tests on +/// the same attribution contract without depending on the process clock. +pub fn get_agent_usage_by_day_since( + conn: &Connection, + since: &str, +) -> Result, rusqlite::Error> { let mut stmt = conn.prepare( "WITH session_total AS ( SELECT session_id, SUM(msg_count) AS total_n @@ -3161,6 +3172,31 @@ pub fn get_agent_usage_by_day( Ok(rows) } +/// Count distinct sessions with attributed activity at or after `since`. +/// `None` returns the exact all-time session count, including legacy sessions +/// that do not have per-day attribution rows. +pub fn get_agent_session_count_since( + conn: &Connection, + agent_type: &str, + since: Option<&str>, +) -> Result { + match since { + Some(since) => conn.query_row( + "SELECT COUNT(DISTINCT s.id) + FROM cc_sessions s + JOIN cc_session_days d ON d.session_id = s.id + WHERE s.agent_type = ?1 AND d.day >= ?2", + params![agent_type, since], + |row| row.get(0), + ), + None => conn.query_row( + "SELECT COUNT(*) FROM cc_sessions WHERE agent_type = ?1", + params![agent_type], + |row| row.get(0), + ), + } +} + /// One model's token usage within one session (row shape for /// `session_model_usage`). `input_tokens` includes cache read/creation tokens, /// mirroring the cc_sessions totals. @@ -3686,11 +3722,13 @@ mod tests { disposition: "accepted".into(), }; assert_eq!( - append_codex_usage_observations(&conn, "s", &[observation.clone()]).unwrap(), + append_codex_usage_observations(&conn, "s", std::slice::from_ref(&observation)) + .unwrap(), 1 ); assert_eq!( - append_codex_usage_observations(&conn, "s", &[observation.clone()]).unwrap(), + append_codex_usage_observations(&conn, "s", std::slice::from_ref(&observation)) + .unwrap(), 0 ); reconcile_codex_usage_totals(&conn, "s").expect("reconcile"); @@ -3777,7 +3815,7 @@ mod tests { commit_codex_usage_batch( &conn, &source, - &[observation.clone()], + std::slice::from_ref(&observation), Some(&checkpoint), &coverage, ) @@ -3881,8 +3919,14 @@ mod tests { observation_watermark: source.last_observed_at.clone(), }; assert_eq!( - commit_codex_usage_batch(&conn, &source, &[observation.clone()], None, &coverage) - .expect("first commit"), + commit_codex_usage_batch( + &conn, + &source, + std::slice::from_ref(&observation), + None, + &coverage, + ) + .expect("first commit"), 1 ); assert_eq!( diff --git a/apps/desktop/src-tauri/src/db/verification_workbench_schema.rs b/apps/desktop/src-tauri/src/db/verification_workbench_schema.rs index 940687f6..60450693 100644 --- a/apps/desktop/src-tauri/src/db/verification_workbench_schema.rs +++ b/apps/desktop/src-tauri/src/db/verification_workbench_schema.rs @@ -196,10 +196,30 @@ pub fn run_migration(conn: &Connection) -> Result<(), rusqlite::Error> { CREATE INDEX IF NOT EXISTS idx_local_performance_receipts_kind ON local_performance_receipts(receipt_kind, created_at DESC); + CREATE TABLE IF NOT EXISTS local_check_runs ( + run_id TEXT PRIMARY KEY, + schema_version TEXT NOT NULL, + repo_path TEXT NOT NULL, + base_sha TEXT NOT NULL, + head_sha TEXT NOT NULL, + verdict TEXT NOT NULL, + task TEXT NOT NULL, + receipt_json TEXT NOT NULL, + ran_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_local_check_runs_repo_time + ON local_check_runs(repo_path, ran_at DESC); + INSERT OR IGNORE INTO verification_workbench_schema_migrations (version, migration_identity, applied_at) VALUES (1, 'verification-workbench-v1', strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); + + INSERT OR IGNORE INTO verification_workbench_schema_migrations + (version, migration_identity, applied_at) + VALUES + (2, 'verification-workbench-local-check-runs-v2', strftime('%Y-%m-%dT%H:%M:%fZ', 'now')); "#, )?; @@ -255,6 +275,7 @@ mod tests { "managed_work_checkpoints", "intent_closure_receipts", "local_performance_receipts", + "local_check_runs", ] { assert!(table_exists(&conn, table), "missing {table}"); } @@ -268,6 +289,15 @@ mod tests { ) .expect("migration row"); assert_eq!(identity, "verification-workbench-v1"); + let run_identity: String = conn + .query_row( + "SELECT migration_identity + FROM verification_workbench_schema_migrations WHERE version = 2", + [], + |row| row.get(0), + ) + .expect("local-check migration row"); + assert_eq!(run_identity, "verification-workbench-local-check-runs-v2"); } #[test] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index dcb58600..4e426685 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -9,6 +9,8 @@ //! repository interpretation. pub mod agent; +pub mod application; +pub mod capabilities; pub mod commands; pub mod db; pub mod mcp; diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 10621b90..48489232 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -528,6 +528,9 @@ fn main() { commands::review::set_finding_disposition, commands::review::list_reviews, commands::review::get_standards_pack_usage, + commands::rubric_settings::get_rubric_settings, + commands::rubric_settings::set_active_rubric_pack, + commands::rubric_settings::save_rubric_pack, commands::review::run_cli_review, commands::review::cancel_cli_review, commands::xray::build_agent_pr_xray, diff --git a/apps/desktop/src-tauri/src/mcp/contracts.rs b/apps/desktop/src-tauri/src/mcp/contracts.rs index 7325a444..1e5e6bea 100644 --- a/apps/desktop/src-tauri/src/mcp/contracts.rs +++ b/apps/desktop/src-tauri/src/mcp/contracts.rs @@ -5,10 +5,15 @@ use std::sync::Arc; pub(crate) fn tool_definitions() -> Vec { let specs = [ + ( + "capability_catalog", + "Return the canonical UI, CLI, and agent capability glossary and parity matrix", + &[] as &[&str], + ), ( "graph_query", "Search the canonical structural graph or return a compact overview", - &[] as &[&str], + &[], ), ( "graph_get_node", @@ -85,6 +90,21 @@ pub(crate) fn tool_definitions() -> Vec { "Prepare a bounded source-backed review packet for one exact repository change", &["task", "change"], ), + ( + "resolve_evidence_scope", + "Resolve one flow, exact change, or bounded codebase into deterministic testing or performance candidates without executing them", + &["consumer", "scope_kind"], + ), + ( + "qa_workspace_inspect", + "Inspect secret-safe saved QA workflows, discovered Playwright specs, and optional post-fix rerun setup without executing browser or project code", + &[], + ), + ( + "verification_get_receipt", + "Read one canonical persisted local-check receipt in this authorized repository scope without executing verification", + &["run_id"], + ), ( "review_list_manifests", "List bounded deterministic review coverage manifests for this authorized repository", @@ -153,6 +173,7 @@ fn input_schema(name: &str, required: &[&str]) -> Arc { "cursor", "rule_id", "review_id", + "run_id", "task", ] { properties.insert( @@ -160,10 +181,31 @@ fn input_schema(name: &str, required: &[&str]) -> Arc { json!({"type": "string", "maxLength": 4096}), ); } + properties.insert( + "run_id".to_string(), + json!({ + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$" + }), + ); properties.insert( "change".to_string(), json!({"type": "string", "minLength": 1, "maxLength": 512}), ); + properties.insert( + "consumer".to_string(), + json!({"type": "string", "enum": ["testing", "performance"]}), + ); + properties.insert( + "scope_kind".to_string(), + json!({"type": "string", "enum": ["flow", "change", "codebase"]}), + ); + properties.insert( + "scope_value".to_string(), + json!({"type": "string", "minLength": 1, "maxLength": 512}), + ); properties.insert( "limit".to_string(), json!({"type": "integer", "minimum": 1, "maximum": MAX_PAGE_SIZE}), @@ -370,6 +412,7 @@ fn output_schema() -> Arc { pub(crate) fn tool_fields(name: &str) -> Option<&'static [&'static str]> { Some(match name { + "capability_catalog" => &[], "graph_query" => &["query", "filter", "limit", "cursor"], "graph_get_node" => &["node"], "graph_get_neighbors" => &["node", "direction", "filter", "limit", "cursor"], @@ -386,6 +429,9 @@ pub(crate) fn tool_fields(name: &str) -> Option<&'static [&'static str]> { "history_compare" => &["before", "after"], "history_get_evidence" => &["ids"], "prepare_review" => &["task", "change"], + "resolve_evidence_scope" => &["consumer", "scope_kind", "scope_value"], + "qa_workspace_inspect" => &["fix_completed_at"], + "verification_get_receipt" => &["run_id"], "review_list_manifests" => &["review_id", "limit", "cursor"], "archaeology_list_rules" => &["filter", "limit", "cursor"], "archaeology_list_domains" => &["limit", "cursor"], diff --git a/apps/desktop/src-tauri/src/mcp/server/mod.rs b/apps/desktop/src-tauri/src/mcp/server/mod.rs index e298ae8f..24def6a2 100644 --- a/apps/desktop/src-tauri/src/mcp/server/mod.rs +++ b/apps/desktop/src-tauri/src/mcp/server/mod.rs @@ -6,6 +6,7 @@ use crate::{ history_read::{ contributors::HistoryContributorScope, HistoryReadService, HistorySearchKind, }, + local_check::get_local_check_receipt, mcp_access::{record_mcp_audit, require_enabled_scope}, structural_graph::{ query::{GraphDirection, GraphQueryFilter}, diff --git a/apps/desktop/src-tauri/src/mcp/server/prepare_review.rs b/apps/desktop/src-tauri/src/mcp/server/prepare_review.rs index 8d9ae8ad..e78a42a4 100644 --- a/apps/desktop/src-tauri/src/mcp/server/prepare_review.rs +++ b/apps/desktop/src-tauri/src/mcp/server/prepare_review.rs @@ -168,6 +168,31 @@ fn verification_scope( })) } +pub(super) fn resolve_agent_evidence_scope( + repo_path: &str, + consumer: &str, + kind: &str, + value: Option<&str>, +) -> Result { + let consumer = match consumer { + "testing" => EvidenceScopeConsumer::Testing, + "performance" => EvidenceScopeConsumer::Performance, + _ => return Err("Evidence-scope consumer must be testing or performance".to_string()), + }; + let kind = match kind { + "flow" => EvidenceScopeKind::Flow, + "change" => EvidenceScopeKind::Change, + "codebase" => EvidenceScopeKind::Codebase, + _ => return Err("Evidence-scope kind must be flow, change, or codebase".to_string()), + }; + tauri::async_runtime::block_on(resolve_evidence_scope(EvidenceScopeInput { + repo_path: repo_path.to_string(), + kind, + value: value.map(str::to_string), + consumer, + })) +} + fn scope_value(scope: Result) -> Value { match scope { Ok(plan) => json!({"status": "ready", "plan": plan}), diff --git a/apps/desktop/src-tauri/src/mcp/server/tests.rs b/apps/desktop/src-tauri/src/mcp/server/tests.rs index f0a50300..0c63a65e 100644 --- a/apps/desktop/src-tauri/src/mcp/server/tests.rs +++ b/apps/desktop/src-tauri/src/mcp/server/tests.rs @@ -10,6 +10,19 @@ use rmcp::{ClientHandler, ServiceExt}; use rusqlite::params; use std::{fs, process::Command}; +const SURFACE_PARITY_FIXTURE: &str = + include_str!("../../../tests/fixtures/surface-parity/evidence-scope-v1.json"); +const LOCAL_CHECK_PARITY_FIXTURE: &str = + include_str!("../../../tests/fixtures/surface-parity/local-check-v1.json"); + +fn surface_parity_fixture() -> Value { + serde_json::from_str(SURFACE_PARITY_FIXTURE).expect("surface parity fixture") +} + +fn local_check_parity_fixture() -> Value { + serde_json::from_str(LOCAL_CHECK_PARITY_FIXTURE).expect("local-check parity fixture") +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn timed_out_sql_workers_release_all_query_capacity() { let semaphore = Arc::new(Semaphore::new(4)); @@ -59,6 +72,9 @@ async fn timed_out_sql_workers_release_all_query_capacity() { #[test] fn every_tool_is_explicitly_read_only_and_schema_bounded() { + let fixture = surface_parity_fixture(); + assert_eq!(fixture["authority"]["mcp"], "read_only_projection"); + assert_eq!(fixture["authority"]["mcp_may_execute"], false); let tools = tool_definitions(); assert_eq!( tools @@ -66,6 +82,7 @@ fn every_tool_is_explicitly_read_only_and_schema_bounded() { .map(|tool| tool.name.as_ref()) .collect::>(), vec![ + "capability_catalog", "graph_query", "graph_get_node", "graph_get_neighbors", @@ -82,6 +99,9 @@ fn every_tool_is_explicitly_read_only_and_schema_bounded() { "history_compare", "history_get_evidence", "prepare_review", + "resolve_evidence_scope", + "qa_workspace_inspect", + "verification_get_receipt", "review_list_manifests", "archaeology_list_rules", "archaeology_list_domains", @@ -147,13 +167,28 @@ impl ClientHandler for TestClient {} #[tokio::test] async fn protocol_lifecycle_is_scoped_structured_and_live_revocable() { let fixture = tempfile::tempdir().expect("fixture"); + let surface_fixture = surface_parity_fixture(); + let local_check_fixture = local_check_parity_fixture(); let repo = fixture.path().join("repo"); fs::create_dir(&repo).expect("repo"); git(&repo, &["init"]); git(&repo, &["config", "user.email", "fixture@codevetter.local"]); git(&repo, &["config", "user.name", "CodeVetter Fixture"]); fs::write(repo.join("main.rs"), "fn main() {}\n").expect("source"); - git(&repo, &["add", "main.rs"]); + for (relative_path, content) in surface_fixture["repository"]["files"] + .as_object() + .expect("surface parity files") + { + let path = repo.join(relative_path); + fs::create_dir_all(path.parent().expect("surface fixture parent")) + .expect("surface fixture directory"); + fs::write( + path, + content.as_str().expect("surface fixture file content"), + ) + .expect("surface fixture file"); + } + git(&repo, &["add", "."]); git(&repo, &["commit", "-m", "fixture release"]); git(&repo, &["tag", "v1.0.0"]); let head = git_output(&repo, &["rev-parse", "HEAD"]); @@ -221,6 +256,33 @@ async fn protocol_lifecycle_is_scoped_structured_and_live_revocable() { params![repo_path, repo_id, "2026-01-01T00:00:00Z"], ) .expect("scope"); + let mut canonical_local_check = local_check_fixture["canonical_receipt"].clone(); + canonical_local_check["repo_path"] = Value::String(repo_path.clone()); + connection + .execute( + "INSERT INTO local_check_runs ( + run_id, schema_version, repo_path, base_sha, head_sha, + verdict, task, receipt_json, ran_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + canonical_local_check["run_id"].as_str().expect("run id"), + canonical_local_check["schema_version"] + .as_str() + .expect("receipt schema"), + repo_path, + canonical_local_check["source"]["base_sha"] + .as_str() + .expect("base sha"), + canonical_local_check["source"]["head_sha"] + .as_str() + .expect("head sha"), + canonical_local_check["verdict"].as_str().expect("verdict"), + canonical_local_check["task"].as_str().expect("task"), + serde_json::to_string(&canonical_local_check).expect("receipt JSON"), + canonical_local_check["ran_at"].as_str().expect("run time"), + ], + ) + .expect("local-check parity receipt"); persist_snapshot( &connection, &StructuralGraphSnapshot { @@ -265,7 +327,7 @@ async fn protocol_lifecycle_is_scoped_structured_and_live_revocable() { }); let client = TestClient.serve(client_transport).await.expect("client"); let tools = client.list_tools(None).await.expect("tools"); - assert_eq!(tools.tools.len(), 24); + assert_eq!(tools.tools.len(), 28); assert!(tools.tools.iter().all(|tool| tool.output_schema.is_some())); let templates = client .list_resource_templates(None) @@ -389,6 +451,110 @@ async fn protocol_lifecycle_is_scoped_structured_and_live_revocable() { ); assert_eq!(prepared["data"]["data"]["source"]["head_sha"], head); assert!(prepared.to_string().find(&repo_path).is_none()); + let performance_scope = client + .call_tool( + CallToolRequestParams::new("resolve_evidence_scope").with_arguments( + json!({"consumer": "performance", "scope_kind": "codebase"}) + .as_object() + .expect("arguments") + .clone(), + ), + ) + .await + .expect("performance scope") + .structured_content + .expect("performance scope structured"); + assert_eq!(performance_scope["data"]["data"]["schema_version"], 1); + assert_eq!(performance_scope["data"]["data"]["consumer"], "performance"); + assert_eq!(performance_scope["data"]["data"]["kind"], "codebase"); + assert!(performance_scope.to_string().find(&repo_path).is_none()); + let request = &surface_fixture["request"]; + let expected = &surface_fixture["expected"]; + let parity_scope = client + .call_tool( + CallToolRequestParams::new("resolve_evidence_scope").with_arguments( + json!({ + "consumer": request["consumer"], + "scope_kind": request["kind"], + "scope_value": request["value"] + }) + .as_object() + .expect("surface parity arguments") + .clone(), + ), + ) + .await + .expect("surface parity MCP scope") + .structured_content + .expect("surface parity MCP structured content"); + let parity_plan = &parity_scope["data"]["data"]; + assert_eq!(parity_plan["schema_version"], expected["schema_version"]); + assert_eq!(parity_plan["status"], expected["status"]); + assert_eq!( + parity_plan["candidates"].as_array().map(Vec::len), + expected["candidate_count"] + .as_u64() + .map(|count| count as usize) + ); + assert_eq!( + parity_plan["candidates"][0]["id"], + expected["first_candidate"]["id"] + ); + assert_eq!( + parity_plan["candidates"][0]["target"], + expected["first_candidate"]["target"] + ); + assert!(parity_scope.to_string().find(&repo_path).is_none()); + let local_expected = &local_check_fixture["expected"]; + let parity_receipt = client + .call_tool( + CallToolRequestParams::new("verification_get_receipt").with_arguments( + json!({"run_id": local_expected["run_id"]}) + .as_object() + .expect("receipt parity arguments") + .clone(), + ), + ) + .await + .expect("surface parity MCP receipt") + .structured_content + .expect("surface parity MCP receipt content"); + let receipt_projection = &parity_receipt["data"]["data"]; + assert_eq!( + receipt_projection["schema_version"], + local_expected["mcp_projection_schema"] + ); + assert_eq!(receipt_projection["authority"], "read_only_projection"); + assert_eq!( + receipt_projection["receipt"]["schema_version"], + local_expected["receipt_schema"] + ); + assert_eq!( + receipt_projection["receipt"]["request_id"], + local_expected["request_id"] + ); + assert_eq!( + receipt_projection["receipt"]["verdict"], + local_expected["verdict"] + ); + assert_eq!( + receipt_projection["receipt"]["stages"]["performance"]["status"], + local_expected["performance_status"] + ); + assert_eq!( + receipt_projection["receipt"]["stages"]["review"]["evidence"]["cross_review"]["strategy"], + "claude_then_codex_independent" + ); + assert_eq!( + receipt_projection["receipt"]["stages"]["review"]["evidence"]["cross_review"]["passes"][1] + ["reviewer"], + "codex" + ); + assert!(receipt_projection["receipt"]["limitations"] + .as_array() + .is_some_and(|limitations| limitations.contains(&local_expected["limitation"]))); + assert!(receipt_projection["receipt"].get("repo_path").is_none()); + assert!(parity_receipt.to_string().find(&repo_path).is_none()); let first_page = client .call_tool( CallToolRequestParams::new("history_list_releases") @@ -696,6 +862,50 @@ fn request_validation_rejects_unknown_and_out_of_bounds_arguments() { .expect("arguments") .clone(); assert!(validate_tool_arguments("prepare_review", &arguments).is_err()); + + arguments = json!({ + "consumer": "performance", + "scope_kind": "change", + "scope_value": "main...HEAD" + }) + .as_object() + .expect("arguments") + .clone(); + assert!(validate_tool_arguments("resolve_evidence_scope", &arguments).is_ok()); + + arguments = json!({"consumer": "testing", "scope_kind": "codebase"}) + .as_object() + .expect("arguments") + .clone(); + assert!(validate_tool_arguments("resolve_evidence_scope", &arguments).is_ok()); + + arguments = json!({"consumer": "performance", "scope_kind": "flow"}) + .as_object() + .expect("arguments") + .clone(); + assert!(validate_tool_arguments("resolve_evidence_scope", &arguments).is_err()); + + arguments = json!({ + "consumer": "performance", + "scope_kind": "codebase", + "scope_value": "must-not-be-present" + }) + .as_object() + .expect("arguments") + .clone(); + assert!(validate_tool_arguments("resolve_evidence_scope", &arguments).is_err()); + + arguments = json!({"run_id": "local-check-surface-parity"}) + .as_object() + .expect("arguments") + .clone(); + assert!(validate_tool_arguments("verification_get_receipt", &arguments).is_ok()); + + arguments = json!({"run_id": "../foreign receipt"}) + .as_object() + .expect("arguments") + .clone(); + assert!(validate_tool_arguments("verification_get_receipt", &arguments).is_err()); } #[test] diff --git a/apps/desktop/src-tauri/src/mcp/server/tools.rs b/apps/desktop/src-tauri/src/mcp/server/tools.rs index 0245673c..b6722183 100644 --- a/apps/desktop/src-tauri/src/mcp/server/tools.rs +++ b/apps/desktop/src-tauri/src/mcp/server/tools.rs @@ -41,6 +41,7 @@ pub(super) fn dispatch_tool( let limit = bounded_limit(arguments.get("limit")); let filter = optional_field::(&arguments, "filter")?.unwrap_or_default(); let data = match name { + "capability_catalog" => serde_json::to_value(crate::capabilities::capability_registry()), "prepare_review" => serde_json::to_value(prepare_review_packet( connection, repo_path, @@ -49,6 +50,32 @@ pub(super) fn dispatch_tool( required_string(&arguments, "task")?, required_string(&arguments, "change")?, )?), + "resolve_evidence_scope" => serde_json::to_value(resolve_agent_evidence_scope( + repo_path, + required_string(&arguments, "consumer")?, + required_string(&arguments, "scope_kind")?, + optional_string(&arguments, "scope_value")?, + )?), + "qa_workspace_inspect" => { + serde_json::to_value(crate::commands::qa_workspace::run_qa_workspace_headless( + connection, + PathBuf::from(repo_path), + crate::commands::qa_workspace::QaWorkspaceMutation::Inspect, + optional_string(&arguments, "fix_completed_at")?, + )?) + } + "verification_get_receipt" => { + let receipt = get_local_check_receipt( + connection, + repo_path, + required_string(&arguments, "run_id")?, + )?; + Ok(json!({ + "schema_version": "codevetter.verification-receipt-projection/v1", + "authority": "read_only_projection", + "receipt": receipt, + })) + } "graph_query" => { let query = optional_string(&arguments, "query")?; let fingerprint = serde_json::to_string(&(query.map(str::to_ascii_lowercase), &filter)) diff --git a/apps/desktop/src-tauri/src/mcp/validation.rs b/apps/desktop/src-tauri/src/mcp/validation.rs index 85c01700..c43fe041 100644 --- a/apps/desktop/src-tauri/src/mcp/validation.rs +++ b/apps/desktop/src-tauri/src/mcp/validation.rs @@ -76,11 +76,18 @@ pub(crate) fn validate_tool_arguments( "to", "entity", "review_id", + "run_id", "task", "change", + "scope_value", + "fix_completed_at", ] { if let Some(value) = arguments.get(field) { - let maximum = if field == "change" { 512 } else { 4_096 }; + let maximum = match field { + "run_id" => 128, + "change" | "scope_value" => 512, + _ => 4_096, + }; let text = value .as_str() .filter(|text| text.len() <= maximum) @@ -90,6 +97,17 @@ pub(crate) fn validate_tool_arguments( } } } + if let Some(run_id) = arguments.get("run_id").and_then(Value::as_str) { + if !run_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':')) + { + return Err( + "'run_id' may contain only ASCII letters, numbers, dash, underscore, dot, or colon" + .to_string(), + ); + } + } if let Some(value) = arguments.get("cursor") { value .as_str() @@ -99,6 +117,37 @@ pub(crate) fn validate_tool_arguments( validate_integer(arguments, "limit", 1, MAX_PAGE_SIZE)?; validate_integer(arguments, "depth", 1, MAX_HOPS)?; + if name == "resolve_evidence_scope" { + let consumer = arguments + .get("consumer") + .and_then(Value::as_str) + .filter(|value| matches!(*value, "testing" | "performance")) + .ok_or_else(|| "'consumer' must be testing or performance".to_string())?; + let _ = consumer; + let kind = arguments + .get("scope_kind") + .and_then(Value::as_str) + .filter(|value| matches!(*value, "flow" | "change" | "codebase")) + .ok_or_else(|| "'scope_kind' must be flow, change, or codebase".to_string())?; + match (kind, arguments.get("scope_value")) { + ("codebase", None) => {} + ("codebase", Some(_)) => { + return Err("'scope_value' must be omitted for codebase scope".to_string()) + } + (_, Some(_)) => {} + (_, None) => { + return Err("'scope_value' is required for flow and change scope".to_string()) + } + } + } + + if name == "qa_workspace_inspect" { + if let Some(value) = arguments.get("fix_completed_at").and_then(Value::as_str) { + chrono::DateTime::parse_from_rfc3339(value) + .map_err(|_| "'fix_completed_at' must be an RFC3339 timestamp".to_string())?; + } + } + if name.starts_with("graph_") { if let Some(value) = arguments.get("filter") { validate_object_keys(value, "filter", &["node_kinds", "edge_kinds", "trust"])?; diff --git a/apps/desktop/src-tauri/tests/fixtures/surface-parity/local-check-v1.json b/apps/desktop/src-tauri/tests/fixtures/surface-parity/local-check-v1.json new file mode 100644 index 00000000..8129f489 --- /dev/null +++ b/apps/desktop/src-tauri/tests/fixtures/surface-parity/local-check-v1.json @@ -0,0 +1 @@ +{"schema_version":"codevetter.local-check-surface-parity-fixture/v1","authority":{"rust":"authoritative_service","cli":"supervised_execution","native":"supervised_execution","mcp":"read_only_projection","mcp_may_execute":false},"request":{"schema_version":"codevetter.verification-command/v1","request_id":"surface-parity-local-check","operation":"execute","repo_path":"/fixture/repo","change":"main...HEAD","task":"Preserve checkout totals"},"expected":{"receipt_schema":"codevetter.local-check/v1","request_id":"surface-parity-local-check","run_id":"local-check-surface-parity","verdict":"no_confidence","exit_code":2,"performance_status":"no_confidence","limitation":"The performance collector is unavailable in this fixture.","mcp_projection_schema":"codevetter.verification-receipt-projection/v1"},"canonical_receipt":{"schema_version":"codevetter.local-check/v1","request_id":"surface-parity-local-check","run_id":"local-check-surface-parity","ran_at":"2026-09-01T00:00:00Z","repo_path":"/fixture/repo","task":"Preserve checkout totals","source":{"kind":"range","input":"main...HEAD","base_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","head_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","commits":["bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"],"changed_paths":["src/cart.ts"]},"stages":{"review":{"status":"completed","duration_ms":18,"target":null,"evidence":{"summary":"The bounded review completed without a qualified finding.","findings":[],"cross_review":{"schema_version":"codevetter.cross-review/v1","strategy":"claude_then_codex_independent","status":"completed","counts":{"corroborated":0,"claude_only":0,"codex_only":0,"conflicting":0},"passes":[{"reviewer":"claude","status":"completed"},{"reviewer":"codex","status":"completed"}],"proof_boundary":"Reviewer agreement is review coverage, never executable proof."}},"limitations":[]},"correctness":{"status":"passed","duration_ms":12,"target":{"adapter":"vitest","target":"src/cart.test.ts","name":null,"source":"selected:fixture"},"evidence":{"verdict":"passed"},"limitations":[]},"performance":{"status":"no_confidence","duration_ms":0,"target":null,"evidence":{},"limitations":["The performance collector is unavailable in this fixture."]},"optimization":{"status":"no_confidence","duration_ms":0,"target":null,"evidence":{},"limitations":["No paired optimization claim can be made without performance evidence."]}},"verdict":"no_confidence","limitations":["The performance collector is unavailable in this fixture.","No paired optimization claim can be made without performance evidence."]}} diff --git a/apps/desktop/src-tauri/tests/mcp_stdio.rs b/apps/desktop/src-tauri/tests/mcp_stdio.rs index d7b3aa9a..998bf3e4 100644 --- a/apps/desktop/src-tauri/tests/mcp_stdio.rs +++ b/apps/desktop/src-tauri/tests/mcp_stdio.rs @@ -26,7 +26,7 @@ fn stdio_boundary_is_json_only_scoped_and_paginated() { "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} })); let tool_definitions = tools["result"]["tools"].as_array().expect("tools"); - assert_eq!(tool_definitions.len(), 24); + assert_eq!(tool_definitions.len(), 28); assert!(tool_definitions.iter().any(|tool| { tool["name"] == "prepare_review" && tool["inputSchema"]["additionalProperties"] == false }));