From 2483b7391d187a0853493fd7751607ba9b38ae0a Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 2 Sep 2026 21:52:27 +0530 Subject: [PATCH] feat: stage verification backend modules 2/4 --- .../src-tauri/src/commands/cross_review.rs | 601 +++++++ .../src-tauri/src/commands/fix_attempt.rs | 1442 +++++++++++++++++ .../src-tauri/src/commands/fix_packet.rs | 507 ++++++ .../src-tauri/src/commands/history_roots.rs | 250 +++ .../src-tauri/src/commands/native_settings.rs | 535 ++++++ .../src-tauri/src/commands/onboarding.rs | 185 +++ .../src-tauri/src/commands/ops_status.rs | 160 ++ 7 files changed, 3680 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/cross_review.rs create mode 100644 apps/desktop/src-tauri/src/commands/fix_attempt.rs create mode 100644 apps/desktop/src-tauri/src/commands/fix_packet.rs create mode 100644 apps/desktop/src-tauri/src/commands/history_roots.rs create mode 100644 apps/desktop/src-tauri/src/commands/native_settings.rs create mode 100644 apps/desktop/src-tauri/src/commands/onboarding.rs create mode 100644 apps/desktop/src-tauri/src/commands/ops_status.rs diff --git a/apps/desktop/src-tauri/src/commands/cross_review.rs b/apps/desktop/src-tauri/src/commands/cross_review.rs new file mode 100644 index 00000000..6a106f5f --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/cross_review.rs @@ -0,0 +1,601 @@ +//! Deterministic reconciliation for independent Claude and Codex review passes. +//! +//! Each provider receives the original immutable target and context. This +//! module never invokes an LLM to merge results: only source-qualified identity +//! (path, resolved line, and source anchor) can correlate findings. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; + +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use crate::db::queries::{self, LocalReviewFindingInput, LocalReviewInput, LocalReviewUpdate}; +use crate::DbState; + +use super::review::resolve_agent_cli_path; + +pub const CROSS_REVIEW_SCHEMA: &str = "codevetter.cross-review/v1"; + +pub fn coordinator_policy_binding( + repo_path: &str, + diff_range: &str, + task: &str, + runtime_context: &[Value], +) -> Result { + let canonical = serde_json::to_vec(&json!({ + "schema_version": CROSS_REVIEW_SCHEMA, + "repository": repo_path, + "diff_range": diff_range, + "task": task, + "runtime_context": runtime_context, + })) + .map_err(|error| format!("Could not bind the cross-review policy: {error}"))?; + Ok(format!("{:x}", Sha256::digest(canonical))) +} + +pub fn attach_coordinator_binding(evidence: &mut Value, binding: &str) -> Result<(), String> { + evidence + .as_object_mut() + .ok_or_else(|| "Review pass evidence is not an object".to_string())? + .insert( + "cross_review_policy_binding".into(), + Value::String(binding.into()), + ); + Ok(()) +} + +pub fn missing_executors() -> Vec { + ["claude", "codex"] + .into_iter() + .filter(|agent| !Path::new(&resolve_agent_cli_path(agent)).is_file()) + .map(str::to_string) + .collect() +} + +pub fn reconcile_complete(claude: Value, codex: Value) -> Result { + let claude_target = target_identity(&claude)?; + let codex_target = target_identity(&codex)?; + if claude_target != codex_target { + return Err("Cross-review passes do not bind the same immutable target".into()); + } + let claude_policy = policy_binding(&claude)?; + let codex_policy = policy_binding(&codex)?; + if claude_policy != codex_policy { + return Err("Cross-review passes do not bind the same coordinator policy".into()); + } + let claude_units = unit_plan_identity(&claude)?; + let codex_units = unit_plan_identity(&codex)?; + if claude_units != codex_units { + return Err("Cross-review passes do not cover the same review units".into()); + } + let mut grouped = BTreeMap::>::new(); + let mut unresolved = Vec::new(); + for (reviewer, evidence) in [("claude", &claude), ("codex", &codex)] { + for finding in evidence + .get("findings") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if let Some(identity) = finding_identity(finding) { + grouped + .entry(identity) + .or_default() + .push((reviewer, finding.clone())); + } else { + unresolved.push(json!({ + "reviewer": reviewer, + "reason": "missing_source_qualified_identity" + })); + } + } + } + + let mut findings = Vec::new(); + let mut counts = BTreeMap::from([ + ("corroborated", 0_u64), + ("claude_only", 0), + ("codex_only", 0), + ("conflicting", 0), + ( + "rejected", + qualification_total(&claude, "rejected") + qualification_total(&codex, "rejected"), + ), + ( + "stale", + qualification_total(&claude, "stale") + qualification_total(&codex, "stale"), + ), + ( + "unresolved", + qualification_total(&claude, "unresolved") + qualification_total(&codex, "unresolved"), + ), + ]); + for candidates in grouped.into_values() { + let reviewers = candidates + .iter() + .map(|(reviewer, _)| *reviewer) + .collect::>(); + let classification = match reviewers.iter().copied().collect::>().as_slice() { + ["claude"] => "claude_only", + ["codex"] => "codex_only", + _ if severities(&candidates).len() > 1 => "conflicting", + _ => "corroborated", + }; + *counts.entry(classification).or_default() += 1; + let mut selected = candidates + .iter() + .max_by_key(|(_, finding)| finding_rank(finding)) + .map(|(_, finding)| finding.clone()) + .ok_or_else(|| { + "Cross-review reconciliation received an empty finding group".to_string() + })?; + let object = selected + .as_object_mut() + .ok_or_else(|| "Qualified review finding is not an object".to_string())?; + object.insert( + "cross_review_class".into(), + Value::String(classification.into()), + ); + object.insert( + "reviewers".into(), + Value::Array( + reviewers + .into_iter() + .map(|reviewer| Value::String(reviewer.into())) + .collect(), + ), + ); + findings.push(selected); + } + + let claude_ready = review_ready(&claude); + let codex_ready = review_ready(&codex); + if let Some(count) = counts.get_mut("unresolved") { + *count += unresolved.len() as u64; + } + let complete = claude_ready && codex_ready && unresolved.is_empty(); + if !complete { + findings.clear(); + } + let limitations = if complete { + Vec::new() + } else { + vec!["Both independent passes and every source-qualified identity are required".to_string()] + }; + Ok(json!({ + "schema_version": CROSS_REVIEW_SCHEMA, + "strategy": "claude_then_codex_independent", + "status": if complete { "completed" } else { "incomplete" }, + "target_identity": claude_target, + "policy_binding": claude_policy, + "unit_plan_identity": claude_units, + "passes": [pass_summary("claude", &claude), pass_summary("codex", &codex)], + "counts": counts, + "findings": findings, + "unresolved": unresolved, + "limitations": limitations, + "authority": "deterministic_source_qualified_union", + "proof_boundary": "Reviewer agreement is review coverage, never executable proof." + })) +} + +pub fn incomplete_after_pass( + completed_reviewer: Option<(&str, Value)>, + failed_reviewer: &str, + error: &str, +) -> Value { + let passes = completed_reviewer + .map(|(reviewer, evidence)| vec![pass_summary(reviewer, &evidence)]) + .unwrap_or_default(); + json!({ + "schema_version": CROSS_REVIEW_SCHEMA, + "strategy": "claude_then_codex_independent", + "status": "incomplete", + "passes": passes, + "failed_reviewer": failed_reviewer, + "findings": [], + "limitations": [format!("{failed_reviewer} pass did not complete: {}", bounded(error, 320))], + "authority": "deterministic_source_qualified_union", + "proof_boundary": "A partial run cannot produce a composite cross-review claim." + }) +} + +pub fn project_stage_evidence(cross_review: Value) -> Value { + let findings = cross_review + .get("findings") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + let complete = cross_review.get("status").and_then(Value::as_str) == Some("completed"); + let limitations = cross_review + .get("limitations") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + json!({ + "agent": "cross", + "review_status": if complete { "completed" } else { "incomplete" }, + "review_readiness": { + "status": if complete { "ready" } else { "incomplete" }, + "complete_coverage": complete, + "limitations": limitations, + }, + "findings_count": findings.as_array().map_or(0, Vec::len), + "findings": findings, + "cross_review": cross_review, + "summary": "Independent Claude and Codex passes reconciled by source-qualified identity." + }) +} + +pub fn persist_composite_review( + db: &DbState, + repo_path: &str, + diff_range: &str, + standards_pack: Option, + evidence: &mut Value, +) -> Result<(), String> { + let status = evidence + .get("review_status") + .and_then(Value::as_str) + .unwrap_or("incomplete") + .to_string(); + let connection = db.0.lock().map_err(|error| error.to_string())?; + let review_id = queries::create_local_review( + &connection, + &LocalReviewInput { + review_type: Some("cross_review".into()), + source_label: Some(format!("cli:cross:{diff_range}")), + repo_path: Some(repo_path.into()), + repo_full_name: None, + pr_number: None, + agent_used: Some("claude+codex".into()), + status: Some(status.clone()), + standards_pack, + }, + ) + .map_err(|error| error.to_string())?; + let findings = evidence + .get_mut("findings") + .and_then(Value::as_array_mut) + .ok_or_else(|| "Cross-review evidence omitted its finding union".to_string())?; + for finding in findings.iter_mut() { + let fingerprint = finding_identity(finding); + let object = finding + .as_object_mut() + .ok_or_else(|| "Cross-review finding is not an object".to_string())?; + let finding_id = queries::insert_review_finding( + &connection, + &LocalReviewFindingInput { + review_id: review_id.clone(), + severity: string(object, "severity").unwrap_or("medium").into(), + title: string(object, "title").unwrap_or("Untitled").into(), + summary: string(object, "summary").unwrap_or("").into(), + suggestion: string(object, "suggestion").map(str::to_string), + file_path: string(object, "filePath").map(str::to_string), + line: object.get("line").and_then(Value::as_i64), + confidence: object.get("confidence").and_then(Value::as_f64), + fingerprint, + discovery_method: Some("independent_cross_review".into()), + }, + ) + .map_err(|error| error.to_string())?; + object.insert("id".into(), Value::String(finding_id)); + } + queries::update_local_review( + &connection, + &review_id, + &LocalReviewUpdate { + findings_count: Some(findings.len() as i64), + summary_markdown: Some( + "Independent Claude then Codex review; source-qualified union only. Agreement does not create executable proof." + .into(), + ), + status: Some(status), + completed_at: Some(chrono::Utc::now().to_rfc3339()), + ..LocalReviewUpdate::default() + }, + ) + .map_err(|error| error.to_string())?; + evidence + .as_object_mut() + .ok_or_else(|| "Cross-review stage evidence is not an object".to_string())? + .insert("review_id".into(), Value::String(review_id)); + Ok(()) +} + +fn target_identity(evidence: &Value) -> Result { + evidence + .pointer("/review_manifest/target/identity") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| "Review pass omitted its immutable target identity".into()) +} + +fn policy_binding(evidence: &Value) -> Result { + evidence + .get("cross_review_policy_binding") + .and_then(Value::as_str) + .filter(|value| value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())) + .map(str::to_string) + .ok_or_else(|| "Review pass omitted its shared coordinator policy binding".into()) +} + +fn unit_plan_identity(evidence: &Value) -> Result { + let units = evidence + .pointer("/review_manifest/units") + .and_then(Value::as_array) + .ok_or_else(|| "Review pass omitted its bounded unit plan".to_string())?; + let canonical = units + .iter() + .map(|unit| { + json!({ + "file_path": unit.get("file_path"), + "file_status": unit.get("file_status"), + "diff_bytes": unit.get("diff_bytes"), + "prompt_budget_bytes": unit.get("prompt_budget_bytes"), + }) + }) + .collect::>(); + let bytes = serde_json::to_vec(&canonical) + .map_err(|error| format!("Could not bind the review unit plan: {error}"))?; + Ok(format!("{:x}", Sha256::digest(bytes))) +} + +fn finding_identity(finding: &Value) -> Option { + let path = finding.get("filePath").and_then(Value::as_str)?.trim(); + let line = finding.get("line").and_then(Value::as_i64)?; + let anchor = finding.get("sourceAnchor").and_then(Value::as_str)?.trim(); + (!path.is_empty() && line > 0 && !anchor.is_empty()) + .then(|| format!("{path}\0{line}\0{anchor}")) +} + +fn severities(candidates: &[(&str, Value)]) -> BTreeSet { + candidates + .iter() + .filter_map(|(_, finding)| finding.get("severity").and_then(Value::as_str)) + .map(str::to_string) + .collect() +} + +fn finding_rank(finding: &Value) -> (u8, u64) { + let severity = match finding.get("severity").and_then(Value::as_str) { + Some("critical") => 4, + Some("high") => 3, + Some("medium") => 2, + Some("low") => 1, + _ => 0, + }; + let confidence = finding + .get("confidence") + .and_then(Value::as_f64) + .map_or(0, |value| (value.clamp(0.0, 1.0) * 1_000_000.0) as u64); + (severity, confidence) +} + +fn review_ready(evidence: &Value) -> bool { + evidence + .pointer("/review_readiness/status") + .and_then(Value::as_str) + == Some("ready") + && evidence.get("review_status").and_then(Value::as_str) == Some("completed") +} + +fn qualification_total(evidence: &Value, state: &str) -> u64 { + evidence + .pointer(&format!("/review_manifest/qualification_counts/{state}")) + .and_then(Value::as_u64) + .unwrap_or(0) +} + +fn pass_summary(reviewer: &str, evidence: &Value) -> Value { + json!({ + "reviewer": reviewer, + "status": evidence.get("review_status").cloned().unwrap_or(Value::String("incomplete".into())), + "review_id": evidence.get("review_id").cloned().unwrap_or(Value::Null), + "duration_ms": evidence.get("duration_ms").cloned().unwrap_or(Value::Null), + "findings_count": evidence.get("findings_count").cloned().unwrap_or(Value::from(0)), + "qualified_findings": evidence.get("findings").cloned().unwrap_or_else(|| Value::Array(Vec::new())), + "review_readiness": evidence.get("review_readiness").cloned().unwrap_or(Value::Null), + "review_manifest": evidence.get("review_manifest").cloned().unwrap_or(Value::Null), + "usage": evidence.get("usage").cloned().unwrap_or(Value::Null), + "raw_candidate_access": "not_exposed_by_review_contract; qualification diagnostics remain in review_manifest", + }) +} + +fn string<'a>(object: &'a serde_json::Map, key: &str) -> Option<&'a str> { + object.get(key).and_then(Value::as_str) +} + +fn bounded(value: &str, max: usize) -> String { + value.chars().take(max).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pass(reviewer: &str, findings: Value) -> Value { + json!({ + "cross_review_policy_binding": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "review_id": format!("{reviewer}-review"), + "review_status": "completed", + "review_readiness": {"status": "ready"}, + "findings": findings, + "findings_count": findings.as_array().map_or(0, Vec::len), + "duration_ms": 25, + "review_manifest": { + "target": {"identity": "immutable-target"}, + "executor_id": reviewer, + "policy_fingerprint": format!("{reviewer}-policy"), + "units": [{ + "file_path": "src/a.rs", + "file_status": "M", + "diff_bytes": 128, + "prompt_budget_bytes": 81920 + }] + } + }) + } + + fn finding(severity: &str, title: &str, path: &str, line: i64, anchor: &str) -> Value { + json!({ + "severity": severity, + "title": title, + "summary": format!("{title} evidence"), + "filePath": path, + "line": line, + "sourceAnchor": anchor, + "confidence": 0.9 + }) + } + + #[test] + fn source_identity_reconciles_corroborated_unique_and_conflicting_findings() { + let shared = finding("high", "Claude title", "src/a.rs", 8, "danger();"); + let mut codex_shared = shared.clone(); + codex_shared["title"] = Value::String("Different title, same exact source".into()); + let conflicting = finding("medium", "Claude severity", "src/b.rs", 9, "other();"); + let mut codex_conflicting = conflicting.clone(); + codex_conflicting["severity"] = Value::String("critical".into()); + let receipt = reconcile_complete( + pass( + "claude", + json!([ + shared, + conflicting, + finding("low", "Claude only", "src/c.rs", 2, "c();") + ]), + ), + pass( + "codex", + json!([ + codex_shared, + codex_conflicting, + finding("high", "Codex only", "src/d.rs", 3, "d();") + ]), + ), + ) + .expect("cross review"); + assert_eq!(receipt["status"], "completed"); + assert_eq!(receipt["counts"]["corroborated"], 1); + assert_eq!(receipt["counts"]["conflicting"], 1); + assert_eq!(receipt["counts"]["claude_only"], 1); + assert_eq!(receipt["counts"]["codex_only"], 1); + assert_eq!(receipt["findings"].as_array().map(Vec::len), Some(4)); + assert_eq!( + receipt["findings"] + .as_array() + .and_then(|items| items.iter().find(|item| item["filePath"] == "src/b.rs")) + .map(|item| &item["severity"]), + Some(&Value::String("critical".into())) + ); + } + + #[test] + fn title_similarity_never_merges_different_source_locations() { + let receipt = reconcile_complete( + pass( + "claude", + json!([finding("high", "Same title", "src/a.rs", 1, "a();")]), + ), + pass( + "codex", + json!([finding("high", "Same title", "src/b.rs", 1, "b();")]), + ), + ) + .expect("cross review"); + assert_eq!(receipt["findings"].as_array().map(Vec::len), Some(2)); + } + + #[test] + fn missing_anchor_and_partial_execution_fail_closed() { + let receipt = reconcile_complete( + pass( + "claude", + json!([{"severity":"high","title":"x","summary":"x"}]), + ), + pass("codex", json!([])), + ) + .expect("cross review"); + assert_eq!(receipt["status"], "incomplete"); + assert!(receipt["findings"].as_array().is_some_and(Vec::is_empty)); + assert_eq!( + receipt["passes"][0]["qualified_findings"] + .as_array() + .map(Vec::len), + Some(1) + ); + assert_eq!(receipt["passes"][0]["review_readiness"]["status"], "ready"); + + let partial = incomplete_after_pass( + Some(("claude", pass("claude", json!([])))), + "codex", + "cancelled", + ); + assert_eq!(partial["status"], "incomplete"); + assert_eq!(partial["passes"].as_array().map(Vec::len), Some(1)); + assert!(partial["findings"].as_array().is_some_and(Vec::is_empty)); + } + + #[test] + fn different_targets_never_form_a_composite() { + let claude = pass("claude", json!([])); + let mut codex = pass("codex", json!([])); + codex["review_manifest"]["target"]["identity"] = Value::String("other".into()); + assert!(reconcile_complete(claude, codex).is_err()); + } + + #[test] + fn different_coordinator_policy_or_unit_plan_never_forms_a_composite() { + let claude = pass("claude", json!([])); + let mut codex = pass("codex", json!([])); + codex["cross_review_policy_binding"] = Value::String("b".repeat(64)); + assert!(reconcile_complete(claude.clone(), codex).is_err()); + + let mut different_units = pass("codex", json!([])); + different_units["review_manifest"]["units"][0]["diff_bytes"] = Value::from(129); + assert!(reconcile_complete(claude, different_units).is_err()); + } + + #[test] + fn composite_and_qualified_union_persist_under_one_review_identity() { + let connection = rusqlite::Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + let db = DbState(std::sync::Arc::new(std::sync::Mutex::new(connection))); + let receipt = reconcile_complete( + pass( + "claude", + json!([finding( + "high", + "Shared finding", + "src/a.rs", + 8, + "danger();" + )]), + ), + pass( + "codex", + json!([finding( + "high", + "Shared finding", + "src/a.rs", + 8, + "danger();" + )]), + ), + ) + .expect("cross review"); + let mut evidence = project_stage_evidence(receipt); + persist_composite_review(&db, "/fixture/repo", "main...HEAD", None, &mut evidence) + .expect("persist composite"); + + let review_id = evidence["review_id"].as_str().expect("review id"); + let connection = db.0.lock().expect("database lock"); + let (review, findings) = + queries::get_local_review_with_findings(&connection, review_id).expect("stored review"); + assert_eq!(review.review_type.as_deref(), Some("cross_review")); + assert_eq!(review.agent_used, "claude+codex"); + assert_eq!(review.findings_count, Some(1)); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].file_path.as_deref(), Some("src/a.rs")); + } +} diff --git a/apps/desktop/src-tauri/src/commands/fix_attempt.rs b/apps/desktop/src-tauri/src/commands/fix_attempt.rs new file mode 100644 index 00000000..c947eed8 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/fix_attempt.rs @@ -0,0 +1,1442 @@ +//! Explicit, isolated agent-fix execution followed by executable and review rechecks. +//! +//! A fix attempt never edits, commits, or merges into the selected checkout. It +//! materializes the exact source receipt head as a detached Git worktree under +//! CodeVetter's app-data directory, lets one explicitly confirmed coding agent +//! edit there, reruns the recorded correctness target, and reviews only the +//! resulting worktree diff. The retained worktree remains owner-inspectable +//! until a separately confirmed discard operation removes it. + +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command as StdCommand, Stdio}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::process::Command as TokioCommand; + +use crate::{db, DbState}; + +use super::fix_packet::{ + build_agent_fix_packet, load_local_check_receipt, AgentFixPacketReceipt, FixPacketFinding, +}; +use super::local_check::{ + rerun_fix_correctness_target, LocalCheckReceipt, LocalCheckStage, LocalCheckStatus, +}; +use super::review::{resolve_cli_path, run_cli_review_core}; + +const SCHEMA_VERSION: &str = "codevetter.fix-attempt/v1"; +const MAX_AGENT_OUTPUT_BYTES: usize = 512 * 1024; +const MAX_DIFF_BYTES: usize = 1024 * 1024; +const MAX_DIFF_PREVIEW_BYTES: usize = 128 * 1024; +const MAX_CHANGED_FILES: usize = 100; +const MAX_FINDINGS: usize = 100; +const AGENT_DEADLINE: Duration = Duration::from_secs(30 * 60); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixAttemptInput { + pub run_id: String, + pub finding_ids: Vec, + pub agent: String, + pub confirmed: bool, + pub timeout_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DiscardFixAttemptInput { + pub attempt_id: String, + pub confirmed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FixAttemptReceipt { + pub schema_version: String, + pub attempt_id: String, + pub operation: String, + pub state: String, + pub source_run_id: String, + pub repository_path: String, + pub source: FixAttemptSource, + pub worktree: FixAttemptWorktree, + pub agent: FixAttemptAgent, + pub change: FixAttemptChange, + pub recheck: FixAttemptRecheck, + pub limitations: Vec, + pub started_at: String, + pub completed_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixAttemptSource { + pub input: String, + pub base_sha: String, + pub head_sha: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixAttemptWorktree { + pub path: String, + pub detached: bool, + pub retained: bool, + pub source_head_sha: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixAttemptAgent { + pub id: String, + pub status: String, + pub duration_ms: u64, + pub diagnostic: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixAttemptChange { + pub changed_files: Vec, + pub diff_sha256: Option, + pub diff_bytes: usize, + pub diff_preview: String, + pub preview_truncated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FixAttemptRecheck { + pub diff_check: FixAttemptGate, + pub correctness: FixAttemptCorrectness, + pub review: FixAttemptReview, + pub findings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixAttemptGate { + pub status: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FixAttemptCorrectness { + pub status: String, + pub target: Option, + pub duration_ms: u64, + pub evidence: Value, + pub limitations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FixAttemptReview { + pub status: String, + pub review_id: Option, + pub summary: Option, + pub findings: Vec, + pub limitation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixFindingRecheck { + pub finding_id: String, + pub status: String, + pub reason: String, +} + +struct AgentExecution { + success: bool, + duration_ms: u64, + diagnostic: Option, +} + +pub async fn execute_fix_attempt( + app_data_dir: PathBuf, + input: FixAttemptInput, +) -> Result { + validate_execute_input(&input)?; + let connection = db::init_db(app_data_dir.clone()) + .map_err(|error| format!("Open CodeVetter database: {error}"))?; + let packet = build_agent_fix_packet(&connection, &input.run_id, &input.finding_ids)?; + let source_receipt = load_local_check_receipt(&connection, &input.run_id)?; + drop(connection); + validate_packet_source(&packet, &source_receipt)?; + + let repository = canonical_git_repository(Path::new(&packet.repo_path))?; + require_commit(&repository, &packet.source.head_sha)?; + let attempt_id = format!("fix-attempt-{}", uuid::Uuid::new_v4().simple()); + let attempt_root = attempt_root(&app_data_dir, &attempt_id)?; + let worktree_path = attempt_root.join("worktree"); + create_detached_worktree(&repository, &worktree_path, &packet.source.head_sha)?; + + let started_at = chrono::Utc::now().to_rfc3339(); + let mut execution = + run_fix_agent(&input.agent, &worktree_path, &render_agent_prompt(&packet)).await; + if !execution.success { + let receipt = terminal_receipt( + &attempt_id, + "failed", + &input, + &packet, + &repository, + &worktree_path, + execution, + empty_change(), + unchecked_recheck(&packet, "The coding agent did not complete successfully."), + vec![ + "The isolated worktree is retained for inspection; CodeVetter did not merge or commit any change." + .into(), + ], + started_at, + ); + persist_receipt(&attempt_root, &receipt)?; + return Ok(receipt); + } + + match exact_worktree_head(&worktree_path) { + Ok(head) if head == packet.source.head_sha => {} + Ok(head) => { + execution.success = false; + execution.diagnostic = Some(format!( + "The coding agent changed Git HEAD from {} to {head}; commits and branch movement are outside the fix-attempt contract", + packet.source.head_sha + )); + let receipt = terminal_receipt( + &attempt_id, + "failed", + &input, + &packet, + &repository, + &worktree_path, + execution, + empty_change(), + unchecked_recheck( + &packet, + "Rechecks were blocked because the coding agent changed Git history.", + ), + vec![ + "The isolated worktree is retained for inspection; CodeVetter did not merge or push the unsupported commit." + .into(), + ], + started_at, + ); + persist_receipt(&attempt_root, &receipt)?; + return Ok(receipt); + } + Err(error) => { + execution.success = false; + execution.diagnostic = Some(error.clone()); + let receipt = terminal_receipt( + &attempt_id, + "failed", + &input, + &packet, + &repository, + &worktree_path, + execution, + empty_change(), + unchecked_recheck( + &packet, + "Rechecks were blocked because Git HEAD was unreadable.", + ), + vec![error], + started_at, + ); + persist_receipt(&attempt_root, &receipt)?; + return Ok(receipt); + } + } + + expose_untracked_diff(&worktree_path)?; + let change = collect_change(&worktree_path)?; + if change.changed_files.is_empty() || change.diff_bytes == 0 { + let receipt = terminal_receipt( + &attempt_id, + "no_changes", + &input, + &packet, + &repository, + &worktree_path, + execution, + change, + unchecked_recheck(&packet, "The coding agent produced no inspectable worktree diff."), + vec![ + "No source change was produced, so CodeVetter issued no fixed finding or correctness claim." + .into(), + "The isolated worktree is retained for inspection; CodeVetter did not merge or commit any change." + .into(), + ], + started_at, + ); + persist_receipt(&attempt_root, &receipt)?; + return Ok(receipt); + } + + let diff_check = run_diff_check(&worktree_path); + let correctness = rerun_fix_correctness_target( + &worktree_path, + source_receipt.stages.correctness.target.clone(), + input.timeout_ms, + ) + .await; + let correctness_projection = project_correctness(&correctness); + let review = if diff_check.status == "passed" { + run_fix_review( + &app_data_dir, + &worktree_path, + &input.agent, + &packet, + &source_receipt, + &correctness, + ) + .await + } else { + FixAttemptReview { + status: "unchecked".into(), + review_id: None, + summary: None, + findings: Vec::new(), + limitation: Some("Review was skipped because git diff --check failed.".into()), + } + }; + let finding_rechecks = classify_findings(&packet.findings, &review, &correctness_projection); + let state = classify_attempt_state( + &diff_check, + &correctness_projection, + &review, + &finding_rechecks, + ); + let mut limitations = vec![ + "The isolated worktree is retained for owner inspection; CodeVetter did not commit, merge, push, or modify the selected checkout." + .into(), + "A fixed status is bounded to the recorded correctness target and source-qualified re-review; it is not a general proof of the repository." + .into(), + ]; + if correctness_projection.status == "no_confidence" { + limitations.push( + "The source verification receipt had no runnable correctness target, or its recheck produced no executable confidence." + .into(), + ); + } + if let Some(limitation) = review.limitation.clone() { + limitations.push(limitation); + } + let receipt = terminal_receipt( + &attempt_id, + &state, + &input, + &packet, + &repository, + &worktree_path, + execution, + change, + FixAttemptRecheck { + diff_check, + correctness: correctness_projection, + review, + findings: finding_rechecks, + }, + limitations, + started_at, + ); + persist_receipt(&attempt_root, &receipt)?; + Ok(receipt) +} + +pub fn inspect_fix_attempt( + app_data_dir: &Path, + attempt_id: &str, +) -> Result { + let root = attempt_root(app_data_dir, attempt_id)?; + let bytes = fs::read(root.join("receipt.json")) + .map_err(|error| format!("Read fix-attempt receipt: {error}"))?; + let receipt: FixAttemptReceipt = serde_json::from_slice(&bytes) + .map_err(|error| format!("Decode fix-attempt receipt: {error}"))?; + if receipt.schema_version != SCHEMA_VERSION || receipt.attempt_id != attempt_id { + return Err("The fix-attempt receipt identity is invalid".into()); + } + let expected_worktree = root.join("worktree"); + if Path::new(&receipt.worktree.path) != expected_worktree { + return Err("The fix-attempt worktree escaped its app-data scope".into()); + } + Ok(receipt) +} + +pub fn discard_fix_attempt( + app_data_dir: &Path, + input: DiscardFixAttemptInput, +) -> Result { + if !input.confirmed { + return Err("Discard requires explicit confirmation because unmerged worktree changes will be removed".into()); + } + let mut receipt = inspect_fix_attempt(app_data_dir, &input.attempt_id)?; + if !receipt.worktree.retained { + return Ok(receipt); + } + let repository = canonical_git_repository(Path::new(&receipt.repository_path))?; + let worktree = PathBuf::from(&receipt.worktree.path); + let output = StdCommand::new("git") + .args(["worktree", "remove", "--force"]) + .arg(&worktree) + .current_dir(&repository) + .output() + .map_err(|error| format!("Start git worktree remove: {error}"))?; + if !output.status.success() { + return Err(format!( + "Discard isolated worktree: {}", + bounded_diagnostic(&output.stderr, 4_096) + )); + } + let _ = StdCommand::new("git") + .args(["worktree", "prune"]) + .current_dir(&repository) + .output(); + receipt.operation = "discard".into(); + receipt.state = "discarded".into(); + receipt.worktree.retained = false; + receipt.completed_at = chrono::Utc::now().to_rfc3339(); + receipt.limitations.push( + "The separately confirmed discard removed the isolated unmerged worktree; the source checkout was not modified." + .into(), + ); + persist_receipt(&attempt_root(app_data_dir, &input.attempt_id)?, &receipt)?; + Ok(receipt) +} + +fn validate_execute_input(input: &FixAttemptInput) -> Result<(), String> { + validate_identity(&input.run_id, "run id")?; + if input.finding_ids.is_empty() || input.finding_ids.len() > MAX_FINDINGS { + return Err(format!("Select between 1 and {MAX_FINDINGS} findings")); + } + let unique = input.finding_ids.iter().collect::>(); + if unique.len() != input.finding_ids.len() { + return Err("Finding selection contains duplicate identities".into()); + } + for finding_id in &input.finding_ids { + validate_identity(finding_id, "finding id")?; + } + if !matches!(input.agent.as_str(), "claude" | "gemini" | "codex") { + return Err("Fix agent must be `claude`, `gemini`, or `codex`".into()); + } + if !(100..=120_000).contains(&input.timeout_ms) { + return Err("Correctness timeout must be between 100 and 120,000 milliseconds".into()); + } + if !input.confirmed { + return Err( + "Fix execution requires explicit confirmation because it invokes an agent and edits an isolated worktree" + .into(), + ); + } + Ok(()) +} + +fn validate_packet_source( + packet: &AgentFixPacketReceipt, + receipt: &LocalCheckReceipt, +) -> Result<(), String> { + if packet.run_id != receipt.run_id + || packet.repo_path != receipt.repo_path + || packet.source.input != receipt.source.input + || packet.source.base_sha != receipt.source.base_sha + || packet.source.head_sha != receipt.source.head_sha + { + return Err("The fix packet drifted from its persisted local-check source identity".into()); + } + Ok(()) +} + +fn canonical_git_repository(path: &Path) -> Result { + let canonical = fs::canonicalize(path) + .map_err(|error| format!("Repository {} is unavailable: {error}", path.display()))?; + let output = StdCommand::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(&canonical) + .output() + .map_err(|error| format!("Inspect repository: {error}"))?; + if !output.status.success() { + return Err("Fix execution requires a readable Git repository".into()); + } + let top = fs::canonicalize(String::from_utf8_lossy(&output.stdout).trim()) + .map_err(|_| "The Git repository root is unavailable".to_string())?; + if top != canonical { + return Err("Fix execution requires the exact Git repository root".into()); + } + Ok(canonical) +} + +fn require_commit(repository: &Path, sha: &str) -> Result<(), String> { + if !valid_sha(sha) { + return Err("The source receipt head is not an exact Git SHA".into()); + } + let output = StdCommand::new("git") + .args(["cat-file", "-e", &format!("{sha}^{{commit}}")]) + .current_dir(repository) + .output() + .map_err(|error| format!("Inspect source commit: {error}"))?; + if !output.status.success() { + return Err("The exact source receipt head is no longer available locally".into()); + } + Ok(()) +} + +fn create_detached_worktree(repository: &Path, worktree: &Path, sha: &str) -> Result<(), String> { + if worktree.exists() { + return Err("The generated fix-attempt worktree already exists".into()); + } + let parent = worktree + .parent() + .ok_or_else(|| "The fix-attempt directory is invalid".to_string())?; + fs::create_dir_all(parent).map_err(|error| format!("Create fix-attempt directory: {error}"))?; + let output = StdCommand::new("git") + .args(["worktree", "add", "--detach"]) + .arg(worktree) + .arg(sha) + .current_dir(repository) + .output() + .map_err(|error| format!("Create isolated worktree: {error}"))?; + if !output.status.success() { + return Err(format!( + "Create isolated worktree: {}", + bounded_diagnostic(&output.stderr, 4_096) + )); + } + Ok(()) +} + +async fn run_fix_agent(agent: &str, worktree: &Path, prompt: &str) -> AgentExecution { + let cli_path = resolve_cli_path(agent); + if cli_path == agent { + return AgentExecution { + success: false, + duration_ms: 0, + diagnostic: Some(format!("Coding agent `{agent}` is unavailable")), + }; + } + run_fix_agent_at(agent, Path::new(&cli_path), worktree, prompt).await +} + +async fn run_fix_agent_at( + agent: &str, + cli_path: &Path, + worktree: &Path, + prompt: &str, +) -> AgentExecution { + let started = Instant::now(); + let mut command = TokioCommand::new(cli_path); + command + .args(fix_agent_arguments(agent, prompt)) + .current_dir(worktree) + .env("CODEVETTER_FIX_ATTEMPT", "1") + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_CONFIG_COUNT", "1") + .env("GIT_CONFIG_KEY_0", "push.default") + .env("GIT_CONFIG_VALUE_0", "nothing") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + #[cfg(unix)] + unsafe { + command.pre_exec(|| { + if libc::setpgid(0, 0) == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + return AgentExecution { + success: false, + duration_ms: elapsed_ms(started), + diagnostic: Some(format!("Start coding agent `{agent}`: {error}")), + }; + } + }; + let pid = child.id(); + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let stdout_task = stdout.map(|stream| tokio::spawn(read_bounded(stream))); + let stderr_task = stderr.map(|stream| tokio::spawn(read_bounded(stream))); + let status = match tokio::time::timeout(AGENT_DEADLINE, child.wait()).await { + Ok(Ok(status)) => Some(status), + Ok(Err(error)) => { + return AgentExecution { + success: false, + duration_ms: elapsed_ms(started), + diagnostic: Some(format!("Wait for coding agent `{agent}`: {error}")), + }; + } + Err(_) => { + #[cfg(unix)] + if let Some(pid) = pid { + unsafe { + libc::kill(-(pid as i32), libc::SIGKILL); + } + } + let _ = child.kill().await; + let _ = child.wait().await; + if let Some(task) = stdout_task { + task.abort(); + } + if let Some(task) = stderr_task { + task.abort(); + } + return AgentExecution { + success: false, + duration_ms: elapsed_ms(started), + diagnostic: Some("Coding agent exceeded the 30 minute deadline".into()), + }; + } + }; + let stdout = join_output(stdout_task).await; + let stderr = join_output(stderr_task).await; + let status = status.expect("completed process has a status"); + AgentExecution { + success: status.success(), + duration_ms: elapsed_ms(started), + diagnostic: if status.success() { + None + } else { + Some(agent_failure_detail(&stdout, &stderr, status.code())) + }, + } +} + +fn fix_agent_arguments(agent: &str, prompt: &str) -> Vec { + match agent { + "claude" => [ + "--setting-sources", + "user", + "--permission-mode", + "acceptEdits", + "--no-session-persistence", + "--no-chrome", + "--strict-mcp-config", + "--mcp-config", + "{\"mcpServers\":{}}", + "-p", + prompt, + ] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + "codex" => [ + "exec", + "--ephemeral", + "--ignore-user-config", + "-c", + "model_reasoning_effort=\"medium\"", + "--sandbox", + "workspace-write", + "--color", + "never", + prompt, + ] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + _ => [ + "--sandbox", + "--approval-mode", + "auto_edit", + "--extensions", + "none", + "-p", + prompt, + ] + .into_iter() + .map(ToOwned::to_owned) + .collect(), + } +} + +fn render_agent_prompt(packet: &AgentFixPacketReceipt) -> String { + format!( + "You are executing a bounded CodeVetter fix attempt in an isolated detached Git worktree. Edit the actual source files in this worktree; do not commit, create branches, merge, push, or modify another checkout. Keep the patch minimal, obey repository instructions, and preserve the recorded evidence contract. Do not claim completion merely by describing a fix.\n\n{}", + packet.markdown + ) +} + +async fn read_bounded(mut stream: R) -> Result, String> { + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .await + .map_err(|error| format!("Read coding-agent output: {error}"))?; + if bytes.len() > MAX_AGENT_OUTPUT_BYTES { + return Err(format!( + "Coding-agent output exceeded {MAX_AGENT_OUTPUT_BYTES} bytes" + )); + } + Ok(bytes) +} + +async fn join_output(task: Option, String>>>) -> Vec { + match task { + Some(task) => task.await.ok().and_then(Result::ok).unwrap_or_default(), + None => Vec::new(), + } +} + +fn agent_failure_detail(stdout: &[u8], stderr: &[u8], code: Option) -> String { + let stderr = bounded_diagnostic(stderr, 2_048); + let stdout = bounded_diagnostic(stdout, 2_048); + let detail = match (stderr.is_empty(), stdout.is_empty()) { + (false, false) => format!("stderr: {stderr}; stdout: {stdout}"), + (false, true) => stderr, + (true, false) => stdout, + (true, true) => "Coding agent returned no diagnostic output".into(), + }; + format!("Coding agent failed with exit {code:?}: {detail}") +} + +fn expose_untracked_diff(worktree: &Path) -> Result<(), String> { + let output = StdCommand::new("git") + .args(["add", "--intent-to-add", "--all"]) + .current_dir(worktree) + .output() + .map_err(|error| format!("Prepare isolated diff: {error}"))?; + if !output.status.success() { + return Err(format!( + "Prepare isolated diff: {}", + bounded_diagnostic(&output.stderr, 4_096) + )); + } + Ok(()) +} + +fn exact_worktree_head(worktree: &Path) -> Result { + let output = git_output(worktree, &["rev-parse", "HEAD"], 1_024)?; + let head = String::from_utf8(output) + .map_err(|_| "The isolated worktree HEAD is not UTF-8".to_string())? + .trim() + .to_string(); + if !valid_sha(&head) { + return Err("The isolated worktree no longer has an exact Git HEAD".into()); + } + Ok(head) +} + +fn collect_change(worktree: &Path) -> Result { + let names = git_output( + worktree, + &["diff", "--name-only", "-z", "HEAD"], + MAX_DIFF_BYTES, + )?; + let changed_files = names + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + .map(|path| String::from_utf8(path.to_vec()).map_err(|_| "Changed path is not UTF-8")) + .collect::, _>>()?; + if changed_files.len() > MAX_CHANGED_FILES { + return Err(format!( + "The fix attempt changed more than {MAX_CHANGED_FILES} files" + )); + } + for path in &changed_files { + validate_relative_path(path)?; + } + let diff = git_output(worktree, &["diff", "--binary", "HEAD"], MAX_DIFF_BYTES)?; + let diff_sha256 = (!diff.is_empty()).then(|| format!("sha256:{:x}", Sha256::digest(&diff))); + let preview_bytes = diff.len().min(MAX_DIFF_PREVIEW_BYTES); + let mut preview_end = preview_bytes; + while preview_end > 0 && std::str::from_utf8(&diff[..preview_end]).is_err() { + preview_end -= 1; + } + Ok(FixAttemptChange { + changed_files, + diff_sha256, + diff_bytes: diff.len(), + diff_preview: String::from_utf8_lossy(&diff[..preview_end]).into_owned(), + preview_truncated: diff.len() > preview_end, + }) +} + +fn run_diff_check(worktree: &Path) -> FixAttemptGate { + match StdCommand::new("git") + .args(["diff", "--check", "HEAD"]) + .current_dir(worktree) + .output() + { + Ok(output) if output.status.success() => FixAttemptGate { + status: "passed".into(), + detail: "git diff --check passed for the isolated worktree".into(), + }, + Ok(output) => FixAttemptGate { + status: "failed".into(), + detail: bounded_diagnostic(&[output.stdout, output.stderr].concat(), 4_096), + }, + Err(error) => FixAttemptGate { + status: "no_confidence".into(), + detail: format!("Could not run git diff --check: {error}"), + }, + } +} + +async fn run_fix_review( + app_data_dir: &Path, + worktree: &Path, + agent: &str, + packet: &AgentFixPacketReceipt, + source_receipt: &LocalCheckReceipt, + correctness: &LocalCheckStage, +) -> FixAttemptReview { + let connection = match db::init_db(app_data_dir.to_path_buf()) { + Ok(connection) => connection, + Err(error) => { + return FixAttemptReview { + status: "no_confidence".into(), + review_id: None, + summary: None, + findings: Vec::new(), + limitation: Some(format!("Open recheck database: {error}")), + }; + } + }; + let db = DbState(std::sync::Arc::new(std::sync::Mutex::new(connection))); + let acceptance = if packet.task.acceptance_criteria.is_empty() { + String::new() + } else { + format!( + "\n\nAcceptance criteria:\n{}", + packet + .task + .acceptance_criteria + .iter() + .map(|item| format!("- {item}")) + .collect::>() + .join("\n") + ) + }; + let qa = json!({ + "kind": "fix_correctness_recheck", + "status": status_name(correctness.status), + "target": correctness.target, + "limitations": correctness.limitations, + }); + match run_cli_review_core( + db, + worktree.to_string_lossy().into_owned(), + "WORKTREE".into(), + format!("Isolated fix attempt for local-check run {}", packet.run_id), + format!( + "Recheck whether the worktree diff resolves only the selected findings while preserving the original task: {}{}", + packet.task.goal, acceptance + ), + Some(agent.into()), + Some(vec![qa]), + source_receipt.standards_pack.clone(), + ) + .await + { + Ok(value) => { + let complete = value.get("review_status").and_then(Value::as_str) + == Some("completed") + && value + .pointer("/review_manifest/complete_coverage") + .and_then(Value::as_bool) + == Some(true); + FixAttemptReview { + status: if complete { + "completed" + } else { + "no_confidence" + } + .into(), + review_id: value + .get("review_id") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + summary: value + .get("summary") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + findings: value + .get("findings") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .take(MAX_FINDINGS) + .collect(), + limitation: (!complete).then(|| { + "Source-qualified re-review completed with incomplete coverage or readiness limitations." + .into() + }), + } + } + Err(error) => FixAttemptReview { + status: "no_confidence".into(), + review_id: None, + summary: None, + findings: Vec::new(), + limitation: Some(format!("Source-qualified re-review did not complete: {error}")), + }, + } +} + +fn project_correctness(stage: &LocalCheckStage) -> FixAttemptCorrectness { + FixAttemptCorrectness { + status: status_name(stage.status).into(), + target: stage + .target + .as_ref() + .map(|target| format!("{} · {}", target.adapter, target.target)), + duration_ms: stage.duration_ms, + evidence: stage.evidence.clone(), + limitations: stage.limitations.clone(), + } +} + +fn classify_findings( + originals: &[FixPacketFinding], + review: &FixAttemptReview, + correctness: &FixAttemptCorrectness, +) -> Vec { + originals + .iter() + .map(|finding| { + if review.status != "completed" || correctness.status == "no_confidence" { + return FixFindingRecheck { + finding_id: finding.id.clone(), + status: "unchecked".into(), + reason: "Executable or source-qualified recheck confidence is incomplete".into(), + }; + } + if review + .findings + .iter() + .any(|candidate| finding_matches(finding, candidate)) + || correctness.status == "failed" + { + FixFindingRecheck { + finding_id: finding.id.clone(), + status: "reproduced".into(), + reason: if correctness.status == "failed" { + "The recorded correctness target still fails".into() + } else { + "Source-qualified re-review reproduced the finding".into() + }, + } + } else if correctness.status == "passed" { + FixFindingRecheck { + finding_id: finding.id.clone(), + status: "fixed".into(), + reason: "The recorded correctness target passed and re-review did not reproduce the finding" + .into(), + } + } else { + FixFindingRecheck { + finding_id: finding.id.clone(), + status: "unchecked".into(), + reason: "The correctness recheck did not produce a passing executable result".into(), + } + } + }) + .collect() +} + +fn finding_matches(original: &FixPacketFinding, candidate: &Value) -> bool { + let candidate_path = candidate + .get("filePath") + .or_else(|| candidate.get("file_path")) + .and_then(Value::as_str) + .unwrap_or_default(); + if candidate_path != original.file_path { + return false; + } + let candidate_line = candidate.get("line").and_then(Value::as_i64); + if original + .line + .zip(candidate_line) + .is_some_and(|(left, right)| left.abs_diff(right) <= 5) + { + return true; + } + let candidate_title = candidate + .get("title") + .and_then(Value::as_str) + .unwrap_or_default(); + token_similarity(&original.title, candidate_title) >= 0.5 +} + +fn token_similarity(left: &str, right: &str) -> f64 { + let left = title_tokens(left); + let right = title_tokens(right); + if left.is_empty() || right.is_empty() { + return 0.0; + } + let intersection = left.intersection(&right).count() as f64; + let union = left.union(&right).count() as f64; + intersection / union +} + +fn title_tokens(value: &str) -> BTreeSet { + value + .split(|character: char| !character.is_ascii_alphanumeric()) + .map(str::to_ascii_lowercase) + .filter(|token| token.len() >= 3) + .collect() +} + +fn classify_attempt_state( + diff_check: &FixAttemptGate, + correctness: &FixAttemptCorrectness, + review: &FixAttemptReview, + findings: &[FixFindingRecheck], +) -> String { + if diff_check.status == "failed" || correctness.status == "failed" { + return "reproduced".into(); + } + if diff_check.status != "passed" + || correctness.status != "passed" + || review.status != "completed" + || findings.iter().any(|finding| finding.status == "unchecked") + { + return "no_confidence".into(); + } + if findings + .iter() + .any(|finding| finding.status == "reproduced") + { + "reproduced".into() + } else if !review.findings.is_empty() { + "needs_attention".into() + } else { + "verified_fixed".into() + } +} + +#[allow(clippy::too_many_arguments)] +fn terminal_receipt( + attempt_id: &str, + state: &str, + input: &FixAttemptInput, + packet: &AgentFixPacketReceipt, + repository: &Path, + worktree: &Path, + execution: AgentExecution, + change: FixAttemptChange, + recheck: FixAttemptRecheck, + limitations: Vec, + started_at: String, +) -> FixAttemptReceipt { + FixAttemptReceipt { + schema_version: SCHEMA_VERSION.into(), + attempt_id: attempt_id.into(), + operation: "execute".into(), + state: state.into(), + source_run_id: input.run_id.clone(), + repository_path: repository.to_string_lossy().into_owned(), + source: FixAttemptSource { + input: packet.source.input.clone(), + base_sha: packet.source.base_sha.clone(), + head_sha: packet.source.head_sha.clone(), + }, + worktree: FixAttemptWorktree { + path: worktree.to_string_lossy().into_owned(), + detached: true, + retained: true, + source_head_sha: packet.source.head_sha.clone(), + }, + agent: FixAttemptAgent { + id: input.agent.clone(), + status: if execution.success { + "completed" + } else { + "failed" + } + .into(), + duration_ms: execution.duration_ms, + diagnostic: execution.diagnostic, + }, + change, + recheck, + limitations, + started_at, + completed_at: chrono::Utc::now().to_rfc3339(), + } +} + +fn unchecked_recheck(packet: &AgentFixPacketReceipt, reason: &str) -> FixAttemptRecheck { + FixAttemptRecheck { + diff_check: FixAttemptGate { + status: "unchecked".into(), + detail: reason.into(), + }, + correctness: FixAttemptCorrectness { + status: "unchecked".into(), + target: None, + duration_ms: 0, + evidence: Value::Null, + limitations: vec![reason.into()], + }, + review: FixAttemptReview { + status: "unchecked".into(), + review_id: None, + summary: None, + findings: Vec::new(), + limitation: Some(reason.into()), + }, + findings: packet + .findings + .iter() + .map(|finding| FixFindingRecheck { + finding_id: finding.id.clone(), + status: "unchecked".into(), + reason: reason.into(), + }) + .collect(), + } +} + +fn empty_change() -> FixAttemptChange { + FixAttemptChange { + changed_files: Vec::new(), + diff_sha256: None, + diff_bytes: 0, + diff_preview: String::new(), + preview_truncated: false, + } +} + +fn persist_receipt(root: &Path, receipt: &FixAttemptReceipt) -> Result<(), String> { + fs::create_dir_all(root) + .map_err(|error| format!("Create fix-attempt receipt directory: {error}"))?; + let bytes = serde_json::to_vec_pretty(receipt) + .map_err(|error| format!("Encode fix-attempt receipt: {error}"))?; + let temporary = root.join("receipt.json.tmp"); + let destination = root.join("receipt.json"); + fs::write(&temporary, bytes).map_err(|error| format!("Write fix-attempt receipt: {error}"))?; + fs::rename(&temporary, &destination) + .map_err(|error| format!("Publish fix-attempt receipt: {error}"))?; + Ok(()) +} + +fn attempt_root(app_data_dir: &Path, attempt_id: &str) -> Result { + validate_identity(attempt_id, "attempt id")?; + if !attempt_id.starts_with("fix-attempt-") { + return Err("Fix-attempt identity has an unsupported prefix".into()); + } + Ok(app_data_dir.join("fix-attempts").join(attempt_id)) +} + +fn git_output(worktree: &Path, arguments: &[&str], max_bytes: usize) -> Result, String> { + let output = StdCommand::new("git") + .args(arguments) + .current_dir(worktree) + .output() + .map_err(|error| format!("Run git {}: {error}", arguments.join(" ")))?; + if !output.status.success() { + return Err(format!( + "git {} failed: {}", + arguments.join(" "), + bounded_diagnostic(&output.stderr, 4_096) + )); + } + if output.stdout.len() > max_bytes || output.stderr.len() > max_bytes { + return Err(format!( + "git {} output exceeded its bound", + arguments.join(" ") + )); + } + Ok(output.stdout) +} + +fn validate_identity(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 160 + || value.trim() != value + || value.contains('\0') + || value.contains(['\r', '\n']) + || !value + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + { + return Err(format!("{label} must be a bounded lowercase-safe identity")); + } + Ok(()) +} + +fn validate_relative_path(value: &str) -> Result<(), String> { + let path = Path::new(value); + if value.is_empty() + || path.is_absolute() + || path + .components() + .any(|component| !matches!(component, std::path::Component::Normal(_))) + { + return Err("The fix attempt produced an unsafe changed path".into()); + } + Ok(()) +} + +fn valid_sha(value: &str) -> bool { + matches!(value.len(), 40 | 64) + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn status_name(status: LocalCheckStatus) -> &'static str { + match status { + LocalCheckStatus::Passed => "passed", + LocalCheckStatus::Completed => "completed", + LocalCheckStatus::Ready => "ready", + LocalCheckStatus::NeedsAttention => "needs_attention", + LocalCheckStatus::Failed => "failed", + LocalCheckStatus::NoConfidence => "no_confidence", + } +} + +fn bounded_diagnostic(bytes: &[u8], limit: usize) -> String { + String::from_utf8_lossy(bytes) + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(limit) + .collect() +} + +fn elapsed_ms(started: Instant) -> u64 { + started.elapsed().as_millis().try_into().unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + fn finding(id: &str, title: &str, path: &str, line: i64) -> FixPacketFinding { + FixPacketFinding { + id: id.into(), + severity: "high".into(), + title: title.into(), + summary: "Fixture problem".into(), + suggestion: None, + file_path: path.into(), + line: Some(line), + confidence: Some(0.9), + } + } + + fn correctness(status: &str) -> FixAttemptCorrectness { + FixAttemptCorrectness { + status: status.into(), + target: Some("vitest · src/cart.test.ts".into()), + duration_ms: 10, + evidence: json!({}), + limitations: Vec::new(), + } + } + + #[test] + fn execution_requires_explicit_consent_and_bounded_inputs() { + let mut input = FixAttemptInput { + run_id: "local-check-7".into(), + finding_ids: vec!["finding-1".into()], + agent: "codex".into(), + confirmed: false, + timeout_ms: 30_000, + }; + assert!(validate_execute_input(&input) + .unwrap_err() + .contains("explicit confirmation")); + input.confirmed = true; + assert!(validate_execute_input(&input).is_ok()); + input.finding_ids.push("finding-1".into()); + assert!(validate_execute_input(&input) + .unwrap_err() + .contains("duplicate")); + } + + #[test] + fn finding_recheck_matches_nearby_lines_or_same_file_title_tokens() { + let original = finding( + "finding-1", + "Checkout total uses stale subtotal", + "src/cart.ts", + 42, + ); + assert!(finding_matches( + &original, + &json!({"filePath":"src/cart.ts","line":45,"title":"Different wording"}) + )); + assert!(finding_matches( + &original, + &json!({"file_path":"src/cart.ts","line":90,"title":"Checkout total uses stale value"}) + )); + assert!(!finding_matches( + &original, + &json!({"filePath":"src/other.ts","line":42,"title":"Checkout total uses stale subtotal"}) + )); + } + + #[test] + fn fixed_status_requires_both_executable_pass_and_completed_rereview() { + let originals = vec![finding("finding-1", "Stale subtotal", "src/cart.ts", 42)]; + let completed = FixAttemptReview { + status: "completed".into(), + review_id: Some("review-8".into()), + summary: None, + findings: Vec::new(), + limitation: None, + }; + let rechecks = classify_findings(&originals, &completed, &correctness("passed")); + assert_eq!(rechecks[0].status, "fixed"); + assert_eq!( + classify_attempt_state( + &FixAttemptGate { + status: "passed".into(), + detail: String::new() + }, + &correctness("passed"), + &completed, + &rechecks, + ), + "verified_fixed" + ); + + let unchecked = classify_findings(&originals, &completed, &correctness("no_confidence")); + assert_eq!(unchecked[0].status, "unchecked"); + assert_eq!( + classify_attempt_state( + &FixAttemptGate { + status: "passed".into(), + detail: String::new() + }, + &correctness("no_confidence"), + &completed, + &unchecked, + ), + "no_confidence" + ); + + let new_regression = FixAttemptReview { + findings: vec![json!({ + "filePath": "src/new-regression.ts", + "line": 8, + "title": "Fix introduced a new regression" + })], + ..completed.clone() + }; + let original_fixed = classify_findings(&originals, &new_regression, &correctness("passed")); + assert_eq!(original_fixed[0].status, "fixed"); + assert_eq!( + classify_attempt_state( + &FixAttemptGate { + status: "passed".into(), + detail: String::new() + }, + &correctness("passed"), + &new_regression, + &original_fixed, + ), + "needs_attention" + ); + } + + #[test] + fn coding_agents_use_noninteractive_edit_only_safety_modes() { + let claude = fix_agent_arguments("claude", "prompt"); + assert!(claude + .windows(2) + .any(|pair| pair == ["--permission-mode", "acceptEdits"])); + assert!(claude.contains(&"--strict-mcp-config".to_string())); + assert!(claude.contains(&"--no-session-persistence".to_string())); + + let gemini = fix_agent_arguments("gemini", "prompt"); + assert!(gemini.contains(&"--sandbox".to_string())); + assert!(gemini + .windows(2) + .any(|pair| pair == ["--approval-mode", "auto_edit"])); + assert!(gemini + .windows(2) + .any(|pair| pair == ["--extensions", "none"])); + + let codex = fix_agent_arguments("codex", "prompt"); + assert!(codex + .windows(2) + .any(|pair| pair == ["--sandbox", "workspace-write"])); + assert!(codex.contains(&"--ephemeral".to_string())); + } + + #[cfg(unix)] + #[tokio::test] + async fn fixture_agent_edits_only_the_detached_worktree_and_yields_a_bounded_diff() { + let root = tempfile::tempdir().expect("temporary fix fixture"); + let repository = root.path().join("repository"); + fs::create_dir(&repository).expect("repository directory"); + for arguments in [ + vec!["init"], + vec!["config", "user.email", "fixture@codevetter.test"], + vec!["config", "user.name", "CodeVetter Fixture"], + ] { + assert!(StdCommand::new("git") + .args(arguments) + .current_dir(&repository) + .status() + .expect("git setup") + .success()); + } + fs::write(repository.join("source.txt"), "original\n").expect("fixture source"); + assert!(StdCommand::new("git") + .args(["add", "source.txt"]) + .current_dir(&repository) + .status() + .expect("git add") + .success()); + assert!(StdCommand::new("git") + .args(["commit", "-m", "fixture"]) + .current_dir(&repository) + .status() + .expect("git commit") + .success()); + let head = git_output(&repository, &["rev-parse", "HEAD"], 1_024) + .map(String::from_utf8) + .expect("head bytes") + .expect("head UTF-8"); + let worktree = root.path().join("attempt/worktree"); + create_detached_worktree(&repository, &worktree, head.trim()).expect("detached worktree"); + + let fixture_agent = root.path().join("fixture-codex"); + fs::write( + &fixture_agent, + "#!/bin/sh\nprintf 'fixed by fixture agent\\n' > fixed.txt\n", + ) + .expect("fixture agent"); + fs::set_permissions(&fixture_agent, fs::Permissions::from_mode(0o755)) + .expect("fixture agent permissions"); + let execution = + run_fix_agent_at("codex", &fixture_agent, &worktree, "bounded fixture prompt").await; + assert!(execution.success, "{:?}", execution.diagnostic); + assert!(!repository.join("fixed.txt").exists()); + assert!(worktree.join("fixed.txt").is_file()); + + expose_untracked_diff(&worktree).expect("expose fixture diff"); + let change = collect_change(&worktree).expect("bounded fixture change"); + assert_eq!(change.changed_files, vec!["fixed.txt"]); + assert!(change.diff_bytes > 0); + assert_eq!(run_diff_check(&worktree).status, "passed"); + + assert!(StdCommand::new("git") + .args(["worktree", "remove", "--force"]) + .arg(&worktree) + .current_dir(&repository) + .status() + .expect("remove fixture worktree") + .success()); + } + + #[test] + fn attempt_identity_cannot_escape_the_app_data_root() { + let root = Path::new("/tmp/codevetter-fixture"); + assert_eq!( + attempt_root(root, "fix-attempt-abc123").unwrap(), + root.join("fix-attempts/fix-attempt-abc123") + ); + assert!(attempt_root(root, "../outside").is_err()); + assert!(attempt_root(root, "other-abc123").is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/commands/fix_packet.rs b/apps/desktop/src-tauri/src/commands/fix_packet.rs new file mode 100644 index 00000000..90cb8b9e --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/fix_packet.rs @@ -0,0 +1,507 @@ +//! Deterministic local agent handoff derived from one persisted local-check receipt. + +use rusqlite::{Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashSet; + +use super::local_check::LocalCheckReceipt; + +const SCHEMA_VERSION: &str = "codevetter.agent-fix-packet/v1"; +const MAX_FINDINGS: usize = 100; +const MAX_EVIDENCE_REFS: usize = 24; +const MAX_MARKDOWN_BYTES: usize = 256 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct AgentFixPacketReceipt { + pub schema_version: String, + pub created_at: String, + pub run_id: String, + pub repo_path: String, + pub source: FixPacketSource, + pub agent: String, + pub task: FixPacketTask, + pub route_advice: String, + pub findings: Vec, + pub evidence: Vec, + pub limitations: Vec, + pub markdown: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixPacketSource { + pub input: String, + pub base_sha: String, + pub head_sha: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixPacketTask { + pub goal: String, + pub acceptance_criteria: Vec, + pub non_goals: Vec, + pub source: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct FixPacketFinding { + pub id: String, + pub severity: String, + pub title: String, + pub summary: String, + pub suggestion: Option, + pub file_path: String, + pub line: Option, + pub confidence: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixPacketEvidence { + pub kind: String, + pub status: String, + pub label: String, + pub artifact: Option, + pub qualification: String, +} + +pub fn build_agent_fix_packet( + connection: &Connection, + run_id: &str, + selected_finding_ids: &[String], +) -> Result { + validate_identity(run_id, "run id")?; + if selected_finding_ids.len() > MAX_FINDINGS { + return Err(format!( + "At most {MAX_FINDINGS} findings can enter one fix packet" + )); + } + let selected = selected_finding_ids + .iter() + .map(|id| { + validate_identity(id, "finding id")?; + Ok(id.clone()) + }) + .collect::, String>>()?; + if selected.len() != selected_finding_ids.len() { + return Err("Finding selection contains duplicate identities".into()); + } + + let receipt_json: Option = connection + .query_row( + "SELECT receipt_json FROM local_check_runs WHERE run_id = ?1", + [run_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| format!("Read local-check receipt: {error}"))?; + let receipt_json = receipt_json.ok_or_else(|| "Local-check run was not found".to_string())?; + let receipt: Value = serde_json::from_str(&receipt_json) + .map_err(|error| format!("Decode local-check receipt: {error}"))?; + if string(&receipt, "schema_version") != Some("codevetter.local-check/v1") { + return Err("Only completed codevetter.local-check/v1 receipts support fix packets".into()); + } + + let repo_path = required_string(&receipt, "repo_path")?; + let source = receipt + .get("source") + .ok_or_else(|| "Local-check receipt has no source identity".to_string())?; + let source = FixPacketSource { + input: required_string(source, "input")?, + base_sha: required_string(source, "base_sha")?, + head_sha: required_string(source, "head_sha")?, + }; + let task_goal = required_string(&receipt, "task")?; + let review_evidence = receipt + .pointer("/stages/review/evidence") + .unwrap_or(&Value::Null); + let all_findings = review_evidence + .get("findings") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let mut matched = HashSet::new(); + let mut findings = Vec::new(); + for (index, finding) in all_findings.into_iter().take(MAX_FINDINGS).enumerate() { + let persisted_id = string(&finding, "id").map(ToOwned::to_owned); + if !selected.is_empty() + && !persisted_id + .as_ref() + .is_some_and(|id| selected.contains(id)) + { + continue; + } + let file_path = string(&finding, "filePath") + .or_else(|| string(&finding, "file_path")) + .unwrap_or_default() + .to_string(); + if file_path.is_empty() || std::path::Path::new(&file_path).is_absolute() { + continue; + } + let id = persisted_id.unwrap_or_else(|| format!("receipt-finding-{}", index + 1)); + matched.insert(id.clone()); + findings.push(FixPacketFinding { + id, + severity: string(&finding, "severity") + .unwrap_or("unknown") + .to_string(), + title: required_string(&finding, "title")?, + summary: required_string(&finding, "summary")?, + suggestion: string(&finding, "suggestion") + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned), + file_path, + line: finding.get("line").and_then(Value::as_i64), + confidence: finding.get("confidence").and_then(Value::as_f64), + }); + } + if !selected.is_empty() && !selected.is_subset(&matched) { + let missing = selected.difference(&matched).cloned().collect::>(); + return Err(format!( + "Selected findings are unavailable or not source-qualified: {}", + missing.join(", ") + )); + } + if findings.is_empty() { + return Err("The local-check receipt has no source-qualified findings to hand off".into()); + } + + let acceptance_criteria = receipt + .pointer("/spec_coverage/requirements") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|requirement| { + requirement + .get("supplied_to_review") + .and_then(Value::as_bool) + .unwrap_or(false) + || requirement + .get("selected_for_execution") + .and_then(Value::as_bool) + .unwrap_or(false) + }) + .filter_map(|requirement| { + let title = string(requirement, "title")?; + let text = string(requirement, "text").unwrap_or_default(); + Some(if text.is_empty() { + title.to_string() + } else { + format!("{title}: {text}") + }) + }) + .take(32) + .collect::>(); + + let agent = string(review_evidence, "agent") + .or_else(|| { + review_evidence + .pointer("/review_manifest/executor_id")? + .as_str() + }) + .unwrap_or("coding-agent") + .to_string(); + let evidence = collect_evidence(&receipt, review_evidence); + let high_risk = findings + .iter() + .any(|finding| matches!(finding.severity.as_str(), "critical" | "high")); + let route_advice = if high_risk { + "Use a full coding agent in an isolated worktree; require executable proof before merge." + } else if findings.len() <= 2 { + "Keep the patch tightly scoped, then rerun the exact verification receipt." + } else { + "Split this broad batch by file or behavior before starting the first fix attempt." + } + .to_string(); + let mut limitations = string_array(&receipt, "limitations"); + limitations.push( + "This packet is a deterministic handoff, not proof that a proposed fix is correct.".into(), + ); + if acceptance_criteria.is_empty() { + limitations.push( + "No explicit acceptance requirements were attached; the task goal is the only intent contract." + .into(), + ); + } + let mut packet = AgentFixPacketReceipt { + schema_version: SCHEMA_VERSION.into(), + created_at: chrono::Utc::now().to_rfc3339(), + run_id: run_id.to_string(), + repo_path, + source, + agent, + task: FixPacketTask { + goal: task_goal, + acceptance_criteria, + non_goals: Vec::new(), + source: "persisted_local_check_receipt".into(), + }, + route_advice, + findings, + evidence, + limitations, + markdown: String::new(), + }; + packet.markdown = render_markdown(&packet); + if packet.markdown.len() > MAX_MARKDOWN_BYTES { + return Err("Agent fix packet exceeds the bounded Markdown size".into()); + } + Ok(packet) +} + +pub fn load_local_check_receipt( + connection: &Connection, + run_id: &str, +) -> Result { + validate_identity(run_id, "run id")?; + let receipt_json: Option = connection + .query_row( + "SELECT receipt_json FROM local_check_runs WHERE run_id = ?1", + [run_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| format!("Read local-check receipt: {error}"))?; + let receipt_json = receipt_json.ok_or_else(|| "Local-check run was not found".to_string())?; + let receipt: LocalCheckReceipt = serde_json::from_str(&receipt_json) + .map_err(|error| format!("Decode local-check receipt: {error}"))?; + if receipt.schema_version != "codevetter.local-check/v1" || receipt.run_id != run_id { + return Err( + "Only the exact completed codevetter.local-check/v1 receipt is supported".into(), + ); + } + Ok(receipt) +} + +fn collect_evidence(receipt: &Value, review_evidence: &Value) -> Vec { + let mut rows = Vec::new(); + for stage in ["correctness", "performance"] { + let Some(value) = receipt.pointer(&format!("/stages/{stage}")) else { + continue; + }; + let status = string(value, "status").unwrap_or("unavailable"); + let target = value.get("target"); + let label = target + .and_then(|target| { + let adapter = string(target, "adapter")?; + let path = string(target, "target")?; + Some(format!("{adapter} · {path}")) + }) + .unwrap_or_else(|| format!("{stage} stage")); + rows.push(FixPacketEvidence { + kind: stage.into(), + status: status.into(), + label, + artifact: None, + qualification: "versioned local-check stage".into(), + }); + } + for qa in review_evidence + .get("qa_evidence") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(5) + { + rows.push(FixPacketEvidence { + kind: "synthetic_qa".into(), + status: if qa.get("pass").and_then(Value::as_bool) == Some(true) { + "passed".into() + } else { + "failed".into() + }, + label: string(qa, "goal") + .or_else(|| string(qa, "route")) + .unwrap_or("Recorded QA journey") + .to_string(), + artifact: string(qa, "screenshot_path").map(ToOwned::to_owned), + qualification: "recorded runtime evidence".into(), + }); + } + for step in review_evidence + .get("evidence_procedure_steps") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(8) + { + rows.push(FixPacketEvidence { + kind: "procedure_gate".into(), + status: string(step, "status").unwrap_or("planned").to_string(), + label: string(step, "gate") + .or_else(|| string(step, "procedure")) + .unwrap_or("Evidence procedure") + .to_string(), + artifact: string(step, "artifact") + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + qualification: "deterministic procedure context".into(), + }); + } + rows.truncate(MAX_EVIDENCE_REFS); + rows +} + +fn render_markdown(packet: &AgentFixPacketReceipt) -> String { + let mut out = vec![ + "# Agent Fix Packet".to_string(), + String::new(), + format!("Run: {}", packet.run_id), + format!("Repo: {}", packet.repo_path), + format!("Diff: {}", packet.source.input), + format!("Head: {}", packet.source.head_sha), + format!("Agent: {}", packet.agent), + format!("Route advice: {}", packet.route_advice), + String::new(), + format!("Goal: {}", packet.task.goal), + ]; + if !packet.task.acceptance_criteria.is_empty() { + out.extend([String::new(), "Acceptance:".into()]); + out.extend( + packet + .task + .acceptance_criteria + .iter() + .map(|value| format!("- {value}")), + ); + } + out.extend([String::new(), "Findings:".into()]); + for (index, finding) in packet.findings.iter().enumerate() { + let line = finding + .line + .map(|line| format!(":{line}")) + .unwrap_or_default(); + out.push(format!( + "- {}. [{}] {} ({}{})", + index + 1, + finding.severity, + finding.title, + finding.file_path, + line + )); + out.push(format!(" Problem: {}", finding.summary)); + if let Some(suggestion) = &finding.suggestion { + out.push(format!(" Suggested fix: {suggestion}")); + } + } + if !packet.evidence.is_empty() { + out.extend([String::new(), "Evidence to preserve:".into()]); + for evidence in &packet.evidence { + let artifact = evidence + .artifact + .as_ref() + .map(|value| format!("; artifact={value}")) + .unwrap_or_default(); + out.push(format!( + "- [{} / {}] {} ({}){}", + evidence.kind, evidence.status, evidence.label, evidence.qualification, artifact + )); + } + } + out.extend([String::new(), "Limitations:".into()]); + out.extend(packet.limitations.iter().map(|value| format!("- {value}"))); + out.join("\n") +} + +fn validate_identity(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 160 + || value.trim() != value + || value.contains('\0') + || value.contains(['\r', '\n']) + { + return Err(format!("{label} must be a bounded single-line identity")); + } + Ok(()) +} + +fn required_string(value: &Value, key: &str) -> Result { + string(value, key) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| format!("Local-check receipt is missing {key}")) +} + +fn string<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value.get(key).and_then(Value::as_str) +} + +fn string_array(value: &Value, key: &str) -> Vec { + value + .get(key) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::params; + use serde_json::json; + + fn fixture() -> Connection { + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + let receipt = json!({ + "schema_version": "codevetter.local-check/v1", + "run_id": "local-check-7", + "ran_at": "2026-09-01T00:00:00Z", + "repo_path": "/fixture/repo", + "task": "Preserve checkout totals", + "source": { + "input": "main...HEAD", + "base_sha": "a".repeat(40), + "head_sha": "b".repeat(40), + "changed_paths": ["src/cart.ts"] + }, + "stages": { + "review": {"status":"needs_attention","evidence":{ + "review_manifest":{"executor_id":"claude"}, + "findings":[ + {"id":"finding-1","severity":"high","title":"Stale total","summary":"Uses stale subtotal.","suggestion":"Use discounted total.","filePath":"src/cart.ts","line":42,"confidence":0.94}, + {"id":"finding-2","severity":"low","title":"Copy","summary":"Label is unclear.","filePath":"src/cart.ts","line":9} + ], + "qa_evidence":[{"goal":"Verify checkout","pass":true,"screenshot_path":"artifacts/checkout.png"}], + "evidence_procedure_steps":[{"status":"satisfied","gate":"Exact checkout passes","artifact":"artifacts/receipt.json"}] + }}, + "correctness":{"status":"failed","target":{"adapter":"vitest","target":"src/cart.test.ts"}}, + "performance":{"status":"no_confidence","target":null} + }, + "spec_coverage":{"requirements":[{"title":"Discounted total","text":"Charge the post-discount amount.","supplied_to_review":true,"selected_for_execution":true}]}, + "limitations":["No performance workload matched."] + }); + 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,'codevetter.local-check/v1','/fixture/repo',?2,?3,'needs_attention','Preserve checkout totals',?4,'2026-09-01T00:00:00Z')", + params!["local-check-7", "a".repeat(40), "b".repeat(40), receipt.to_string()], + ).expect("insert receipt"); + connection + } + + #[test] + fn packet_preserves_selected_findings_acceptance_and_runtime_evidence() { + let packet = build_agent_fix_packet(&fixture(), "local-check-7", &["finding-1".into()]) + .expect("fix packet"); + assert_eq!(packet.schema_version, SCHEMA_VERSION); + assert_eq!(packet.findings.len(), 1); + assert_eq!(packet.findings[0].id, "finding-1"); + assert_eq!(packet.task.acceptance_criteria.len(), 1); + assert!(packet + .evidence + .iter() + .any(|row| row.kind == "synthetic_qa" && row.status == "passed")); + assert!(packet.markdown.contains("Use discounted total.")); + assert!(packet + .markdown + .contains("not proof that a proposed fix is correct")); + } + + #[test] + fn packet_rejects_unknown_or_unqualified_finding_selection() { + let error = build_agent_fix_packet(&fixture(), "local-check-7", &["missing".into()]) + .expect_err("unknown finding"); + assert!(error.contains("unavailable")); + } +} diff --git a/apps/desktop/src-tauri/src/commands/history_roots.rs b/apps/desktop/src-tauri/src/commands/history_roots.rs new file mode 100644 index 00000000..3a2437ab --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/history_roots.rs @@ -0,0 +1,250 @@ +use crate::db::queries; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +pub const HISTORY_ROOTS_SCHEMA_VERSION: &str = "codevetter.history-roots/v1"; +const PREFERENCE_KEY: &str = "codex_usage_import_roots"; +const MAX_ROOTS: usize = 16; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HistoryRootsOperation { + Read, + Add, + Remove, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryRoot { + pub path: String, + pub display_path: String, + pub exists: bool, + pub sessions_available: bool, + pub archived_sessions_available: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HistoryRootsReceipt { + pub schema_version: String, + pub generated_at: String, + pub operation: HistoryRootsOperation, + pub database_available: bool, + pub changed_root: Option, + pub roots: Vec, + pub limitations: Vec, +} + +pub fn run_history_roots( + connection: Option<&Connection>, + operation: HistoryRootsOperation, + requested_path: Option<&Path>, +) -> Result { + let mut roots = read_stored_roots(connection)?; + let changed_root = match operation { + HistoryRootsOperation::Read => { + if requested_path.is_some() { + return Err("read does not accept a history-root path".to_string()); + } + None + } + HistoryRootsOperation::Add => { + let connection = connection.ok_or("history-root add requires the local database")?; + let path = normalize_new_root( + requested_path.ok_or("history-root add requires an explicit directory")?, + )?; + let path_string = path.to_string_lossy().to_string(); + if !roots.contains(&path_string) { + if roots.len() >= MAX_ROOTS { + return Err(format!( + "at most {MAX_ROOTS} additional Codex roots are allowed" + )); + } + roots.push(path_string.clone()); + roots.sort(); + persist_roots(connection, &roots)?; + } + Some(path_string) + } + HistoryRootsOperation::Remove => { + let connection = connection.ok_or("history-root remove requires the local database")?; + let requested = requested_path + .ok_or("history-root remove requires an explicit stored path")? + .to_string_lossy() + .to_string(); + let previous_len = roots.len(); + roots.retain(|root| root != &requested); + if roots.len() == previous_len { + return Err("the requested history root is not configured".to_string()); + } + persist_roots(connection, &roots)?; + Some(requested) + } + }; + + Ok(HistoryRootsReceipt { + schema_version: HISTORY_ROOTS_SCHEMA_VERSION.to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + operation, + database_available: connection.is_some(), + changed_root, + roots: roots.iter().map(|root| describe_root(root)).collect(), + limitations: vec![ + "The active CODEX_HOME remains automatic and is not duplicated here.".to_string(), + "Saving a root does not start reconciliation or read transcript content.".to_string(), + "Removing a root changes future discovery only; it does not delete provider transcripts." + .to_string(), + ], + }) +} + +fn read_stored_roots(connection: Option<&Connection>) -> Result, String> { + let Some(connection) = connection else { + return Ok(Vec::new()); + }; + let Some(raw) = queries::get_preference(connection, PREFERENCE_KEY) + .map_err(|error| format!("read additional Codex roots: {error}"))? + else { + return Ok(Vec::new()); + }; + let roots: Vec = serde_json::from_str(&raw) + .map_err(|error| format!("stored additional Codex roots are invalid: {error}"))?; + validate_stored_roots(roots) +} + +fn validate_stored_roots(roots: Vec) -> Result, String> { + if roots.len() > MAX_ROOTS { + return Err(format!( + "stored additional Codex roots exceed the {MAX_ROOTS}-root limit" + )); + } + let mut valid = Vec::with_capacity(roots.len()); + for root in roots { + if root.is_empty() || root.len() > 4_096 || root.contains(['\0', '\n', '\r']) { + return Err("stored additional Codex roots contain an invalid path".to_string()); + } + if !Path::new(&root).is_absolute() { + return Err("stored additional Codex roots must be absolute paths".to_string()); + } + if !valid.contains(&root) { + valid.push(root); + } + } + valid.sort(); + Ok(valid) +} + +fn normalize_new_root(path: &Path) -> Result { + let canonical = std::fs::canonicalize(path) + .map_err(|error| format!("open selected Codex history root: {error}"))?; + if !canonical.is_dir() { + return Err("the selected Codex history root is not a directory".to_string()); + } + let base = match canonical.file_name().and_then(|name| name.to_str()) { + Some("sessions" | "archived_sessions") => canonical + .parent() + .map(Path::to_path_buf) + .ok_or("the selected sessions directory has no parent")?, + _ => canonical, + }; + if !base.join("sessions").is_dir() && !base.join("archived_sessions").is_dir() { + return Err( + "select a Codex home, sessions directory, or archived_sessions directory".to_string(), + ); + } + Ok(base) +} + +fn persist_roots(connection: &Connection, roots: &[String]) -> Result<(), String> { + let serialized = serde_json::to_string(roots) + .map_err(|error| format!("serialize additional Codex roots: {error}"))?; + queries::set_preference(connection, PREFERENCE_KEY, &serialized) + .map_err(|error| format!("save additional Codex roots: {error}")) +} + +fn describe_root(path: &str) -> HistoryRoot { + let root = Path::new(path); + HistoryRoot { + path: path.to_string(), + display_path: display_path(root), + exists: root.is_dir(), + sessions_available: root.join("sessions").is_dir(), + archived_sessions_available: root.join("archived_sessions").is_dir(), + } +} + +fn display_path(path: &Path) -> String { + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + if let Ok(relative) = path.strip_prefix(home) { + return format!("~/{}", relative.to_string_lossy()); + } + } + path.to_string_lossy().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + + #[test] + fn history_roots_normalize_dedupe_and_remove_without_reading_transcripts() { + let fixture = tempfile::tempdir().expect("fixture"); + let codex_home = fixture.path().join("codex-home"); + std::fs::create_dir_all(codex_home.join("sessions")).expect("sessions"); + std::fs::write( + codex_home.join("sessions").join("secret.jsonl"), + "secret transcript", + ) + .expect("transcript"); + + let connection = Connection::open_in_memory().expect("database"); + db::schema::run_migrations(&connection).expect("schema"); + let added = run_history_roots( + Some(&connection), + HistoryRootsOperation::Add, + Some(&codex_home.join("sessions")), + ) + .expect("add root"); + assert_eq!(added.roots.len(), 1); + let canonical_home = std::fs::canonicalize(&codex_home).expect("canonical Codex home"); + assert_eq!(added.roots[0].path, canonical_home.to_string_lossy()); + assert!(added.roots[0].sessions_available); + assert!(!serde_json::to_string(&added) + .expect("receipt") + .contains("secret transcript")); + + let duplicate = run_history_roots( + Some(&connection), + HistoryRootsOperation::Add, + Some(&codex_home), + ) + .expect("dedupe root"); + assert_eq!(duplicate.roots.len(), 1); + + let removed = run_history_roots( + Some(&connection), + HistoryRootsOperation::Remove, + Some(Path::new(&added.roots[0].path)), + ) + .expect("remove root"); + assert!(removed.roots.is_empty()); + assert!(codex_home.join("sessions").join("secret.jsonl").is_file()); + } + + #[test] + fn history_roots_reject_unrelated_or_relative_directories() { + let fixture = tempfile::tempdir().expect("fixture"); + let connection = Connection::open_in_memory().expect("database"); + db::schema::run_migrations(&connection).expect("schema"); + assert!(run_history_roots( + Some(&connection), + HistoryRootsOperation::Add, + Some(fixture.path()), + ) + .is_err()); + queries::set_preference(&connection, PREFERENCE_KEY, "[\"relative/path\"]") + .expect("invalid stored root"); + assert!(run_history_roots(Some(&connection), HistoryRootsOperation::Read, None).is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/commands/native_settings.rs b/apps/desktop/src-tauri/src/commands/native_settings.rs new file mode 100644 index 00000000..bc3bc217 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/native_settings.rs @@ -0,0 +1,535 @@ +use crate::db::queries; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +pub const NATIVE_SETTINGS_SCHEMA_VERSION: &str = "codevetter.native-settings/v1"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum NativeSettingKind { + Toggle, + Choice, + Text, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NativeSettingOption { + pub value: String, + pub label: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NativeSettingValue { + pub key: String, + pub section: String, + pub label: String, + pub description: String, + pub kind: NativeSettingKind, + pub value: String, + pub default_value: String, + pub options: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NativeSettingsReceipt { + pub schema_version: String, + pub generated_at: String, + pub database_available: bool, + pub saved_key: Option, + pub settings: Vec, + pub excluded_sensitive_keys: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct NativeSettingDefinition { + key: &'static str, + section: &'static str, + label: &'static str, + description: &'static str, + kind: NativeSettingKind, + default_value: &'static str, + options: &'static [(&'static str, &'static str)], +} + +const EMPTY_OPTIONS: &[(&str, &str)] = &[]; +const REVIEW_TONES: &[(&str, &str)] = &[ + ("concise", "Concise"), + ("thorough", "Thorough"), + ("mentoring", "Mentoring"), + ("strict", "Strict"), +]; +const ADAPTERS: &[(&str, &str)] = &[("claude-code", "Claude Code"), ("codex", "Codex")]; +const ROLES: &[(&str, &str)] = &[ + ("coder", "Coder"), + ("reviewer", "Reviewer"), + ("planner", "Planner"), + ("debugger", "Debugger"), +]; +const CONCURRENCY: &[(&str, &str)] = + &[("1", "1"), ("2", "2"), ("3", "3"), ("5", "5"), ("10", "10")]; +const TRAY_CADENCE: &[(&str, &str)] = &[ + ("manual", "Manual only"), + ("60", "Every minute"), + ("120", "Every 2 minutes"), + ("300", "Every 5 minutes"), + ("900", "Every 15 minutes"), +]; +const ISLAND_VOLUME: &[(&str, &str)] = &[("0.5", "Quiet"), ("0.8", "Balanced"), ("1", "Full")]; +const ISLAND_PACE: &[(&str, &str)] = + &[("0.4", "Measured"), ("0.48", "Balanced"), ("0.56", "Quick")]; +const ISLAND_COOLDOWN: &[(&str, &str)] = &[ + ("15", "15 seconds"), + ("30", "30 seconds"), + ("60", "1 minute"), +]; +const ISLAND_QUIET_START: &[(&str, &str)] = &[ + ("", "Off"), + ("20", "8 PM"), + ("21", "9 PM"), + ("22", "10 PM"), + ("23", "11 PM"), +]; +const ISLAND_QUIET_END: &[(&str, &str)] = &[ + ("", "Off"), + ("6", "6 AM"), + ("7", "7 AM"), + ("8", "8 AM"), + ("9", "9 AM"), +]; + +const DEFINITIONS: &[NativeSettingDefinition] = &[ + definition( + "review_tone", + "general", + "Default Review Tone", + "Default tone for a new review.", + NativeSettingKind::Choice, + "thorough", + REVIEW_TONES, + ), + definition( + "compact_mode", + "appearance", + "Compact Mode", + "Use denser spacing on supported workbench surfaces.", + NativeSettingKind::Toggle, + "false", + EMPTY_OPTIONS, + ), + definition( + "show_line_numbers", + "appearance", + "Show Line Numbers", + "Show line identities in source and finding references.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "show_costs", + "appearance", + "Show Costs", + "Show available local cost evidence without inferring cloud quota.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "default_adapter", + "agents", + "Default Adapter", + "Preferred coding-agent adapter for new work.", + NativeSettingKind::Choice, + "claude-code", + ADAPTERS, + ), + definition( + "default_role", + "agents", + "Default Role", + "Default role assigned to a new agent launch.", + NativeSettingKind::Choice, + "coder", + ROLES, + ), + definition( + "max_concurrent_agents", + "agents", + "Max Concurrent Agents", + "Maximum number of agent processes allowed by the current preference.", + NativeSettingKind::Choice, + "3", + CONCURRENCY, + ), + definition( + "claude_cli_path", + "agents", + "Claude Code CLI", + "Optional explicit path; empty keeps executable discovery enabled.", + NativeSettingKind::Text, + "", + EMPTY_OPTIONS, + ), + definition( + "codex_cli_path", + "agents", + "Codex CLI", + "Optional explicit path; empty keeps executable discovery enabled.", + NativeSettingKind::Text, + "", + EMPTY_OPTIONS, + ), + definition( + "notify_review_done", + "notifications", + "Review Completed", + "Notify when a code review finishes.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "notify_agent_error", + "notifications", + "Agent Error", + "Notify when an agent reports a terminal error.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "notify_task_complete", + "notifications", + "Task Completed", + "Notify when an agent finishes a task.", + NativeSettingKind::Toggle, + "false", + EMPTY_OPTIONS, + ), + definition( + "notify_quota_thresholds", + "notifications", + "Provider Quota Thresholds", + "Notify only from observed provider-window telemetry.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "notify_session_usage_thresholds", + "notifications", + "Session Usage Thresholds", + "Notify from indexed session context estimates when enabled.", + NativeSettingKind::Toggle, + "false", + EMPTY_OPTIONS, + ), + definition( + "notification_sound", + "notifications", + "Notification Sounds", + "Play the configured local notification tone.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "tray_refresh_cadence_secs", + "notifications", + "Menu Bar Refresh Cadence", + "Polling cadence for observed live-provider usage.", + NativeSettingKind::Choice, + "300", + TRAY_CADENCE, + ), + definition( + "native_agent_island_enabled", + "agent_island", + "Native Agent Island", + "Retain the opt-in preference for the supervised macOS agent-status surface.", + NativeSettingKind::Toggle, + "false", + EMPTY_OPTIONS, + ), + definition( + "native_agent_island_speech_muted", + "agent_island", + "Mute Voice Callouts", + "Keep visual status available without speaking agent updates.", + NativeSettingKind::Toggle, + "false", + EMPTY_OPTIONS, + ), + definition( + "native_agent_island_speak_completion", + "agent_island", + "Speak Completions", + "Announce the provider and project when a turn finishes.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "native_agent_island_speak_attention", + "agent_island", + "Speak Attention Requests", + "Announce confirmed questions and permission requests.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "native_agent_island_speak_failure", + "agent_island", + "Speak Failures", + "Announce when an owned agent session fails.", + NativeSettingKind::Toggle, + "true", + EMPTY_OPTIONS, + ), + definition( + "native_agent_island_speech_volume", + "agent_island", + "Voice Volume", + "Set the local system voice volume for Agent Island callouts.", + NativeSettingKind::Choice, + "0.8", + ISLAND_VOLUME, + ), + definition( + "native_agent_island_speech_rate", + "agent_island", + "Voice Pace", + "Choose a calm local speech rate.", + NativeSettingKind::Choice, + "0.48", + ISLAND_PACE, + ), + definition( + "native_agent_island_speech_cooldown", + "agent_island", + "Repeat Cooldown", + "Coalesce repeated callouts for the same session and state.", + NativeSettingKind::Choice, + "30", + ISLAND_COOLDOWN, + ), + definition( + "native_agent_island_quiet_start", + "agent_island", + "Quiet Hours Start", + "Optional local hour when voice callouts pause.", + NativeSettingKind::Choice, + "", + ISLAND_QUIET_START, + ), + definition( + "native_agent_island_quiet_end", + "agent_island", + "Quiet Hours End", + "Optional local hour when voice callouts resume.", + NativeSettingKind::Choice, + "", + ISLAND_QUIET_END, + ), + definition( + "native_agent_island_codex_voice", + "agent_island", + "Codex Voice", + "Optional macOS voice identifier; empty preserves the distinct system default.", + NativeSettingKind::Text, + "", + EMPTY_OPTIONS, + ), + definition( + "native_agent_island_claude_voice", + "agent_island", + "Claude Voice", + "Optional macOS voice identifier; empty preserves the distinct system default.", + NativeSettingKind::Text, + "", + EMPTY_OPTIONS, + ), +]; + +const fn definition( + key: &'static str, + section: &'static str, + label: &'static str, + description: &'static str, + kind: NativeSettingKind, + default_value: &'static str, + options: &'static [(&'static str, &'static str)], +) -> NativeSettingDefinition { + NativeSettingDefinition { + key, + section, + label, + description, + kind, + default_value, + options, + } +} + +pub fn list_native_settings( + connection: Option<&Connection>, +) -> Result { + settings_receipt(connection, None) +} + +pub fn set_native_setting( + connection: &Connection, + key: &str, + value: &str, +) -> Result { + let definition = DEFINITIONS + .iter() + .find(|definition| definition.key == key) + .ok_or_else(|| format!("setting `{key}` is not in the native non-secret allowlist"))?; + validate_value(definition, value)?; + queries::set_preference(connection, key, value) + .map_err(|error| format!("save native setting `{key}`: {error}"))?; + settings_receipt(Some(connection), Some(key.to_string())) +} + +fn settings_receipt( + connection: Option<&Connection>, + saved_key: Option, +) -> Result { + let mut settings = Vec::with_capacity(DEFINITIONS.len()); + for definition in DEFINITIONS { + let persisted = connection + .map(|connection| queries::get_preference(connection, definition.key)) + .transpose() + .map_err(|error| format!("read native setting `{}`: {error}", definition.key))? + .flatten(); + let value = persisted.unwrap_or_else(|| definition.default_value.to_string()); + validate_value(definition, &value).map_err(|error| { + format!( + "stored native setting `{}` is invalid and was not projected: {error}", + definition.key + ) + })?; + settings.push(NativeSettingValue { + key: definition.key.to_string(), + section: definition.section.to_string(), + label: definition.label.to_string(), + description: definition.description.to_string(), + kind: definition.kind, + value, + default_value: definition.default_value.to_string(), + options: definition + .options + .iter() + .map(|(value, label)| NativeSettingOption { + value: (*value).to_string(), + label: (*label).to_string(), + }) + .collect(), + }); + } + Ok(NativeSettingsReceipt { + schema_version: NATIVE_SETTINGS_SCHEMA_VERSION.to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + database_available: connection.is_some(), + saved_key, + settings, + excluded_sensitive_keys: vec!["github_token".to_string()], + }) +} + +fn validate_value(definition: &NativeSettingDefinition, value: &str) -> Result<(), String> { + if value.contains('\0') || value.len() > 1_024 { + return Err("value must be at most 1024 characters and contain no NUL byte".to_string()); + } + if matches!( + definition.key, + "native_agent_island_codex_voice" | "native_agent_island_claude_voice" + ) && (value.chars().count() > 256 || value.chars().any(char::is_control)) + { + return Err( + "Agent Island voice identifiers must be at most 256 characters and contain no control characters" + .to_string(), + ); + } + match definition.kind { + NativeSettingKind::Toggle if value != "true" && value != "false" => { + Err("toggle value must be true or false".to_string()) + } + NativeSettingKind::Choice + if !definition + .options + .iter() + .any(|(candidate, _)| *candidate == value) => + { + Err("value is not one of the declared options".to_string()) + } + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + + #[test] + fn native_settings_project_only_allowlisted_non_secret_values() { + let connection = Connection::open_in_memory().expect("database"); + db::schema::run_migrations(&connection).expect("schema"); + queries::set_preference(&connection, "github_token", "secret-value").expect("secret"); + let receipt = list_native_settings(Some(&connection)).expect("settings"); + + assert_eq!(receipt.schema_version, NATIVE_SETTINGS_SCHEMA_VERSION); + assert!(receipt + .settings + .iter() + .all(|setting| setting.key != "github_token")); + assert_eq!(receipt.excluded_sensitive_keys, vec!["github_token"]); + assert!(!serde_json::to_string(&receipt) + .expect("json") + .contains("secret-value")); + } + + #[test] + fn native_settings_validate_and_round_trip_declared_values() { + let connection = Connection::open_in_memory().expect("database"); + db::schema::run_migrations(&connection).expect("schema"); + + let receipt = set_native_setting(&connection, "review_tone", "strict").expect("save"); + assert_eq!(receipt.saved_key.as_deref(), Some("review_tone")); + assert_eq!( + receipt + .settings + .iter() + .find(|setting| setting.key == "review_tone") + .map(|setting| setting.value.as_str()), + Some("strict") + ); + assert!(set_native_setting(&connection, "review_tone", "invented").is_err()); + assert!(set_native_setting(&connection, "github_token", "secret").is_err()); + + let island = set_native_setting(&connection, "native_agent_island_enabled", "true") + .expect("save island setting"); + let island_settings = island + .settings + .iter() + .filter(|setting| setting.section == "agent_island") + .collect::>(); + assert_eq!(island_settings.len(), 12); + assert_eq!( + island_settings + .iter() + .find(|setting| setting.key == "native_agent_island_enabled") + .map(|setting| setting.value.as_str()), + Some("true") + ); + assert!(set_native_setting( + &connection, + "native_agent_island_codex_voice", + &"a".repeat(257), + ) + .is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/commands/onboarding.rs b/apps/desktop/src-tauri/src/commands/onboarding.rs new file mode 100644 index 00000000..1d11d3fc --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/onboarding.rs @@ -0,0 +1,185 @@ +use crate::commands::native_settings::{list_native_settings, set_native_setting}; +use crate::commands::review::resolve_cli_path; +use crate::db::queries; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +pub const ONBOARDING_SCHEMA_VERSION: &str = "codevetter.onboarding/v1"; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OnboardingOperation { + Inspect, + Complete, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OnboardingToolStatus { + pub id: String, + pub label: String, + pub available: bool, + pub role: String, + pub authentication: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OnboardingReceipt { + pub schema_version: String, + pub generated_at: String, + pub operation: OnboardingOperation, + pub completed: bool, + pub completion_source: String, + pub default_adapter: String, + pub tools: Vec, + pub limitations: Vec, +} + +pub fn inspect_onboarding(connection: Option<&Connection>) -> Result { + onboarding_receipt(connection, OnboardingOperation::Inspect) +} + +pub fn complete_onboarding( + connection: &Connection, + default_adapter: &str, +) -> Result { + connection + .execute_batch("BEGIN IMMEDIATE TRANSACTION") + .map_err(|error| format!("begin onboarding update: {error}"))?; + let result = (|| { + set_native_setting(connection, "default_adapter", default_adapter)?; + queries::set_preference(connection, "onboarding_complete", "true") + .map_err(|error| format!("save onboarding completion: {error}"))?; + Ok::<(), String>(()) + })(); + match result { + Ok(()) => connection + .execute_batch("COMMIT") + .map_err(|error| format!("commit onboarding update: {error}"))?, + Err(error) => { + let _ = connection.execute_batch("ROLLBACK"); + return Err(error); + } + } + onboarding_receipt(Some(connection), OnboardingOperation::Complete) +} + +fn onboarding_receipt( + connection: Option<&Connection>, + operation: OnboardingOperation, +) -> Result { + let completed = connection + .map(|connection| queries::get_preference(connection, "onboarding_complete")) + .transpose() + .map_err(|error| format!("read onboarding completion: {error}"))? + .flatten() + .is_some_and(|value| value == "true"); + let default_adapter = list_native_settings(connection)? + .settings + .into_iter() + .find(|setting| setting.key == "default_adapter") + .map(|setting| setting.value) + .unwrap_or_else(|| "claude-code".to_string()); + let tools = [ + ( + "codex", + "Codex CLI", + "Runs configured Codex review and fix work", + ), + ( + "claude", + "Claude Code CLI", + "Runs configured Claude review and fix work", + ), + ( + "gh", + "GitHub CLI", + "Supplies optional repository and pull-request access", + ), + ] + .into_iter() + .map(|(id, label, role)| OnboardingToolStatus { + id: id.to_string(), + label: label.to_string(), + available: Path::new(&resolve_cli_path(id)).is_file(), + role: role.to_string(), + authentication: "not_inspected".to_string(), + }) + .collect::>(); + let selected_binary = if default_adapter == "codex" { + "codex" + } else { + "claude" + }; + let mut limitations = vec![ + "Tool readiness checks executable presence only; authentication and credentials are never inspected." + .to_string(), + "Completing onboarding changes only the shared completion flag and default agent adapter." + .to_string(), + ]; + if !tools + .iter() + .any(|tool| tool.id == selected_binary && tool.available) + { + limitations.push(format!( + "The selected {default_adapter} adapter is not currently discoverable; verification remains fail-closed until it is available." + )); + } + Ok(OnboardingReceipt { + schema_version: ONBOARDING_SCHEMA_VERSION.to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + operation, + completed, + completion_source: "shared_tauri_native_preference".to_string(), + default_adapter, + tools, + limitations, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + + #[test] + fn onboarding_reuses_legacy_completion_without_inspecting_credentials() { + let connection = Connection::open_in_memory().expect("database"); + db::schema::run_migrations(&connection).expect("schema"); + queries::set_preference(&connection, "onboarding_complete", "true").expect("completion"); + queries::set_preference(&connection, "github_token", "fixture-secret").expect("secret"); + + let receipt = inspect_onboarding(Some(&connection)).expect("receipt"); + + assert!(receipt.completed); + assert_eq!(receipt.completion_source, "shared_tauri_native_preference"); + assert!(receipt + .tools + .iter() + .all(|tool| tool.authentication == "not_inspected")); + assert!(!serde_json::to_string(&receipt) + .expect("json") + .contains("fixture-secret")); + } + + #[test] + fn onboarding_completion_updates_only_declared_non_secret_preferences() { + let connection = Connection::open_in_memory().expect("database"); + db::schema::run_migrations(&connection).expect("schema"); + + let receipt = complete_onboarding(&connection, "codex").expect("complete"); + + assert!(receipt.completed); + assert_eq!(receipt.operation, OnboardingOperation::Complete); + assert_eq!(receipt.default_adapter, "codex"); + assert_eq!( + queries::get_preference(&connection, "onboarding_complete").expect("completion"), + Some("true".to_string()) + ); + assert_eq!( + queries::get_preference(&connection, "default_adapter").expect("adapter"), + Some("codex".to_string()) + ); + assert!(complete_onboarding(&connection, "unknown").is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/commands/ops_status.rs b/apps/desktop/src-tauri/src/commands/ops_status.rs new file mode 100644 index 00000000..1f542903 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/ops_status.rs @@ -0,0 +1,160 @@ +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use super::observability::{ + agent_observability_from_connection, billing_config_from_connection, + webhook_config_from_connection, TaskTypeStats, +}; + +pub const OPS_STATUS_SCHEMA_VERSION: &str = "codevetter.ops-status/v1"; +pub const OPS_WINDOWS: &[u32] = &[7, 30, 90]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpsStatusReceipt { + pub schema_version: String, + pub generated_at: String, + pub database_available: bool, + pub window_days: u32, + pub billing: OpsBillingStatus, + pub webhook: OpsWebhookStatus, + pub observability: Vec, + pub excluded_sensitive_keys: Vec, + pub limitations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OpsBillingStatus { + pub anthropic_configured: bool, + pub openai_configured: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct OpsWebhookStatus { + pub configured: bool, + pub flavor: String, +} + +pub fn inspect_ops_status( + connection: Option<&Connection>, + window_days: u32, +) -> Result { + if !OPS_WINDOWS.contains(&window_days) { + return Err("Ops window must be one of 7, 30, or 90 days".to_string()); + } + + let (billing, webhook, observability) = if let Some(connection) = connection { + let billing = billing_config_from_connection(connection); + let webhook = webhook_config_from_connection(connection); + let flavor = match webhook.flavor.as_str() { + "slack" | "discord" | "generic" => webhook.flavor, + _ => "unknown".to_string(), + }; + let observability = agent_observability_from_connection(connection, Some(window_days)); + ( + OpsBillingStatus { + anthropic_configured: billing.anthropic_configured, + openai_configured: billing.openai_configured, + }, + OpsWebhookStatus { + configured: webhook.configured, + flavor, + }, + observability.rows, + ) + } else { + ( + OpsBillingStatus { + anthropic_configured: false, + openai_configured: false, + }, + OpsWebhookStatus { + configured: false, + flavor: "slack".to_string(), + }, + Vec::new(), + ) + }; + + Ok(OpsStatusReceipt { + schema_version: OPS_STATUS_SCHEMA_VERSION.to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + database_available: connection.is_some(), + window_days, + billing, + webhook, + observability, + excluded_sensitive_keys: vec![ + "anthropic_admin_key".to_string(), + "openai_admin_key".to_string(), + "notif_webhook_url".to_string(), + ], + limitations: vec![ + "This read-only receipt never returns credentials or webhook URLs.".to_string(), + "It reads stored aggregate evidence only and never contacts a provider or webhook." + .to_string(), + "Credential writes, live billing refresh, and webhook tests remain incumbent authority." + .to_string(), + "Indexed sessions have no explicit failure signal and remain labelled as an aggregate proxy." + .to_string(), + ], + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + use rusqlite::params; + + #[test] + fn ops_status_is_aggregate_only_and_excludes_sensitive_values() { + let directory = tempfile::tempdir().expect("temporary app data"); + let connection = db::init_db(directory.path().to_path_buf()).expect("database"); + connection + .execute( + "INSERT OR REPLACE INTO preferences (key, value) VALUES (?1, ?2)", + params!["anthropic_admin_key", "secret-anthropic-value"], + ) + .expect("admin preference"); + connection + .execute( + "INSERT OR REPLACE INTO preferences (key, value) VALUES (?1, ?2)", + params!["notif_webhook_url", "https://hooks.example.invalid/private"], + ) + .expect("webhook preference"); + connection + .execute( + "INSERT OR REPLACE INTO preferences (key, value) VALUES (?1, ?2)", + params!["notif_webhook_flavor", "discord"], + ) + .expect("webhook flavor"); + + let receipt = inspect_ops_status(Some(&connection), 30).expect("Ops receipt"); + let encoded = serde_json::to_string(&receipt).expect("serialize receipt"); + + assert_eq!(receipt.schema_version, OPS_STATUS_SCHEMA_VERSION); + assert!(receipt.billing.anthropic_configured); + assert!(!receipt.billing.openai_configured); + assert!(receipt.webhook.configured); + assert_eq!(receipt.webhook.flavor, "discord"); + assert!(!encoded.contains("secret-anthropic-value")); + assert!(!encoded.contains("hooks.example.invalid")); + assert_eq!(receipt.excluded_sensitive_keys.len(), 3); + } + + #[test] + fn ops_status_rejects_unbounded_windows_and_unknown_flavors() { + let directory = tempfile::tempdir().expect("temporary app data"); + let connection = db::init_db(directory.path().to_path_buf()).expect("database"); + assert!(inspect_ops_status(Some(&connection), 365).is_err()); + + connection + .execute( + "INSERT OR REPLACE INTO preferences (key, value) VALUES (?1, ?2)", + params!["notif_webhook_flavor", "custom-private-flavor"], + ) + .expect("webhook flavor"); + let receipt = inspect_ops_status(Some(&connection), 7).expect("Ops receipt"); + assert_eq!(receipt.webhook.flavor, "unknown"); + } +}