From bc62c1b7d2133bc823ccc84dda9fc47ba630376c 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 3/4 --- .../src-tauri/src/commands/qa_workspace.rs | 712 +++++++++++ .../src-tauri/src/commands/repo_query.rs | 1094 +++++++++++++++++ .../src-tauri/src/commands/review_intent.rs | 342 ++++++ .../src-tauri/src/commands/rubric_settings.rs | 536 ++++++++ .../src-tauri/src/commands/run_history.rs | 841 +++++++++++++ 5 files changed, 3525 insertions(+) create mode 100644 apps/desktop/src-tauri/src/commands/qa_workspace.rs create mode 100644 apps/desktop/src-tauri/src/commands/repo_query.rs create mode 100644 apps/desktop/src-tauri/src/commands/review_intent.rs create mode 100644 apps/desktop/src-tauri/src/commands/rubric_settings.rs create mode 100644 apps/desktop/src-tauri/src/commands/run_history.rs diff --git a/apps/desktop/src-tauri/src/commands/qa_workspace.rs b/apps/desktop/src-tauri/src/commands/qa_workspace.rs new file mode 100644 index 00000000..715dc0bd --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/qa_workspace.rs @@ -0,0 +1,712 @@ +use crate::db::queries; +use chrono::{DateTime, Utc}; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +pub const QA_WORKSPACE_SCHEMA: &str = "codevetter.qa-workspace/v1"; +const NATIVE_WORKFLOW_PREFIX: &str = "native_testing_qa_workflows_v1"; +const LEGACY_WORKFLOW_PREFIX: &str = "quick_review_qa_workflows"; +const LEGACY_PRESET_PREFIX: &str = "quick_review_qa_preset"; +const MAX_WORKFLOWS: usize = 12; +const MAX_TARGETS: usize = 16; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QaTargetPreset { + pub id: String, + pub name: String, + pub route: String, + pub goal: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct StoredQaWorkflow { + #[serde(default)] + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub base_url: String, + #[serde(default)] + pub loop_id: String, + #[serde(default = "default_runner")] + pub runner_type: String, + #[serde(default)] + pub goal: String, + #[serde(default)] + pub repo_spec_path: String, + #[serde(default = "default_trace_mode")] + pub repo_trace_mode: String, + #[serde(default)] + pub target_route: String, + #[serde(default)] + pub allow_remote_target: bool, + #[serde(default)] + pub targets: Vec, + #[serde(default)] + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QaWorkflow { + pub id: String, + pub name: String, + pub base_url: String, + pub loop_id: String, + pub runner_type: String, + pub goal: String, + pub repo_spec_path: String, + pub repo_trace_mode: String, + pub target_route: String, + pub allow_remote_target: bool, + pub targets: Vec, + pub updated_at: String, + pub editable: bool, + pub limitation: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QaSpecCandidate { + pub path: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QaRerunRun { + pub id: String, + pub created_at: String, + pub runner_type: String, + pub base_url: String, + pub loop_id: String, + pub route: String, + pub goal: String, + pub pass: bool, + pub duration_ms: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QaPostFixPreparation { + pub status: String, + pub summary: String, + pub before: QaRerunRun, + pub after: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QaWorkspaceReceipt { + pub schema_version: String, + pub repo_path: String, + pub preference_key: String, + pub source: String, + pub workflows: Vec, + pub specs: Vec, + pub post_fix: Option, + pub limitations: Vec, +} + +#[derive(Debug, Clone)] +pub enum QaWorkspaceMutation { + Inspect, + SaveWorkflow(StoredQaWorkflow), + DeleteWorkflow { + workflow_id: String, + }, + SaveTarget { + workflow_id: String, + target: QaTargetPreset, + }, + DeleteTarget { + workflow_id: String, + target_id: String, + }, +} + +fn default_runner() -> String { + "playwright_builtin".to_string() +} + +fn default_trace_mode() -> String { + "retain-on-failure".to_string() +} + +fn stable_preference_suffix(value: &str) -> String { + let mut hash: u32 = 2_166_136_261; + for unit in value.encode_utf16() { + hash ^= u32::from(unit); + hash = hash.wrapping_mul(16_777_619); + } + to_base36(hash) +} + +fn to_base36(mut value: u32) -> String { + if value == 0 { + return "0".to_string(); + } + let mut out = Vec::new(); + while value > 0 { + let digit = value % 36; + out.push(if digit < 10 { + (b'0' + digit as u8) as char + } else { + (b'a' + (digit - 10) as u8) as char + }); + value /= 36; + } + out.iter().rev().collect() +} + +fn scoped_key(prefix: &str, repo_path: &str) -> String { + format!( + "{prefix}_repo_{}", + stable_preference_suffix(repo_path.trim()) + ) +} + +fn required_text(value: &str, field: &str, max: usize) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err(format!("{field} is required")); + } + if value.chars().count() > max { + return Err(format!("{field} must be at most {max} characters")); + } + Ok(value.to_string()) +} + +fn normalize_route(value: &str) -> Result { + let value = required_text(value, "route", 240)?; + if !value.starts_with('/') || value.starts_with("//") { + return Err("route must be a repository-relative browser path beginning with /".into()); + } + Ok(value) +} + +fn normalize_spec_path(repo: &Path, value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Ok(String::new()); + } + let relative = Path::new(value); + if relative.is_absolute() + || relative + .components() + .any(|part| matches!(part, std::path::Component::ParentDir)) + { + return Err("repo_spec_path must remain repository-relative".into()); + } + let candidate = repo.join(relative); + if !candidate.is_file() { + return Err(format!("repo_spec_path does not exist: {value}")); + } + Ok(value.replace('\\', "/")) +} + +fn normalize_target(target: QaTargetPreset) -> Result { + Ok(QaTargetPreset { + id: required_text(&target.id, "target id", 100)?, + name: required_text(&target.name, "target name", 100)?, + route: normalize_route(&target.route)?, + goal: required_text(&target.goal, "target goal", 500)?, + }) +} + +fn normalize_workflow(repo: &Path, workflow: StoredQaWorkflow) -> Result { + let runner_type = required_text(&workflow.runner_type, "runner_type", 40)?; + if !matches!( + runner_type.as_str(), + "playwright_builtin" | "repo_playwright" + ) { + return Err("native QA workflows support playwright_builtin or repo_playwright; arbitrary external commands remain legacy-only".into()); + } + let repo_trace_mode = required_text(&workflow.repo_trace_mode, "repo_trace_mode", 40)?; + if !matches!(repo_trace_mode.as_str(), "off" | "retain-on-failure" | "on") { + return Err("repo_trace_mode must be off, retain-on-failure, or on".into()); + } + let mut targets = workflow + .targets + .into_iter() + .map(normalize_target) + .collect::, _>>()?; + targets.truncate(MAX_TARGETS); + let base_url = workflow.base_url.trim().trim_end_matches('/').to_string(); + if !base_url.is_empty() { + let url = reqwest::Url::parse(&base_url) + .map_err(|_| "base_url must be a valid HTTP(S) URL".to_string())?; + if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() { + return Err("base_url must be a valid HTTP(S) URL".into()); + } + if !url.username().is_empty() || url.password().is_some() { + return Err("base_url must not contain embedded credentials".into()); + } + } + Ok(StoredQaWorkflow { + id: required_text(&workflow.id, "workflow id", 100)?, + name: required_text(&workflow.name, "workflow name", 100)?, + base_url, + loop_id: required_text(&workflow.loop_id, "loop_id", 100)?, + runner_type, + goal: required_text(&workflow.goal, "goal", 500)?, + repo_spec_path: normalize_spec_path(repo, &workflow.repo_spec_path)?, + repo_trace_mode, + target_route: normalize_route(&workflow.target_route)?, + allow_remote_target: workflow.allow_remote_target, + targets, + updated_at: Utc::now().to_rfc3339(), + }) +} + +fn project_workflow(workflow: StoredQaWorkflow) -> QaWorkflow { + let editable = matches!( + workflow.runner_type.as_str(), + "playwright_builtin" | "repo_playwright" + ); + let mut limitations = Vec::new(); + if !editable { + limitations.push( + "This legacy workflow uses an arbitrary external command. Native Testing will not expose or execute it." + .to_string(), + ); + } + let base_url = match reqwest::Url::parse(workflow.base_url.trim()) { + Ok(url) + if matches!(url.scheme(), "http" | "https") + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() => + { + workflow.base_url + } + _ if workflow.base_url.trim().is_empty() => String::new(), + _ => { + limitations.push( + "The legacy preview URL was omitted because it was invalid or credential-bearing." + .to_string(), + ); + String::new() + } + }; + QaWorkflow { + id: workflow.id, + name: workflow.name, + base_url, + loop_id: workflow.loop_id, + runner_type: workflow.runner_type, + goal: workflow.goal, + repo_spec_path: workflow.repo_spec_path, + repo_trace_mode: workflow.repo_trace_mode, + target_route: workflow.target_route, + allow_remote_target: workflow.allow_remote_target, + targets: workflow.targets.into_iter().take(MAX_TARGETS).collect(), + updated_at: workflow.updated_at, + editable, + limitation: (!limitations.is_empty()).then(|| limitations.join(" ")), + } +} + +fn parse_workflows(raw: Option) -> Vec { + raw.and_then(|value| serde_json::from_str::>(&value).ok()) + .unwrap_or_default() + .into_iter() + .take(MAX_WORKFLOWS) + .collect() +} + +fn legacy_preset(raw: Option) -> Vec { + raw.and_then(|value| serde_json::from_str::(&value).ok()) + .map(|mut workflow| { + workflow.id = "legacy-preset".into(); + if workflow.name.trim().is_empty() { + workflow.name = "Legacy QA preset".into(); + } + vec![workflow] + }) + .unwrap_or_default() +} + +fn load_workflows( + connection: &Connection, + repo_path: &str, +) -> Result<(String, Vec), String> { + let native_key = scoped_key(NATIVE_WORKFLOW_PREFIX, repo_path); + let native_raw = queries::get_preference(connection, &native_key).map_err(|e| e.to_string())?; + if native_raw.is_some() { + return Ok(("native".into(), parse_workflows(native_raw))); + } + let scoped_legacy_key = scoped_key(LEGACY_WORKFLOW_PREFIX, repo_path); + let scoped_legacy = parse_workflows( + queries::get_preference(connection, &scoped_legacy_key).map_err(|e| e.to_string())?, + ); + if !scoped_legacy.is_empty() { + return Ok(("legacy_projected".into(), scoped_legacy)); + } + let global_legacy = parse_workflows( + queries::get_preference(connection, LEGACY_WORKFLOW_PREFIX).map_err(|e| e.to_string())?, + ); + if !global_legacy.is_empty() { + return Ok(("legacy_global_projected".into(), global_legacy)); + } + let scoped_preset_key = scoped_key(LEGACY_PRESET_PREFIX, repo_path); + let scoped_preset = legacy_preset( + queries::get_preference(connection, &scoped_preset_key).map_err(|e| e.to_string())?, + ); + if !scoped_preset.is_empty() { + return Ok(("legacy_preset_projected".into(), scoped_preset)); + } + let global_preset = legacy_preset( + queries::get_preference(connection, LEGACY_PRESET_PREFIX).map_err(|e| e.to_string())?, + ); + Ok(( + if global_preset.is_empty() { + "empty" + } else { + "legacy_global_preset_projected" + } + .into(), + global_preset, + )) +} + +fn save_workflows( + connection: &Connection, + repo_path: &str, + workflows: &[StoredQaWorkflow], +) -> Result<(), String> { + let value = + serde_json::to_string(workflows).map_err(|e| format!("serialize QA workflows: {e}"))?; + queries::set_preference( + connection, + &scoped_key(NATIVE_WORKFLOW_PREFIX, repo_path), + &value, + ) + .map_err(|e| e.to_string()) +} + +fn flow_key(run: &queries::SyntheticQaRunRow) -> String { + format!( + "{}\0{}\0{}\0{}\0{}", + run.runner_type, + run.base_url.as_deref().unwrap_or_default(), + run.loop_id, + run.route.as_deref().unwrap_or_default(), + run.goal.as_deref().unwrap_or_default() + ) +} + +fn rerun_projection(run: &queries::SyntheticQaRunRow) -> QaRerunRun { + QaRerunRun { + id: run.id.clone(), + created_at: run.created_at.clone(), + runner_type: run.runner_type.clone(), + base_url: run.base_url.clone().unwrap_or_default(), + loop_id: run.loop_id.clone(), + route: run.route.clone().unwrap_or_else(|| "/".into()), + goal: run.goal.clone().unwrap_or_else(|| run.loop_id.clone()), + pass: run.pass, + duration_ms: run.duration_ms, + } +} + +fn post_fix_preparation( + connection: &Connection, + repo_path: &str, + fix_completed_at: Option<&str>, +) -> Result, String> { + let Some(fix_completed_at) = fix_completed_at else { + return Ok(None); + }; + let fix_time = DateTime::parse_from_rfc3339(fix_completed_at) + .map_err(|_| "fix_completed_at must be an RFC3339 timestamp".to_string())? + .with_timezone(&Utc); + let runs = queries::list_synthetic_qa_runs_for_repo(connection, repo_path, 50) + .map_err(|e| e.to_string())?; + let before = runs.iter().find(|run| { + DateTime::parse_from_rfc3339(&run.created_at) + .map(|time| time.with_timezone(&Utc) <= fix_time) + .unwrap_or(false) + }); + let Some(before) = before else { + return Ok(None); + }; + let key = flow_key(before); + let after = runs.iter().find(|run| { + DateTime::parse_from_rfc3339(&run.created_at) + .map(|time| time.with_timezone(&Utc) > fix_time) + .unwrap_or(false) + && flow_key(run) == key + }); + let (status, summary) = match after { + None => ( + "needs_rerun", + format!( + "Fix is ready for QA comparison: rerun {} with the same {} flow.", + before.route.as_deref().unwrap_or(&before.loop_id), + before.runner_type + ), + ), + Some(after) if !before.pass && after.pass => ( + "fixed", + "Post-fix QA passed; the prior matching flow failed and the rerun passed.".into(), + ), + Some(after) if !before.pass && !after.pass => ( + "still_broken", + "Post-fix QA still fails; both matching runs failed.".into(), + ), + Some(after) if before.pass && !after.pass => ( + "regressed", + "Post-fix QA regressed; the prior matching flow passed and the rerun failed.".into(), + ), + Some(_) => ( + "still_passing", + "Post-fix QA still passes for the matching flow.".into(), + ), + }; + Ok(Some(QaPostFixPreparation { + status: status.into(), + summary, + before: rerun_projection(before), + after: after.map(rerun_projection), + })) +} + +pub fn run_qa_workspace_headless( + connection: &Connection, + repo_path: PathBuf, + mutation: QaWorkspaceMutation, + fix_completed_at: Option<&str>, +) -> Result { + let repo_path = std::fs::canonicalize(&repo_path) + .map_err(|e| format!("repository {} is unavailable: {e}", repo_path.display()))?; + if !repo_path.is_dir() { + return Err("repo must be an existing directory".into()); + } + let repo = repo_path.to_string_lossy().into_owned(); + let (mut source, mut workflows) = load_workflows(connection, &repo)?; + match mutation { + QaWorkspaceMutation::Inspect => {} + QaWorkspaceMutation::SaveWorkflow(workflow) => { + let mut workflow = normalize_workflow(&repo_path, workflow)?; + if workflow.targets.is_empty() { + if let Some(existing) = workflows + .iter() + .find(|candidate| candidate.id == workflow.id) + { + workflow.targets = existing.targets.clone(); + } + } + workflows.retain(|candidate| candidate.id != workflow.id); + workflows.insert(0, workflow); + workflows.truncate(MAX_WORKFLOWS); + save_workflows(connection, &repo, &workflows)?; + source = "native".into(); + } + QaWorkspaceMutation::DeleteWorkflow { workflow_id } => { + let workflow_id = required_text(&workflow_id, "workflow id", 100)?; + workflows.retain(|candidate| candidate.id != workflow_id); + save_workflows(connection, &repo, &workflows)?; + source = "native".into(); + } + QaWorkspaceMutation::SaveTarget { + workflow_id, + target, + } => { + let workflow_id = required_text(&workflow_id, "workflow id", 100)?; + let target = normalize_target(target)?; + let workflow = workflows + .iter_mut() + .find(|candidate| candidate.id == workflow_id) + .ok_or_else(|| format!("workflow not found: {workflow_id}"))?; + if !matches!( + workflow.runner_type.as_str(), + "playwright_builtin" | "repo_playwright" + ) { + return Err( + "legacy external-command workflows are read-only in native Testing".into(), + ); + } + workflow + .targets + .retain(|candidate| candidate.id != target.id); + workflow.targets.insert(0, target); + workflow.targets.truncate(MAX_TARGETS); + workflow.updated_at = Utc::now().to_rfc3339(); + save_workflows(connection, &repo, &workflows)?; + source = "native".into(); + } + QaWorkspaceMutation::DeleteTarget { + workflow_id, + target_id, + } => { + let workflow_id = required_text(&workflow_id, "workflow id", 100)?; + let target_id = required_text(&target_id, "target id", 100)?; + let workflow = workflows + .iter_mut() + .find(|candidate| candidate.id == workflow_id) + .ok_or_else(|| format!("workflow not found: {workflow_id}"))?; + workflow + .targets + .retain(|candidate| candidate.id != target_id); + workflow.updated_at = Utc::now().to_rfc3339(); + save_workflows(connection, &repo, &workflows)?; + source = "native".into(); + } + } + + let specs = super::synthetic_qa::discover_playwright_specs_headless(&repo_path) + .into_iter() + .map(|candidate| QaSpecCandidate { + path: candidate.path, + reason: candidate.reason, + }) + .collect(); + let projected = workflows + .into_iter() + .map(project_workflow) + .collect::>(); + let mut limitations = vec![ + "Credential-bearing storage-state paths are never projected into this receipt.".into(), + "Arbitrary external-command workflows remain legacy-only and cannot execute from native Testing.".into(), + "Preparing a post-fix flow does not grant preview network consent or start browser execution.".into(), + ]; + if projected.iter().any(|workflow| !workflow.editable) { + limitations.push("At least one projected legacy workflow is read-only.".into()); + } + Ok(QaWorkspaceReceipt { + schema_version: QA_WORKSPACE_SCHEMA.into(), + repo_path: repo.clone(), + preference_key: scoped_key(NATIVE_WORKFLOW_PREFIX, &repo), + source, + workflows: projected, + specs, + post_fix: post_fix_preparation(connection, &repo, fix_completed_at)?, + limitations, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn workflow(repo_spec_path: &str) -> StoredQaWorkflow { + StoredQaWorkflow { + id: "checkout".into(), + name: "Checkout".into(), + base_url: "http://localhost:1420/".into(), + loop_id: "checkout".into(), + runner_type: "repo_playwright".into(), + goal: "Complete checkout".into(), + repo_spec_path: repo_spec_path.into(), + repo_trace_mode: "retain-on-failure".into(), + target_route: "/checkout".into(), + allow_remote_target: false, + targets: vec![QaTargetPreset { + id: "primary".into(), + name: "Primary checkout".into(), + route: "/checkout".into(), + goal: "Complete checkout".into(), + }], + updated_at: String::new(), + } + } + + #[test] + fn scoped_key_matches_the_frontend_fnv_contract() { + assert_eq!( + scoped_key("quick_review_qa_workflows", "/fixture/repo"), + "quick_review_qa_workflows_repo_sfx8og" + ); + } + + #[test] + fn saves_safe_workflow_without_touching_legacy_preference() { + let repo = tempfile::tempdir().expect("repo"); + std::fs::create_dir_all(repo.path().join("tests")).expect("tests"); + std::fs::write( + repo.path().join("tests/checkout.spec.ts"), + "import { test } from '@playwright/test';", + ) + .expect("spec"); + let connection = Connection::open_in_memory().expect("db"); + connection + .execute_batch("CREATE TABLE preferences (key TEXT PRIMARY KEY, value TEXT NOT NULL);") + .expect("schema"); + queries::set_preference(&connection, LEGACY_WORKFLOW_PREFIX, "legacy-value") + .expect("legacy"); + + let receipt = run_qa_workspace_headless( + &connection, + repo.path().to_path_buf(), + QaWorkspaceMutation::SaveWorkflow(workflow("tests/checkout.spec.ts")), + None, + ) + .expect("receipt"); + + assert_eq!(receipt.schema_version, QA_WORKSPACE_SCHEMA); + assert_eq!(receipt.source, "native"); + assert_eq!(receipt.workflows.len(), 1); + assert_eq!(receipt.specs[0].path, "tests/checkout.spec.ts"); + assert_eq!( + queries::get_preference(&connection, LEGACY_WORKFLOW_PREFIX).expect("legacy read"), + Some("legacy-value".into()) + ); + let persisted = queries::get_preference(&connection, &receipt.preference_key) + .expect("native read") + .expect("native value"); + assert!(!persisted.contains("storageStatePath")); + assert!(!persisted.contains("externalCommand")); + } + + #[test] + fn rejects_external_command_runner_and_parent_spec_paths() { + let repo = tempfile::tempdir().expect("repo"); + let mut candidate = workflow("../secret.json"); + candidate.runner_type = "external_skill".into(); + let error = normalize_workflow(repo.path(), candidate).expect_err("external rejected"); + assert!(error.contains("external commands")); + + let error = normalize_workflow(repo.path(), workflow("../secret.json")) + .expect_err("parent path rejected"); + assert!(error.contains("repository-relative")); + } + + #[test] + fn native_empty_state_does_not_resurface_legacy_and_legacy_urls_are_scrubbed() { + let repo = tempfile::tempdir().expect("repo"); + let connection = Connection::open_in_memory().expect("db"); + connection + .execute_batch("CREATE TABLE preferences (key TEXT PRIMARY KEY, value TEXT NOT NULL);") + .expect("schema"); + let repo_path = std::fs::canonicalize(repo.path()).expect("canonical repo"); + let repo_text = repo_path.to_string_lossy(); + queries::set_preference( + &connection, + LEGACY_WORKFLOW_PREFIX, + r#"[{"id":"legacy","name":"Legacy","baseUrl":"https://user:password@example.test","loopId":"legacy","runnerType":"playwright_builtin","goal":"Smoke","targetRoute":"/"}]"#, + ) + .expect("legacy"); + let projected = run_qa_workspace_headless( + &connection, + repo_path.clone(), + QaWorkspaceMutation::Inspect, + None, + ) + .expect("projected"); + assert_eq!(projected.workflows[0].base_url, ""); + assert!(projected.workflows[0] + .limitation + .as_deref() + .is_some_and(|value| value.contains("credential-bearing"))); + + queries::set_preference( + &connection, + &scoped_key(NATIVE_WORKFLOW_PREFIX, &repo_text), + "[]", + ) + .expect("native empty"); + let empty = + run_qa_workspace_headless(&connection, repo_path, QaWorkspaceMutation::Inspect, None) + .expect("native empty receipt"); + assert_eq!(empty.source, "native"); + assert!(empty.workflows.is_empty()); + } +} diff --git a/apps/desktop/src-tauri/src/commands/repo_query.rs b/apps/desktop/src-tauri/src/commands/repo_query.rs new file mode 100644 index 00000000..ef5a336b --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/repo_query.rs @@ -0,0 +1,1094 @@ +//! Bounded, read-only repository query projection shared by the native viewer and CLI. +//! +//! Query semantics stay in the canonical structural graph and history services. This +//! module only validates the native/CLI boundary and packages freshness alongside results. + +use crate::commands::{ + history_graph::HistoryGraphStatus, + history_query::{HistoryCausalSelector, HistoryCausalTrace}, + history_read::{HistoryReadService, HistoryUnifiedSearch}, + structural_graph::{ + query::{ + self, GraphDirection, GraphExplanation, GraphImpactResult, GraphPathResult, + GraphQueryFilter, GraphSearchResult, + }, + service::{StructuralGraphReadService, StructuralGraphReadStatus}, + types::StructuralGraphSnapshot, + }, +}; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + io::{BufRead, Write}, + path::Path, + sync::Arc, +}; + +pub const REPO_QUERY_SCHEMA: &str = "codevetter.repo-query/v2"; +pub const REPO_QUERY_PREPARATION_SCHEMA: &str = "codevetter.repo-query-preparation/v1"; +pub const REPO_QUERY_WORKER_REQUEST_SCHEMA: &str = "codevetter.repo-query-worker-request/v2"; +pub const REPO_QUERY_WORKER_RESPONSE_SCHEMA: &str = "codevetter.repo-query-worker-response/v1"; +const MAX_QUERY_BYTES: usize = 4_096; +const MAX_QUERY_RESULTS: usize = 100; +const MAX_WORKER_LINE_BYTES: usize = 16 * 1024; +const MAX_REQUEST_ID_BYTES: usize = 128; +const MAX_WORKER_GRAPH_SNAPSHOTS: usize = 1; + +#[derive(Default)] +struct RepositoryQueryWorkerCache { + graph_snapshots: HashMap, +} + +struct CachedGraphSnapshot { + snapshot: Arc, + traversal_ready: bool, +} + +impl RepositoryQueryWorkerCache { + fn graph_snapshot( + &mut self, + graph: &StructuralGraphReadService<'_>, + status: &StructuralGraphReadStatus, + traversal_required: bool, + ) -> Result, String> { + let snapshot_id = status.snapshot_id.as_deref().ok_or_else(|| { + "Canonical structural graph snapshot identity is unavailable".to_string() + })?; + if let Some(cached) = self.graph_snapshots.get_mut(snapshot_id) { + if traversal_required && !cached.traversal_ready { + let edges = graph.traversal_edges_by_snapshot_id(snapshot_id)?; + Arc::get_mut(&mut cached.snapshot) + .ok_or_else(|| "Canonical graph cache is unexpectedly shared".to_string())? + .edges = edges; + cached.traversal_ready = true; + } + return Ok(Arc::clone(&cached.snapshot)); + } + let mut snapshot = graph.search_snapshot_by_id(snapshot_id)?; + if snapshot.id != snapshot_id { + return Err( + "Canonical structural graph changed while the query was prepared".to_string(), + ); + } + if self.graph_snapshots.len() >= MAX_WORKER_GRAPH_SNAPSHOTS { + self.graph_snapshots.clear(); + } + if traversal_required { + snapshot.edges = graph.traversal_edges_by_snapshot_id(snapshot_id)?; + } + let snapshot = Arc::new(snapshot); + self.graph_snapshots.insert( + snapshot.id.clone(), + CachedGraphSnapshot { + snapshot: Arc::clone(&snapshot), + traversal_ready: traversal_required, + }, + ); + Ok(snapshot) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RepositoryQueryDomain { + Graph, + History, +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RepositoryQueryMode { + #[default] + Search, + Explain, + Impact, + Path, + Trace, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RepositoryHistorySelectorKind { + Event, + Entity, + Revision, + Release, + Episode, +} + +#[derive(Debug, Clone)] +pub struct RepositoryQueryInput { + pub domain: RepositoryQueryDomain, + pub mode: RepositoryQueryMode, + pub query: String, + pub target: Option, + pub direction: Option, + pub depth: Option, + pub history_selector: Option, + pub limit: usize, +} + +impl RepositoryQueryInput { + pub fn search(domain: RepositoryQueryDomain, query: impl Into, limit: usize) -> Self { + Self { + domain, + mode: RepositoryQueryMode::Search, + query: query.into(), + target: None, + direction: None, + depth: None, + history_selector: None, + limit, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepositoryQueryReceipt { + pub schema_version: &'static str, + pub authority: &'static str, + pub repo_path: String, + pub query: String, + pub domain: RepositoryQueryDomain, + pub mode: RepositoryQueryMode, + pub target: Option, + pub direction: Option, + pub depth: Option, + pub history_selector: Option, + pub limit: usize, + pub status: &'static str, + pub issue: Option, + pub graph_status: StructuralGraphReadStatus, + pub history_status: HistoryGraphStatus, + pub graph_result: Option, + pub graph_explanation: Option, + pub graph_impact: Option, + pub graph_path: Option, + pub history_result: Option, + pub history_trace: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RepositoryQueryWorkerOperation { + Prepare, + Query, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepositoryQueryWorkerRequest { + pub schema_version: String, + pub request_id: String, + pub operation: RepositoryQueryWorkerOperation, + pub repo_path: String, + pub domain: RepositoryQueryDomain, + #[serde(default)] + pub mode: RepositoryQueryMode, + #[serde(default)] + pub query: Option, + #[serde(default)] + pub target: Option, + #[serde(default)] + pub direction: Option, + #[serde(default)] + pub depth: Option, + #[serde(default)] + pub history_selector: Option, + #[serde(default)] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RepositoryQueryPreparation { + pub schema_version: &'static str, + pub authority: &'static str, + pub repo_path: String, + pub domain: RepositoryQueryDomain, + pub status: &'static str, + pub issue: Option, + pub graph_status: StructuralGraphReadStatus, + pub history_status: HistoryGraphStatus, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RepositoryQueryWorkerResponse { + pub schema_version: &'static str, + pub request_id: String, + pub status: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub receipt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preparation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +pub fn query_repository_evidence( + connection: &Connection, + repo_path: &Path, + domain: RepositoryQueryDomain, + query_text: &str, + limit: usize, +) -> Result { + query_repository_evidence_with_input( + connection, + repo_path, + RepositoryQueryInput::search(domain, query_text, limit), + ) +} + +pub fn query_repository_evidence_with_input( + connection: &Connection, + repo_path: &Path, + input: RepositoryQueryInput, +) -> Result { + query_repository_evidence_internal(connection, None, repo_path, input) +} + +pub fn prepare_repository_query( + connection: &Connection, + repo_path: &Path, + domain: RepositoryQueryDomain, +) -> Result { + prepare_repository_query_with_cache(connection, repo_path, domain, None) +} + +fn prepare_repository_query_with_cache( + connection: &Connection, + repo_path: &Path, + domain: RepositoryQueryDomain, + cache: Option<&mut RepositoryQueryWorkerCache>, +) -> Result { + let canonical = canonical_repository(repo_path)?; + let repo_path = canonical.to_string_lossy().into_owned(); + let graph = StructuralGraphReadService::new(connection, repo_path.clone()); + let history = HistoryReadService::new(connection, &repo_path)?; + let graph_status = graph.status()?; + let history_status = history.status()?; + let (status, issue) = match domain { + RepositoryQueryDomain::Graph if graph_status.indexed => { + if let Some(cache) = cache { + let snapshot = cache.graph_snapshot(&graph, &graph_status, false)?; + query::prepare_search_index(&snapshot); + } else { + graph.prepare_search_index()?; + } + ("ready", None) + } + RepositoryQueryDomain::Graph => ( + "unavailable", + Some( + "The canonical structural graph has not been indexed for this repository." + .to_string(), + ), + ), + RepositoryQueryDomain::History if history_status.indexed => ("ready", None), + RepositoryQueryDomain::History => ( + "unavailable", + Some( + "Temporal history has not been indexed for this repository; no query was run." + .to_string(), + ), + ), + }; + Ok(RepositoryQueryPreparation { + schema_version: REPO_QUERY_PREPARATION_SCHEMA, + authority: "read_only_projection", + repo_path, + domain, + status, + issue, + graph_status, + history_status, + }) +} + +/// Serve bounded read-only repository requests until stdin closes. +/// +/// One SQLite connection and the canonical process-local graph index cache are +/// retained for the worker lifetime. Every request receives exactly one line +/// of JSON, including malformed requests, so callers cannot lose framing. +pub fn run_repository_query_worker( + connection: &Connection, + reader: impl BufRead, + mut writer: impl Write, +) -> Result<(), String> { + let mut cache = RepositoryQueryWorkerCache::default(); + for line in reader.lines() { + let line = + line.map_err(|error| format!("read repository query worker request: {error}"))?; + let response = if line.len() > MAX_WORKER_LINE_BYTES { + RepositoryQueryWorkerResponse::error( + "invalid", + format!( + "Repository query worker requests must not exceed {MAX_WORKER_LINE_BYTES} bytes" + ), + ) + } else { + handle_worker_line(connection, &mut cache, &line) + }; + serde_json::to_writer(&mut writer, &response) + .map_err(|error| format!("serialize repository query worker response: {error}"))?; + writer + .write_all(b"\n") + .map_err(|error| format!("write repository query worker response: {error}"))?; + writer + .flush() + .map_err(|error| format!("flush repository query worker response: {error}"))?; + } + Ok(()) +} + +fn handle_worker_line( + connection: &Connection, + cache: &mut RepositoryQueryWorkerCache, + line: &str, +) -> RepositoryQueryWorkerResponse { + let request = match serde_json::from_str::(line) { + Ok(request) => request, + Err(error) => { + return RepositoryQueryWorkerResponse::error( + "invalid", + format!("Decode repository query worker request: {error}"), + ) + } + }; + if let Err(error) = validate_worker_request(&request) { + return RepositoryQueryWorkerResponse::error(&request.request_id, error); + } + let outcome = match request.operation { + RepositoryQueryWorkerOperation::Prepare => prepare_repository_query_with_cache( + connection, + Path::new(&request.repo_path), + request.domain, + Some(cache), + ) + .map(|preparation| (None, Some(preparation))), + RepositoryQueryWorkerOperation::Query => query_repository_evidence_cached( + connection, + cache, + Path::new(&request.repo_path), + RepositoryQueryInput { + domain: request.domain, + mode: request.mode, + query: request.query.unwrap_or_default(), + target: request.target, + direction: request.direction, + depth: request.depth, + history_selector: request.history_selector, + limit: request.limit.unwrap_or(40), + }, + ) + .map(|receipt| (Some(receipt), None)), + }; + match outcome { + Ok((receipt, preparation)) => RepositoryQueryWorkerResponse { + schema_version: REPO_QUERY_WORKER_RESPONSE_SCHEMA, + request_id: request.request_id, + status: "ok", + receipt, + preparation, + error: None, + }, + Err(error) => RepositoryQueryWorkerResponse::error(&request.request_id, error), + } +} + +fn query_repository_evidence_cached( + connection: &Connection, + cache: &mut RepositoryQueryWorkerCache, + repo_path: &Path, + input: RepositoryQueryInput, +) -> Result { + query_repository_evidence_internal(connection, Some(cache), repo_path, input) +} + +fn query_repository_evidence_internal( + connection: &Connection, + cache: Option<&mut RepositoryQueryWorkerCache>, + repo_path: &Path, + mut input: RepositoryQueryInput, +) -> Result { + normalize_and_validate_input(&mut input)?; + let canonical = canonical_repository(repo_path)?; + let repo_path = canonical.to_string_lossy().into_owned(); + let graph = StructuralGraphReadService::new(connection, repo_path.clone()); + let history = HistoryReadService::new(connection, &repo_path)?; + let graph_status = graph.status()?; + let history_status = history.status()?; + + let mut graph_result = None; + let mut graph_explanation = None; + let mut graph_impact = None; + let mut graph_path = None; + let mut history_result = None; + let mut history_trace = None; + let (status, issue) = match input.domain { + RepositoryQueryDomain::Graph if !graph_status.indexed => ( + "unavailable", + Some( + "The canonical structural graph has not been indexed for this repository." + .to_string(), + ), + ), + RepositoryQueryDomain::Graph => { + let snapshot = match cache { + Some(cache) => cache.graph_snapshot( + &graph, + &graph_status, + input.mode != RepositoryQueryMode::Search, + )?, + None => { + let snapshot_id = graph_status.snapshot_id.as_deref().ok_or_else(|| { + "Canonical structural graph snapshot identity is unavailable".to_string() + })?; + let mut snapshot = graph.search_snapshot_by_id(snapshot_id)?; + if input.mode != RepositoryQueryMode::Search { + snapshot.edges = graph.traversal_edges_by_snapshot_id(snapshot_id)?; + } + Arc::new(snapshot) + } + }; + let current_head = graph.current_head(); + match input.mode { + RepositoryQueryMode::Search => { + let mut result = query::search( + &snapshot, + &input.query, + &GraphQueryFilter::default(), + Some(input.limit), + ); + result.context.observe_current_head(current_head); + graph_result = Some(result); + } + RepositoryQueryMode::Explain => { + let mut result = query::explain(&snapshot, &input.query)?; + result.context.observe_current_head(current_head); + graph_explanation = Some(result); + } + RepositoryQueryMode::Impact => { + let mut result = query::impact( + &snapshot, + &input.query, + input.direction.clone().unwrap_or(GraphDirection::Outgoing), + input.depth, + &GraphQueryFilter::default(), + Some(input.limit), + )?; + result.context.observe_current_head(current_head); + hydrate_result_edges(&graph, &result.context.snapshot_id, &mut result.edges)?; + graph_impact = Some(result); + } + RepositoryQueryMode::Path => { + let mut result = query::shortest_path( + &snapshot, + &input.query, + input.target.as_deref().unwrap_or_default(), + &GraphQueryFilter::default(), + )?; + result.context.observe_current_head(current_head); + hydrate_result_edges(&graph, &result.context.snapshot_id, &mut result.edges)?; + graph_path = Some(result); + } + RepositoryQueryMode::Trace => unreachable!("validated graph query mode"), + } + ("ready", None) + } + RepositoryQueryDomain::History if !history_status.indexed => ( + "unavailable", + Some( + "Temporal history has not been indexed for this repository; no query was run." + .to_string(), + ), + ), + RepositoryQueryDomain::History => { + match input.mode { + RepositoryQueryMode::Search => { + history_result = Some(history.search(&input.query, input.limit, 0)?); + } + RepositoryQueryMode::Trace => { + history_trace = Some(history.trace( + history_selector( + input.history_selector.expect("validated history selector"), + &input.query, + ), + input.limit, + None, + )?); + } + _ => unreachable!("validated history query mode"), + } + ("ready", None) + } + }; + + Ok(RepositoryQueryReceipt { + schema_version: REPO_QUERY_SCHEMA, + authority: "read_only_projection", + repo_path, + query: input.query, + domain: input.domain, + mode: input.mode, + target: input.target, + direction: input.direction, + depth: input.depth, + history_selector: input.history_selector, + limit: input.limit, + status, + issue, + graph_status, + history_status, + graph_result, + graph_explanation, + graph_impact, + graph_path, + history_result, + history_trace, + }) +} + +fn validate_worker_request(request: &RepositoryQueryWorkerRequest) -> Result<(), String> { + if request.schema_version != REPO_QUERY_WORKER_REQUEST_SCHEMA { + return Err(format!( + "Unsupported repository query worker request schema {}", + request.schema_version + )); + } + if request.request_id.is_empty() + || request.request_id.len() > MAX_REQUEST_ID_BYTES + || request.request_id.chars().any(char::is_control) + { + return Err("Repository query worker request ids must be one bounded line".to_string()); + } + if request.repo_path.is_empty() + || request.repo_path.len() > MAX_QUERY_BYTES + || request.repo_path.chars().any(char::is_control) + { + return Err("Repository query worker paths must be one bounded line".to_string()); + } + match request.operation { + RepositoryQueryWorkerOperation::Prepare + if request.mode != RepositoryQueryMode::Search + || request.query.is_some() + || request.target.is_some() + || request.direction.is_some() + || request.depth.is_some() + || request.history_selector.is_some() + || request.limit.is_some() => + { + Err("Repository query prepare does not accept query operation fields".to_string()) + } + RepositoryQueryWorkerOperation::Query if request.query.is_none() => { + Err("Repository query requests require a query field".to_string()) + } + RepositoryQueryWorkerOperation::Query => { + let mut input = RepositoryQueryInput { + domain: request.domain, + mode: request.mode, + query: request.query.clone().unwrap_or_default(), + target: request.target.clone(), + direction: request.direction.clone(), + depth: request.depth, + history_selector: request.history_selector, + limit: request.limit.unwrap_or(40), + }; + normalize_and_validate_input(&mut input) + } + RepositoryQueryWorkerOperation::Prepare => Ok(()), + } +} + +impl RepositoryQueryWorkerResponse { + fn error(request_id: &str, error: String) -> Self { + Self { + schema_version: REPO_QUERY_WORKER_RESPONSE_SCHEMA, + request_id: request_id.to_string(), + status: "error", + receipt: None, + preparation: None, + error: Some(error), + } + } +} + +fn canonical_repository(repo_path: &Path) -> Result { + let canonical = std::fs::canonicalize(repo_path) + .map_err(|error| format!("repository {} is unavailable: {error}", repo_path.display()))?; + if !canonical.is_dir() { + return Err(format!( + "repository {} is not a directory", + canonical.display() + )); + } + Ok(canonical) +} + +fn validate_query(query: &str) -> Result { + let query = query.trim(); + if query.is_empty() { + return Err("A non-empty repository query is required".to_string()); + } + if query.len() > MAX_QUERY_BYTES || query.chars().any(char::is_control) { + return Err(format!( + "Repository queries must be one bounded line of at most {MAX_QUERY_BYTES} bytes" + )); + } + Ok(query.to_string()) +} + +fn validate_limit(limit: usize) -> Result { + if !(1..=MAX_QUERY_RESULTS).contains(&limit) { + return Err(format!( + "Repository query limit must be between 1 and {MAX_QUERY_RESULTS}" + )); + } + Ok(limit) +} + +fn normalize_and_validate_input(input: &mut RepositoryQueryInput) -> Result<(), String> { + input.query = validate_query(&input.query)?; + input.limit = validate_limit(input.limit)?; + if let Some(target) = input.target.as_mut() { + *target = validate_query(target)?; + } + if let Some(depth) = input.depth { + if !(1..=12).contains(&depth) { + return Err("Repository impact depth must be between 1 and 12".to_string()); + } + } + match (input.domain, input.mode) { + (RepositoryQueryDomain::Graph, RepositoryQueryMode::Search) + | (RepositoryQueryDomain::Graph, RepositoryQueryMode::Explain) + if input.target.is_none() + && input.direction.is_none() + && input.depth.is_none() + && input.history_selector.is_none() => + { + Ok(()) + } + (RepositoryQueryDomain::Graph, RepositoryQueryMode::Impact) + if input.target.is_none() && input.history_selector.is_none() => + { + input.direction.get_or_insert(GraphDirection::Outgoing); + input.depth.get_or_insert(3); + Ok(()) + } + (RepositoryQueryDomain::Graph, RepositoryQueryMode::Path) + if input.target.is_some() + && input.direction.is_none() + && input.depth.is_none() + && input.history_selector.is_none() => + { + Ok(()) + } + (RepositoryQueryDomain::History, RepositoryQueryMode::Search) + if input.target.is_none() + && input.direction.is_none() + && input.depth.is_none() + && input.history_selector.is_none() => + { + Ok(()) + } + (RepositoryQueryDomain::History, RepositoryQueryMode::Trace) + if input.target.is_none() + && input.direction.is_none() + && input.depth.is_none() + && input.history_selector.is_some() => + { + Ok(()) + } + (RepositoryQueryDomain::Graph, RepositoryQueryMode::Trace) => { + Err("Graph queries do not support causal trace mode".to_string()) + } + (RepositoryQueryDomain::History, _) => { + Err("History queries support only search and causal trace modes".to_string()) + } + _ => Err("Repository query fields are inconsistent with the selected mode".to_string()), + } +} + +fn history_selector(kind: RepositoryHistorySelectorKind, value: &str) -> HistoryCausalSelector { + match kind { + RepositoryHistorySelectorKind::Event => HistoryCausalSelector::Event { + event_id: value.to_string(), + }, + RepositoryHistorySelectorKind::Entity => HistoryCausalSelector::Entity { + entity_id: value.to_string(), + }, + RepositoryHistorySelectorKind::Revision => HistoryCausalSelector::Revision { + revision: value.to_string(), + }, + RepositoryHistorySelectorKind::Release => HistoryCausalSelector::Release { + tag: value.to_string(), + }, + RepositoryHistorySelectorKind::Episode => HistoryCausalSelector::EpisodeKey { + key: value.to_string(), + }, + } +} + +fn hydrate_result_edges( + graph: &StructuralGraphReadService<'_>, + snapshot_id: &str, + edges: &mut [crate::commands::structural_graph::types::StructuralGraphEdge], +) -> Result<(), String> { + let ids = edges.iter().map(|edge| edge.id.clone()).collect::>(); + let mut hydrated = graph + .edges_by_ids(snapshot_id, &ids)? + .into_iter() + .map(|edge| (edge.id.clone(), edge)) + .collect::>(); + for edge in edges { + *edge = hydrated + .remove(&edge.id) + .ok_or_else(|| "A canonical traversal edge could not be hydrated".to_string())?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::structural_graph::{ + storage::persist_snapshot, + types::{ + GraphOrigin, GraphTrust, StructuralGraphCoverage, StructuralGraphEdge, + StructuralGraphEngineInfo, StructuralGraphNode, StructuralGraphSnapshot, + STRUCTURAL_GRAPH_SCHEMA_VERSION, + }, + }; + use std::{fs, io::Cursor, process::Command}; + + #[test] + fn query_boundary_rejects_empty_multiline_and_unbounded_inputs() { + assert!(validate_query(" ").is_err()); + assert!(validate_query("one\ntwo").is_err()); + assert!(validate_query(&"x".repeat(MAX_QUERY_BYTES + 1)).is_err()); + assert!(validate_limit(0).is_err()); + assert!(validate_limit(MAX_QUERY_RESULTS + 1).is_err()); + assert_eq!( + validate_query(" review pipeline ").unwrap(), + "review pipeline" + ); + assert_eq!(validate_limit(25).unwrap(), 25); + } + + #[test] + fn history_query_fails_closed_when_temporal_coverage_is_not_indexed() { + let root = + std::env::temp_dir().join(format!("codevetter-repo-query-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).expect("fixture root"); + for arguments in [ + vec!["init", "-q"], + vec!["add", "README.md"], + vec![ + "-c", + "user.name=CodeVetter", + "-c", + "user.email=codevetter@example.invalid", + "commit", + "-qm", + "Fix verification regression", + ], + ] { + if arguments[0] == "add" { + fs::write(root.join("README.md"), "fixture").expect("fixture source"); + } + assert!(Command::new("git") + .args(arguments) + .current_dir(&root) + .status() + .expect("git command") + .success()); + } + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + + let receipt = query_repository_evidence( + &connection, + &root, + RepositoryQueryDomain::History, + "regression", + 10, + ) + .expect("history query"); + + assert_eq!(receipt.schema_version, REPO_QUERY_SCHEMA); + assert_eq!(receipt.authority, "read_only_projection"); + assert_eq!(receipt.status, "unavailable"); + assert!(!receipt.graph_status.indexed); + assert!(!receipt.history_status.indexed); + assert!(receipt.issue.is_some()); + assert!(receipt.history_result.is_none()); + fs::remove_dir_all(root).expect("fixture cleanup"); + } + + #[test] + fn worker_keeps_framing_and_fails_closed_for_unindexed_evidence() { + let root = + std::env::temp_dir().join(format!("codevetter-repo-worker-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).expect("fixture root"); + assert!(Command::new("git") + .args(["init", "-q"]) + .current_dir(&root) + .status() + .expect("git init") + .success()); + fs::write(root.join("README.md"), "fixture").expect("fixture source"); + assert!(Command::new("git") + .args(["add", "README.md"]) + .current_dir(&root) + .status() + .expect("git add") + .success()); + assert!(Command::new("git") + .args([ + "-c", + "user.name=CodeVetter", + "-c", + "user.email=codevetter@example.invalid", + "commit", + "-qm", + "Seed worker fixture", + ]) + .current_dir(&root) + .status() + .expect("git commit") + .success()); + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + let repo_path = root.to_string_lossy(); + let input = format!( + "{{\"schema_version\":\"{REPO_QUERY_WORKER_REQUEST_SCHEMA}\",\"request_id\":\"prepare-1\",\"operation\":\"prepare\",\"repo_path\":{},\"domain\":\"graph\"}}\n{{\"schema_version\":\"{REPO_QUERY_WORKER_REQUEST_SCHEMA}\",\"request_id\":\"query-1\",\"operation\":\"query\",\"repo_path\":{},\"domain\":\"history\",\"query\":\"seed\",\"limit\":10}}\n", + serde_json::to_string(repo_path.as_ref()).expect("path json"), + serde_json::to_string(repo_path.as_ref()).expect("path json") + ); + let mut output = Vec::new(); + run_repository_query_worker(&connection, Cursor::new(input), &mut output) + .expect("worker run"); + let encoded = String::from_utf8(output).expect("worker utf8"); + let responses = encoded + .lines() + .map(|line| serde_json::from_str::(line).expect("worker response")) + .collect::>(); + + assert_eq!(responses.len(), 2); + assert_eq!(responses[0]["request_id"], "prepare-1"); + assert_eq!(responses[0]["status"], "ok"); + assert_eq!(responses[0]["preparation"]["status"], "unavailable"); + assert_eq!(responses[1]["request_id"], "query-1"); + assert_eq!(responses[1]["status"], "ok"); + assert_eq!(responses[1]["receipt"]["status"], "unavailable"); + fs::remove_dir_all(root).expect("fixture cleanup"); + } + + #[test] + fn worker_cache_reloads_when_the_latest_canonical_snapshot_changes() { + let root = + std::env::temp_dir().join(format!("codevetter-repo-cache-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).expect("fixture root"); + assert!(Command::new("git") + .args(["init", "-q"]) + .current_dir(&root) + .status() + .expect("git init") + .success()); + fs::write(root.join("README.md"), "fixture").expect("fixture source"); + assert!(Command::new("git") + .args(["add", "README.md"]) + .current_dir(&root) + .status() + .expect("git add") + .success()); + assert!(Command::new("git") + .args([ + "-c", + "user.name=CodeVetter", + "-c", + "user.email=codevetter@example.invalid", + "commit", + "-qm", + "Seed cache fixture", + ]) + .current_dir(&root) + .status() + .expect("git commit") + .success()); + let head = String::from_utf8( + Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&root) + .output() + .expect("git head") + .stdout, + ) + .expect("head utf8") + .trim() + .to_string(); + let repo_path = fs::canonicalize(&root) + .expect("canonical fixture root") + .to_string_lossy() + .into_owned(); + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("schema"); + persist_snapshot( + &connection, + &search_snapshot( + "snapshot-1", + "alpha_verifier", + "2026-09-01T00:00:01Z", + &repo_path, + &head, + ), + ) + .expect("first snapshot"); + let mut cache = RepositoryQueryWorkerCache::default(); + let preparation = prepare_repository_query_with_cache( + &connection, + &root, + RepositoryQueryDomain::Graph, + Some(&mut cache), + ) + .expect("prepare first snapshot"); + assert_eq!(preparation.status, "ready"); + assert!(cache.graph_snapshots.contains_key("snapshot-1")); + let first = query_repository_evidence_cached( + &connection, + &mut cache, + &root, + RepositoryQueryInput::search(RepositoryQueryDomain::Graph, "alpha", 10), + ) + .expect("query first snapshot"); + assert_eq!( + first.graph_result.unwrap().hits[0].node.label, + "alpha_verifier" + ); + let explained = query_repository_evidence_cached( + &connection, + &mut cache, + &root, + RepositoryQueryInput { + domain: RepositoryQueryDomain::Graph, + mode: RepositoryQueryMode::Explain, + query: "node:alpha_verifier".to_string(), + target: None, + direction: None, + depth: None, + history_selector: None, + limit: 10, + }, + ) + .expect("explain first snapshot"); + assert_eq!( + explained.graph_explanation.unwrap().node.label, + "alpha_verifier" + ); + let impacted = query_repository_evidence_cached( + &connection, + &mut cache, + &root, + RepositoryQueryInput { + domain: RepositoryQueryDomain::Graph, + mode: RepositoryQueryMode::Impact, + query: "node:alpha_verifier".to_string(), + target: None, + direction: Some(GraphDirection::Both), + depth: Some(2), + history_selector: None, + limit: 10, + }, + ) + .expect("impact first snapshot"); + assert_eq!( + impacted.graph_impact.unwrap().edges[0].evidence, + "canonical fixture edge" + ); + + persist_snapshot( + &connection, + &search_snapshot( + "snapshot-2", + "beta_verifier", + "2026-09-01T00:00:02Z", + &repo_path, + &head, + ), + ) + .expect("second snapshot"); + let second = query_repository_evidence_cached( + &connection, + &mut cache, + &root, + RepositoryQueryInput::search(RepositoryQueryDomain::Graph, "beta", 10), + ) + .expect("query second snapshot"); + assert_eq!( + second.graph_result.unwrap().hits[0].node.label, + "beta_verifier" + ); + assert_eq!(cache.graph_snapshots.len(), 1); + assert!(cache.graph_snapshots.contains_key("snapshot-2")); + fs::remove_dir_all(root).expect("fixture cleanup"); + } + + fn search_snapshot( + id: &str, + label: &str, + created_at: &str, + repo_path: &str, + head: &str, + ) -> StructuralGraphSnapshot { + StructuralGraphSnapshot { + schema_version: STRUCTURAL_GRAPH_SCHEMA_VERSION, + id: id.to_string(), + repo_path: repo_path.to_string(), + repo_head: Some(head.to_string()), + created_at: created_at.to_string(), + engine: StructuralGraphEngineInfo { + id: "fixture".to_string(), + version: "1".to_string(), + bundled: true, + syntax_aware: true, + supported_languages: vec!["rust".to_string()], + }, + cursor: None, + ignore_fingerprint: None, + coverage: StructuralGraphCoverage { + discovered_files: 1, + indexed_files: 1, + ..StructuralGraphCoverage::default() + }, + diagnostics: Vec::new(), + communities: Vec::new(), + files: Vec::new(), + nodes: vec![StructuralGraphNode { + id: format!("node:{label}"), + kind: "function".to_string(), + label: label.to_string(), + qualified_name: Some(format!("fixture::{label}")), + path: Some("src/lib.rs".to_string()), + detail: Some("Canonical worker cache fixture".to_string()), + language: Some("rust".to_string()), + community_id: None, + trust: GraphTrust::Extracted, + origin: GraphOrigin::Syntax, + sources: Vec::new(), + }], + edges: vec![StructuralGraphEdge { + id: format!("edge:{label}"), + from: format!("node:{label}"), + to: format!("node:{label}"), + kind: "references".to_string(), + evidence: "canonical fixture edge".to_string(), + trust: GraphTrust::Extracted, + origin: GraphOrigin::Resolution, + sources: Vec::new(), + candidates: Vec::new(), + }], + metrics: Vec::new(), + clone_groups: Vec::new(), + truncated: false, + } + } +} diff --git a/apps/desktop/src-tauri/src/commands/review_intent.rs b/apps/desktop/src-tauri/src/commands/review_intent.rs new file mode 100644 index 00000000..072c71ec --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/review_intent.rs @@ -0,0 +1,342 @@ +//! Deterministic intent diagnostics for completed reviews. +//! +//! This projection explains what evidence exists around the operator's stated +//! goal. It never closes intent automatically: only an explicit human +//! disposition may make that product claim. + +use std::collections::BTreeSet; + +use serde::Serialize; +use serde_json::Value; + +pub const REVIEW_INTENT_DIAGNOSTIC_SCHEMA: &str = "codevetter.review-intent-diagnostic/v1"; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ReviewIntentDiagnostic { + pub schema_version: String, + pub intent: IntentCapture, + pub changed_surfaces: Vec, + pub signals: IntentSignals, + pub gaps: Vec, + pub timeline: Vec, + pub closure: IntentClosure, + pub limitations: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct IntentCapture { + pub summary: String, + pub status: String, + pub source: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct IntentSignals { + pub changed_paths: usize, + pub findings: usize, + pub high_risk_findings: usize, + pub qa_runs: usize, + pub passed_qa_runs: usize, + pub failed_qa_runs: usize, + pub qa_artifacts: usize, + pub complete_review_coverage: bool, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct IntentTimelineItem { + pub id: String, + pub label: String, + pub detail: String, + pub status: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct IntentClosure { + pub status: String, + pub reason: String, + pub requires_human_disposition: bool, +} + +pub fn build_review_intent_diagnostic( + change_description: &str, + changed_files: &[String], + findings: &[Value], + qa_runs: &[Value], + complete_review_coverage: bool, +) -> ReviewIntentDiagnostic { + let intent_summary = change_description.trim(); + let intent_captured = !intent_summary.is_empty(); + let high_risk_findings = findings + .iter() + .filter(|finding| { + matches!( + finding.get("severity").and_then(Value::as_str), + Some("critical" | "high") + ) + }) + .count(); + let passed_qa_runs = qa_runs + .iter() + .filter(|run| run.get("pass").and_then(Value::as_bool) == Some(true)) + .count(); + let failed_qa_runs = qa_runs.len().saturating_sub(passed_qa_runs); + let qa_artifacts = qa_runs + .iter() + .map(|run| { + let artifacts = run + .get("artifacts") + .and_then(Value::as_array) + .map_or(0, Vec::len); + let screenshot = usize::from( + run.get("screenshot_path") + .and_then(Value::as_str) + .is_some_and(|path| !path.trim().is_empty()), + ); + artifacts + screenshot + }) + .sum(); + + let mut gaps = Vec::new(); + if !intent_captured { + gaps.push("Original task intent was not captured.".to_string()); + } + if !complete_review_coverage { + gaps.push("Deterministic source-review coverage is incomplete.".to_string()); + } + if high_risk_findings > 0 { + gaps.push(format!( + "{high_risk_findings} high-risk finding{} require disposition and executable re-check.", + if high_risk_findings == 1 { "" } else { "s" } + )); + } + if qa_runs.is_empty() { + gaps.push("No synthetic user-flow evidence was recorded.".to_string()); + } else if failed_qa_runs > 0 { + gaps.push(format!( + "{failed_qa_runs} recorded synthetic QA run{} did not pass.", + if failed_qa_runs == 1 { "" } else { "s" } + )); + } + + let (closure_status, closure_reason) = if !intent_captured { + ( + "missing_intent", + "Capture the original goal before judging whether the change satisfies it.", + ) + } else if !complete_review_coverage || high_risk_findings > 0 || failed_qa_runs > 0 { + ( + "evidence_conflict", + "Recorded review or runtime evidence still conflicts with the stated intent.", + ) + } else if qa_runs.is_empty() { + ( + "insufficient_evidence", + "Source review is complete, but no recorded user-flow evidence supports intent closure.", + ) + } else { + ( + "ready_for_human_disposition", + "Recorded evidence is ready for an explicit human intent disposition.", + ) + }; + + ReviewIntentDiagnostic { + schema_version: REVIEW_INTENT_DIAGNOSTIC_SCHEMA.into(), + intent: IntentCapture { + summary: if intent_captured { + intent_summary.to_string() + } else { + "No explicit task intent captured".into() + }, + status: if intent_captured { + "captured" + } else { + "missing" + } + .into(), + source: "operator_task".into(), + }, + changed_surfaces: classify_changed_surfaces(changed_files), + signals: IntentSignals { + changed_paths: changed_files.len(), + findings: findings.len(), + high_risk_findings, + qa_runs: qa_runs.len(), + passed_qa_runs, + failed_qa_runs, + qa_artifacts, + complete_review_coverage, + }, + gaps, + timeline: vec![ + IntentTimelineItem { + id: "intent".into(), + label: "Intent captured".into(), + detail: if intent_captured { + intent_summary.to_string() + } else { + "No explicit task goal was supplied.".into() + }, + status: if intent_captured { "done" } else { "missing" }.into(), + }, + IntentTimelineItem { + id: "review".into(), + label: "Source review".into(), + detail: format!( + "{} findings across {} changed paths; {} high risk.", + findings.len(), + changed_files.len(), + high_risk_findings + ), + status: if complete_review_coverage && high_risk_findings == 0 { + "done" + } else { + "warning" + } + .into(), + }, + IntentTimelineItem { + id: "synthetic_qa".into(), + label: "Synthetic QA".into(), + detail: if qa_runs.is_empty() { + "No recorded user-flow run.".into() + } else { + format!( + "{} passed, {} failed, {} retained artifact references.", + passed_qa_runs, failed_qa_runs, qa_artifacts + ) + }, + status: if qa_runs.is_empty() { + "missing" + } else if failed_qa_runs > 0 { + "warning" + } else { + "done" + } + .into(), + }, + IntentTimelineItem { + id: "human_disposition".into(), + label: "Intent disposition".into(), + detail: "Requires an explicit human decision; CodeVetter does not infer closure." + .into(), + status: "pending".into(), + }, + ], + closure: IntentClosure { + status: closure_status.into(), + reason: closure_reason.into(), + requires_human_disposition: true, + }, + limitations: vec![ + "Intent closure is never inferred from review or test output.".into(), + "Legacy synthetic QA is recorded evidence and is not assumed revision-exact.".into(), + ], + } +} + +fn classify_changed_surfaces(changed_files: &[String]) -> Vec { + let mut surfaces = BTreeSet::new(); + for path in changed_files { + let path = path.to_ascii_lowercase(); + if path.contains("test") || path.contains("spec.") { + surfaces.insert("tests".to_string()); + } + if path.starts_with("docs/") || path.ends_with(".md") { + surfaces.insert("documentation".to_string()); + } + if path.ends_with(".tsx") + || path.ends_with(".jsx") + || path.ends_with(".css") + || path.ends_with(".swift") + { + surfaces.insert("user_interface".to_string()); + } + if path.ends_with(".rs") + || path.ends_with(".ts") + || path.ends_with(".js") + || path.contains("src-tauri") + || path.contains("commands/") + || path.contains("/api/") + || path.contains("server") + { + surfaces.insert("runtime".to_string()); + } + if path.starts_with("scripts/") || path.starts_with(".github/") { + surfaces.insert("automation".to_string()); + } + if path.ends_with(".sql") || path.contains("migration") { + surfaces.insert("data".to_string()); + } + } + if surfaces.is_empty() && !changed_files.is_empty() { + surfaces.insert("other".to_string()); + } + surfaces.into_iter().collect() +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn failed_runtime_and_high_risk_review_block_intent_disposition() { + let diagnostic = build_review_intent_diagnostic( + "Preserve checkout totals", + &["src/cart.ts".into(), "src/cart.test.ts".into()], + &[json!({"severity": "high"})], + &[json!({ + "pass": false, + "artifacts": ["artifacts/trace.zip"], + "screenshot_path": "artifacts/failure.png" + })], + true, + ); + + assert_eq!(diagnostic.schema_version, REVIEW_INTENT_DIAGNOSTIC_SCHEMA); + assert_eq!(diagnostic.closure.status, "evidence_conflict"); + assert!(diagnostic.closure.requires_human_disposition); + assert_eq!(diagnostic.signals.high_risk_findings, 1); + assert_eq!(diagnostic.signals.failed_qa_runs, 1); + assert_eq!(diagnostic.signals.qa_artifacts, 2); + assert_eq!(diagnostic.changed_surfaces, vec!["runtime", "tests"]); + } + + #[test] + fn source_only_review_stays_insufficient_for_intent_closure() { + let diagnostic = build_review_intent_diagnostic( + "Keep settings readable", + &["src/SettingsView.swift".into()], + &[], + &[], + true, + ); + + assert_eq!(diagnostic.closure.status, "insufficient_evidence"); + assert_eq!(diagnostic.changed_surfaces, vec!["user_interface"]); + assert!(diagnostic + .gaps + .iter() + .any(|gap| gap.contains("No synthetic user-flow"))); + } + + #[test] + fn passing_recorded_qa_only_makes_evidence_ready_for_human_disposition() { + let diagnostic = build_review_intent_diagnostic( + "Keep the checkout flow working", + &["src/Checkout.tsx".into()], + &[], + &[json!({"pass": true, "artifacts": ["artifacts/checkout.png"]})], + true, + ); + + assert_eq!(diagnostic.closure.status, "ready_for_human_disposition"); + assert!(diagnostic.closure.requires_human_disposition); + assert!(diagnostic + .limitations + .iter() + .any(|limitation| limitation.contains("not assumed revision-exact"))); + } +} diff --git a/apps/desktop/src-tauri/src/commands/rubric_settings.rs b/apps/desktop/src-tauri/src/commands/rubric_settings.rs new file mode 100644 index 00000000..a66f28f3 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/rubric_settings.rs @@ -0,0 +1,536 @@ +use crate::db::queries; +use chrono::Utc; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use tauri::State; + +use crate::DbState; + +pub const RUBRIC_SETTINGS_SCHEMA_VERSION: &str = "codevetter.rubric-settings/v1"; +const RUBRIC_PREFERENCE_KEY: &str = "review_rubric_config_v1"; +const MAX_CUSTOM_PACKS: usize = 50; + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RubricSettingsOperation { + Read, + Select, + Upsert, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RubricPackInput { + pub id: String, + pub name: String, + pub focus: String, + pub checks: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LegacyRubricConfig { + pub custom_rules: Option>, + pub active_standards_pack: Option, + pub standards_packs: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)] +struct StoredRubricConfig { + active_pack_id: Option, + custom_rules: Vec, + custom_packs: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct RubricPackReceipt { + pub id: String, + pub name: String, + pub focus: String, + pub checks: Vec, + pub built_in: bool, + pub active: bool, + pub review_count: i64, + pub total_findings: i64, + pub prompt_preview: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct RubricSettingsReceipt { + pub schema_version: String, + pub generated_at: String, + pub operation: RubricSettingsOperation, + pub active_pack_id: Option, + pub custom_rules: Vec, + pub packs: Vec, + pub saved_pack_id: Option, + pub migrated_legacy_config: bool, +} + +#[tauri::command] +pub async fn get_rubric_settings( + db: State<'_, DbState>, + legacy_config: Option, +) -> Result { + let connection = db.0.lock().map_err(|error| error.to_string())?; + read_rubric_settings(&connection, legacy_config) +} + +#[tauri::command] +pub async fn set_active_rubric_pack( + db: State<'_, DbState>, + pack_id: String, +) -> Result { + let connection = db.0.lock().map_err(|error| error.to_string())?; + select_rubric_pack(&connection, &pack_id) +} + +#[tauri::command] +pub async fn save_rubric_pack( + db: State<'_, DbState>, + pack: RubricPackInput, +) -> Result { + let connection = db.0.lock().map_err(|error| error.to_string())?; + upsert_rubric_pack(&connection, pack) +} + +pub fn read_rubric_settings( + connection: &Connection, + legacy: Option, +) -> Result { + let (config, migrated) = load_or_migrate_config(connection, legacy)?; + build_receipt( + connection, + RubricSettingsOperation::Read, + config, + None, + migrated, + ) +} + +pub fn active_rubric_prompt(connection: &Connection) -> Result<(Option, String), String> { + let receipt = read_rubric_settings(connection, None)?; + let selected = receipt + .active_pack_id + .as_deref() + .and_then(|id| receipt.packs.iter().find(|pack| pack.id == id)) + .or_else(|| receipt.packs.first()) + .ok_or_else(|| "No review rubric packs are available".to_string())?; + Ok((receipt.active_pack_id, selected.prompt_preview.clone())) +} + +pub fn select_rubric_pack( + connection: &Connection, + pack_id: &str, +) -> Result { + let mut config = load_config(connection)?.unwrap_or_default(); + let pack_id = validate_id(pack_id)?; + if !all_pack_inputs(&config) + .iter() + .any(|pack| pack.id == pack_id) + { + return Err("Rubric pack not found".to_string()); + } + config.active_pack_id = Some(pack_id.clone()); + save_config(connection, &config)?; + build_receipt( + connection, + RubricSettingsOperation::Select, + config, + Some(pack_id), + false, + ) +} + +pub fn upsert_rubric_pack( + connection: &Connection, + pack: RubricPackInput, +) -> Result { + let mut config = load_config(connection)?.unwrap_or_default(); + let pack = validate_pack(pack)?; + if built_in_packs() + .iter() + .any(|built_in| built_in.id == pack.id) + { + return Err("Built-in rubric packs cannot be overwritten".to_string()); + } + if let Some(index) = config + .custom_packs + .iter() + .position(|existing| existing.id == pack.id) + { + config.custom_packs[index] = pack.clone(); + } else { + if config.custom_packs.len() >= MAX_CUSTOM_PACKS { + return Err(format!( + "At most {MAX_CUSTOM_PACKS} custom rubric packs are supported" + )); + } + config.custom_packs.push(pack.clone()); + } + config.active_pack_id = Some(pack.id.clone()); + validate_stored_config(&config)?; + save_config(connection, &config)?; + build_receipt( + connection, + RubricSettingsOperation::Upsert, + config, + Some(pack.id), + false, + ) +} + +fn load_or_migrate_config( + connection: &Connection, + legacy: Option, +) -> Result<(StoredRubricConfig, bool), String> { + if let Some(config) = load_config(connection)? { + return Ok((config, false)); + } + let Some(legacy) = legacy else { + return Ok((StoredRubricConfig::default(), false)); + }; + let config = StoredRubricConfig { + active_pack_id: legacy.active_standards_pack, + custom_rules: legacy.custom_rules.unwrap_or_default(), + custom_packs: legacy.standards_packs.unwrap_or_default(), + }; + let config = validate_stored_config(&config)?; + save_config(connection, &config)?; + Ok((config, true)) +} + +fn load_config(connection: &Connection) -> Result, String> { + let Some(raw) = queries::get_preference(connection, RUBRIC_PREFERENCE_KEY) + .map_err(|error| error.to_string())? + else { + return Ok(None); + }; + let config: StoredRubricConfig = serde_json::from_str(&raw) + .map_err(|_| "Stored rubric configuration is invalid".to_string())?; + Ok(Some(validate_stored_config(&config)?)) +} + +fn save_config(connection: &Connection, config: &StoredRubricConfig) -> Result<(), String> { + let value = serde_json::to_string(config).map_err(|error| error.to_string())?; + queries::set_preference(connection, RUBRIC_PREFERENCE_KEY, &value) + .map_err(|error| error.to_string()) +} + +fn validate_stored_config(config: &StoredRubricConfig) -> Result { + if config.custom_packs.len() > MAX_CUSTOM_PACKS { + return Err(format!( + "At most {MAX_CUSTOM_PACKS} custom rubric packs are supported" + )); + } + let custom_rules = config + .custom_rules + .iter() + .map(|rule| bounded_text(rule, "custom rule", 500)) + .collect::, _>>()?; + if custom_rules.len() > 100 { + return Err("At most 100 custom rubric rules are supported".to_string()); + } + let custom_packs = config + .custom_packs + .iter() + .cloned() + .map(validate_pack) + .collect::, _>>()?; + let mut ids = HashSet::new(); + for pack in built_in_packs().into_iter().chain(custom_packs.clone()) { + if !ids.insert(pack.id.clone()) { + return Err(format!("Duplicate rubric pack id `{}`", pack.id)); + } + } + let active_pack_id = config + .active_pack_id + .as_deref() + .map(validate_id) + .transpose()?; + if active_pack_id.as_ref().is_some_and(|id| !ids.contains(id)) { + return Err("Active rubric pack does not exist".to_string()); + } + Ok(StoredRubricConfig { + active_pack_id, + custom_rules, + custom_packs, + }) +} + +fn validate_pack(pack: RubricPackInput) -> Result { + let id = validate_id(&pack.id)?; + let name = bounded_text(&pack.name, "pack name", 80)?; + let focus = bounded_text(&pack.focus, "pack focus", 500)?; + if pack.checks.is_empty() || pack.checks.len() > 32 { + return Err("A rubric pack requires between 1 and 32 checks".to_string()); + } + let checks = pack + .checks + .iter() + .map(|check| bounded_text(check, "pack check", 500)) + .collect::, _>>()?; + Ok(RubricPackInput { + id, + name, + focus, + checks, + }) +} + +fn validate_id(value: &str) -> Result { + let id = value.trim(); + if id.is_empty() + || id.len() > 64 + || !id.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) + { + return Err("Rubric pack ids use 1-64 lowercase letters, digits, or hyphens".to_string()); + } + Ok(id.to_string()) +} + +fn bounded_text(value: &str, label: &str, max: usize) -> Result { + let value = value.trim(); + if value.is_empty() || value.chars().count() > max { + return Err(format!( + "A {label} between 1 and {max} characters is required" + )); + } + Ok(value.to_string()) +} + +fn build_receipt( + connection: &Connection, + operation: RubricSettingsOperation, + config: StoredRubricConfig, + saved_pack_id: Option, + migrated_legacy_config: bool, +) -> Result { + let usage = queries::get_standards_pack_usage(connection) + .map_err(|error| error.to_string())? + .into_iter() + .map(|row| (row.standards_pack, (row.review_count, row.total_findings))) + .collect::>(); + let built_in_ids = built_in_packs() + .into_iter() + .map(|pack| pack.id) + .collect::>(); + let active_id = config.active_pack_id.clone(); + let packs = all_pack_inputs(&config) + .into_iter() + .map(|pack| { + let (review_count, total_findings) = usage.get(&pack.id).copied().unwrap_or((0, 0)); + RubricPackReceipt { + prompt_preview: build_prompt_preview(&pack, &config.custom_rules), + built_in: built_in_ids.contains(&pack.id), + active: active_id.as_deref() == Some(pack.id.as_str()), + review_count, + total_findings, + id: pack.id, + name: pack.name, + focus: pack.focus, + checks: pack.checks, + } + }) + .collect(); + Ok(RubricSettingsReceipt { + schema_version: RUBRIC_SETTINGS_SCHEMA_VERSION.to_string(), + generated_at: Utc::now().to_rfc3339(), + operation, + active_pack_id: config.active_pack_id, + custom_rules: config.custom_rules, + packs, + saved_pack_id, + migrated_legacy_config, + }) +} + +fn all_pack_inputs(config: &StoredRubricConfig) -> Vec { + built_in_packs() + .into_iter() + .chain(config.custom_packs.clone()) + .collect() +} + +fn build_prompt_preview(pack: &RubricPackInput, custom_rules: &[String]) -> String { + let mut lines = vec![ + "CodeVetter review standards pack:".to_string(), + format!("- Pack: {}", pack.name), + format!("- Focus: {}", pack.focus), + ]; + lines.extend(pack.checks.iter().map(|check| format!("- Check: {check}"))); + lines.extend( + custom_rules + .iter() + .map(|rule| format!("- Custom rule: {rule}")), + ); + lines.join("\n") +} + +fn built_in_packs() -> Vec { + vec![ + RubricPackInput { + id: "product-safety".to_string(), + name: "Product Safety".to_string(), + focus: "User-facing regressions, broken flows, data loss, and confusing states." + .to_string(), + checks: vec![ + "Flag behavior changes that can break an existing user workflow.".to_string(), + "Check loading, empty, error, and permission states for user-facing screens." + .to_string(), + "Prioritize concrete reproduction steps over style commentary.".to_string(), + ], + }, + RubricPackInput { + id: "security-boundary".to_string(), + name: "Security Boundary".to_string(), + focus: "Auth, authorization, secret handling, trust boundaries, and injection risk." + .to_string(), + checks: vec![ + "Verify server-side authorization, not just hidden client controls.".to_string(), + "Flag secrets, tokens, PII, or prompts that can leak into logs or analytics." + .to_string(), + "Check untrusted input before database, shell, network, or model calls." + .to_string(), + ], + }, + RubricPackInput { + id: "agent-handoff".to_string(), + name: "Agent Handoff".to_string(), + focus: "Review quality for multi-agent workflows and future task continuity." + .to_string(), + checks: vec![ + "Call out missing tests or verification commands the next agent must run." + .to_string(), + "Prefer findings with file paths, line numbers, and a bounded fix.".to_string(), + "Separate real blockers from optional cleanup so agents do not waste context." + .to_string(), + ], + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::schema; + + fn fixture() -> Connection { + let connection = Connection::open_in_memory().expect("database"); + schema::run_migrations(&connection).expect("schema"); + connection + } + + #[test] + fn legacy_config_migrates_once_and_prompt_preview_matches_review_format() { + let connection = fixture(); + let legacy = LegacyRubricConfig { + active_standards_pack: Some("custom-payments".to_string()), + custom_rules: Some(vec!["Preserve ledger auditability.".to_string()]), + standards_packs: Some(vec![RubricPackInput { + id: "custom-payments".to_string(), + name: "Payments".to_string(), + focus: "Money movement".to_string(), + checks: vec!["Check retry idempotency.".to_string()], + }]), + }; + let migrated = read_rubric_settings(&connection, Some(legacy.clone())).expect("migrate"); + assert!(migrated.migrated_legacy_config); + let pack = migrated + .packs + .iter() + .find(|pack| pack.id == "custom-payments") + .expect("custom pack"); + assert!(pack.active); + assert!(pack + .prompt_preview + .contains("- Check: Check retry idempotency.")); + assert!(pack + .prompt_preview + .contains("- Custom rule: Preserve ledger auditability.")); + + let reread = + read_rubric_settings(&connection, Some(LegacyRubricConfig::default())).expect("reread"); + assert!(!reread.migrated_legacy_config); + assert_eq!(reread.active_pack_id.as_deref(), Some("custom-payments")); + } + + #[test] + fn existing_canonical_config_wins_over_conflicting_legacy_state() { + let connection = fixture(); + let canonical = RubricPackInput { + id: "canonical-pack".to_string(), + name: "Canonical".to_string(), + focus: "Persisted Rust authority".to_string(), + checks: vec!["Keep the canonical pack.".to_string()], + }; + upsert_rubric_pack(&connection, canonical).expect("canonical pack"); + + let legacy = LegacyRubricConfig { + active_standards_pack: Some("legacy-pack".to_string()), + custom_rules: Some(vec!["Do not overwrite Rust.".to_string()]), + standards_packs: Some(vec![RubricPackInput { + id: "legacy-pack".to_string(), + name: "Legacy".to_string(), + focus: "WebView state".to_string(), + checks: vec!["Legacy check.".to_string()], + }]), + }; + let receipt = read_rubric_settings(&connection, Some(legacy)).expect("read"); + + assert!(!receipt.migrated_legacy_config); + assert_eq!(receipt.active_pack_id.as_deref(), Some("canonical-pack")); + assert!(receipt.packs.iter().any(|pack| pack.id == "canonical-pack")); + assert!(!receipt.packs.iter().any(|pack| pack.id == "legacy-pack")); + } + + #[test] + fn invalid_legacy_state_fails_without_creating_a_canonical_preference() { + let connection = fixture(); + let invalid = LegacyRubricConfig { + active_standards_pack: Some("missing-pack".to_string()), + custom_rules: None, + standards_packs: Some(vec![]), + }; + + assert!(read_rubric_settings(&connection, Some(invalid)).is_err()); + assert!(queries::get_preference(&connection, RUBRIC_PREFERENCE_KEY) + .expect("preference lookup") + .is_none()); + } + + #[test] + fn select_and_upsert_reject_unknown_or_built_in_overwrites() { + let connection = fixture(); + assert!(select_rubric_pack(&connection, "missing").is_err()); + assert!(upsert_rubric_pack( + &connection, + RubricPackInput { + id: "product-safety".to_string(), + name: "Overwrite".to_string(), + focus: "No".to_string(), + checks: vec!["No".to_string()], + } + ) + .is_err()); + + let receipt = upsert_rubric_pack( + &connection, + RubricPackInput { + id: "performance-proof".to_string(), + name: "Performance Proof".to_string(), + focus: "Measured regressions".to_string(), + checks: vec!["Require a reproducible baseline.".to_string()], + }, + ) + .expect("upsert"); + assert_eq!(receipt.saved_pack_id.as_deref(), Some("performance-proof")); + assert_eq!(receipt.active_pack_id.as_deref(), Some("performance-proof")); + let (active_id, prompt) = active_rubric_prompt(&connection).expect("active prompt"); + assert_eq!(active_id.as_deref(), Some("performance-proof")); + assert!(prompt.contains("- Check: Require a reproducible baseline.")); + } +} diff --git a/apps/desktop/src-tauri/src/commands/run_history.rs b/apps/desktop/src-tauri/src/commands/run_history.rs new file mode 100644 index 00000000..902b6044 --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/run_history.rs @@ -0,0 +1,841 @@ +//! One bounded, Rust-owned projection of persisted verification history. +//! +//! The originating receipt remains untouched in `receipt`. Metadata here only +//! gives CLI and native clients a stable way to render unlike receipt families +//! without reinterpreting their verdicts or opening SQLite themselves. + +use std::collections::HashMap; + +use rusqlite::{params, params_from_iter, Connection}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +const MAX_RUN_HISTORY_LIMIT: usize = 100; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RunKind { + LocalCheck, + Preview, + TrexPr, + SyntheticQa, + WarmVerification, + DifferentialVerification, + AudienceValidation, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RunHistoryRecord { + pub schema_version: String, + pub id: String, + pub kind: RunKind, + pub repo_path: Option, + pub recorded_at: String, + pub title: String, + pub outcome: String, + pub receipt_schema: String, + pub source_label: Option, + pub limitations: Vec, + pub receipt: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct RunHistoryReceipt { + pub schema_version: String, + pub generated_at: String, + pub repo_path: Option, + pub limit: usize, + pub returned: usize, + pub runs: Vec, +} + +pub fn list_run_history( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result { + let limit = limit.clamp(1, MAX_RUN_HISTORY_LIMIT); + let mut runs = Vec::new(); + runs.extend(list_local_checks(connection, repo_path, limit)?); + runs.extend(list_preview_runs(connection, repo_path, limit)?); + runs.extend(list_trex_pr_runs(connection, repo_path, limit)?); + runs.extend(list_synthetic_qa_runs(connection, repo_path, limit)?); + runs.extend(list_warm_runs(connection, repo_path, limit)?); + runs.extend(list_differential_runs(connection, repo_path, limit)?); + runs.extend(list_audience_runs(connection, repo_path, limit)?); + runs.sort_by(|left, right| { + right + .recorded_at + .cmp(&left.recorded_at) + .then_with(|| right.id.cmp(&left.id)) + }); + runs.truncate(limit); + + Ok(RunHistoryReceipt { + schema_version: "codevetter.run-history/v1".into(), + generated_at: chrono::Utc::now().to_rfc3339(), + repo_path: repo_path.map(ToOwned::to_owned), + limit, + returned: runs.len(), + runs, + }) +} + +fn list_trex_pr_runs( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let sql = filtered_sql( + "SELECT id, repo_path, ran_at, pr_number, head_sha, verdict, confidence, + summary, status_state, status_error, duration_ms + FROM trex_pr_runs", + repo_path, + "ran_at", + "id", + ); + query_rows(connection, &sql, repo_path, limit, "T-Rex PR", |row| { + let id: String = row.get(0)?; + let repo_path: String = row.get(1)?; + let recorded_at: String = row.get(2)?; + let pr_number: i64 = row.get(3)?; + let head_sha: String = row.get(4)?; + let verdict: String = row.get(5)?; + let confidence: f64 = row.get(6)?; + let summary: String = row.get(7)?; + let status_state: Option = row.get(8)?; + let status_error: Option = row.get(9)?; + let duration_ms: i64 = row.get(10)?; + let limitations = status_error + .iter() + .map(|error| format!("PR status could not be resolved: {error}")) + .collect(); + let receipt = json!({ + "schema_version": "codevetter.trex-pr-run/v1", + "id": id, + "repo_path": repo_path, + "pr_number": pr_number, + "head_sha": head_sha, + "verdict": verdict, + "confidence": confidence, + "summary": summary, + "status_state": status_state, + "status_error": status_error, + "duration_ms": duration_ms, + "ran_at": recorded_at, + }); + Ok(RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id, + kind: RunKind::TrexPr, + repo_path: Some(repo_path), + recorded_at, + title: format!("PR #{pr_number}: {summary}"), + outcome: verdict, + receipt_schema: "codevetter.trex-pr-run/v1".into(), + source_label: Some(short_identity(&head_sha)), + limitations, + receipt, + }) + }) +} + +fn list_synthetic_qa_runs( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let sql = filtered_sql( + "SELECT id, repo_path, created_at, goal, route, runner_type, review_id, + loop_id, base_url, pass, duration_ms, notes, screenshot_path, + artifacts, console_errors, error, trace_json + FROM synthetic_qa_runs", + repo_path, + "created_at", + "id", + ); + query_rows(connection, &sql, repo_path, limit, "synthetic QA", |row| { + let id: String = row.get(0)?; + let repo_path: Option = row.get(1)?; + let recorded_at: String = row.get(2)?; + let goal: Option = row.get(3)?; + let route: Option = row.get(4)?; + let runner_type: String = row.get(5)?; + let review_id: Option = row.get(6)?; + let loop_id: String = row.get(7)?; + let base_url: Option = row.get(8)?; + let passed = row.get::<_, i64>(9)? != 0; + let duration_ms: i64 = row.get(10)?; + let notes: Option = row.get(11)?; + let screenshot_path: Option = row.get(12)?; + let artifacts_json: Option = row.get(13)?; + let console_errors: i64 = row.get(14)?; + let error: Option = row.get(15)?; + let trace_json: Option = row.get(16)?; + let title = non_empty(goal.as_deref()) + .or_else(|| non_empty(route.as_deref())) + .unwrap_or("Synthetic QA") + .to_owned(); + let mut limitations = Vec::new(); + if !passed { + limitations.push( + error + .clone() + .unwrap_or_else(|| "Synthetic QA did not pass".into()), + ); + } + if console_errors > 0 { + limitations.push(format!("{console_errors} console error(s) recorded")); + } + let receipt = json!({ + "schema_version": "codevetter.synthetic-qa-run/v1", + "id": id, + "review_id": review_id, + "repo_path": repo_path, + "loop_id": loop_id, + "runner_type": runner_type, + "base_url": base_url, + "route": route, + "goal": goal, + "pass": passed, + "duration_ms": duration_ms, + "notes": notes, + "screenshot_path": screenshot_path, + "artifacts": decode_json_or_raw(artifacts_json), + "console_errors": console_errors, + "error": error, + "trace": decode_json_or_raw(trace_json.clone()), + "trace_json": trace_json, + "created_at": recorded_at, + }); + Ok(RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id, + kind: RunKind::SyntheticQa, + repo_path, + recorded_at, + title, + outcome: if passed { "passed" } else { "failed" }.into(), + receipt_schema: "codevetter.synthetic-qa-run/v1".into(), + source_label: Some(runner_type), + limitations, + receipt, + }) + }) +} + +fn list_local_checks( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let sql = filtered_sql( + "SELECT run_id, repo_path, ran_at, task, verdict, head_sha, receipt_json + FROM local_check_runs", + repo_path, + "ran_at", + "run_id", + ); + query_rows(connection, &sql, repo_path, limit, "local check", |row| { + let receipt: Value = decode_json(row.get(6)?, "local check")?; + Ok(RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id: row.get(0)?, + kind: RunKind::LocalCheck, + repo_path: Some(row.get(1)?), + recorded_at: row.get(2)?, + title: row.get(3)?, + outcome: row.get(4)?, + receipt_schema: string_field(&receipt, "schema_version") + .unwrap_or("codevetter.local-check/v1") + .into(), + source_label: Some(short_identity(&row.get::<_, String>(5)?)), + limitations: string_array(&receipt, "limitations"), + receipt, + }) + }) +} + +fn list_preview_runs( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let sql = filtered_sql( + "SELECT id, repo_path, ran_at, summary, verdict, head_sha, receipt_json + FROM trex_preview_runs", + repo_path, + "ran_at", + "id", + ); + query_rows(connection, &sql, repo_path, limit, "preview", |row| { + let receipt: Value = decode_json(row.get(6)?, "preview")?; + Ok(RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id: row.get(0)?, + kind: RunKind::Preview, + repo_path: Some(row.get(1)?), + recorded_at: row.get(2)?, + title: row.get(3)?, + outcome: row.get(4)?, + receipt_schema: "codevetter.trex-preview/v1".into(), + source_label: Some(short_identity(&row.get::<_, String>(5)?)), + limitations: string_array(&receipt, "limitations"), + receipt, + }) + }) +} + +fn list_warm_runs( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let sql = filtered_sql( + "SELECT id, repo_path, created_at, outcome, target_sha, result_json + FROM warm_verification_runs", + repo_path, + "created_at", + "id", + ); + query_rows( + connection, + &sql, + repo_path, + limit, + "warm verification", + |row| { + let receipt: Value = decode_json(row.get(5)?, "warm verification")?; + let warm = receipt + .get("warm") + .and_then(Value::as_bool) + .unwrap_or(false); + Ok(RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id: row.get(0)?, + kind: RunKind::WarmVerification, + repo_path: Some(row.get(1)?), + recorded_at: row.get(2)?, + title: if warm { + "Warm browser verification".into() + } else { + "Browser verification".into() + }, + outcome: row.get(3)?, + receipt_schema: "codevetter.warm-verification/v1".into(), + source_label: Some(short_identity(&row.get::<_, String>(4)?)), + limitations: string_array(&receipt, "limitations"), + receipt, + }) + }, + ) +} + +fn list_differential_runs( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let sql = filtered_sql( + "SELECT id, repo_path, created_at, classification, reference_sha, summary_json + FROM differential_verification_runs", + repo_path, + "created_at", + "id", + ); + query_rows( + connection, + &sql, + repo_path, + limit, + "differential verification", + |row| { + let receipt: Value = decode_json(row.get(5)?, "differential verification")?; + let source: Option = row.get(4)?; + Ok(RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id: row.get(0)?, + kind: RunKind::DifferentialVerification, + repo_path: Some(row.get(1)?), + recorded_at: row.get(2)?, + title: "Differential verification".into(), + outcome: row.get(3)?, + receipt_schema: "codevetter.differential-verification/v1".into(), + source_label: source.as_deref().map(short_identity), + limitations: string_array(&receipt, "limitations"), + receipt, + }) + }, + ) +} + +fn list_audience_runs( + connection: &Connection, + repo_path: Option<&str>, + limit: usize, +) -> Result, String> { + let sql = filtered_sql( + "SELECT id, repo_path, created_at, task, status, audience, review_id, + candidate_a, candidate_b, criteria_json, min_responses, required, + waived_reason, updated_at + FROM audience_validation_runs", + repo_path, + "created_at", + "id", + ); + let mut runs = query_rows( + connection, + &sql, + repo_path, + limit, + "audience validation", + |row| { + let criteria_json: String = row.get(9)?; + let criteria: Value = decode_json(criteria_json, "audience criteria")?; + let required = row.get::<_, i64>(11)? != 0; + let status: String = row.get(4)?; + let waived_reason: Option = row.get(12)?; + let mut limitations = Vec::new(); + if status != "complete" && status != "waived" { + limitations.push(format!("Audience validation is {status}")); + } + if let Some(reason) = waived_reason.as_ref() { + limitations.push(format!("Audience validation was waived: {reason}")); + } + let id: String = row.get(0)?; + let repo_path: Option = row.get(1)?; + let recorded_at: String = row.get(2)?; + let title: String = row.get(3)?; + let audience: String = row.get(5)?; + let review_id: String = row.get(6)?; + let candidate_a: String = row.get(7)?; + let candidate_b: Option = row.get(8)?; + let min_responses: i64 = row.get(10)?; + let updated_at: String = row.get(13)?; + let receipt = json!({ + "schema_version": "codevetter.audience-validation-run/v1", + "id": id, + "review_id": review_id, + "repo_path": repo_path, + "audience": audience, + "task": title, + "candidate_a": candidate_a, + "candidate_b": candidate_b, + "criteria": criteria, + "min_responses": min_responses, + "required": required, + "status": status, + "waived_reason": waived_reason, + "created_at": recorded_at, + "updated_at": updated_at, + }); + Ok(RunHistoryRecord { + schema_version: "codevetter.run-record/v1".into(), + id, + kind: RunKind::AudienceValidation, + repo_path, + recorded_at, + title, + outcome: status, + receipt_schema: "codevetter.audience-validation-run/v1".into(), + source_label: Some(audience), + limitations, + receipt, + }) + }, + )?; + let run_ids = runs.iter().map(|run| run.id.clone()).collect::>(); + let mut responses_by_run = audience_responses(connection, &run_ids)?; + for run in &mut runs { + let responses = responses_by_run.remove(&run.id).unwrap_or_default(); + if let Some(receipt) = run.receipt.as_object_mut() { + receipt.insert("response_count".into(), json!(responses.len())); + receipt.insert("responses".into(), Value::Array(responses)); + } + } + Ok(runs) +} + +fn audience_responses( + connection: &Connection, + run_ids: &[String], +) -> Result>, String> { + if run_ids.is_empty() { + return Ok(HashMap::new()); + } + let placeholders = (1..=run_ids.len()) + .map(|index| format!("?{index}")) + .collect::>() + .join(", "); + let sql = format!( + "SELECT run_id, id, participant_id, provenance, criterion, candidate_a, candidate_b, + preferred_candidate, reverse_preferred_candidate, confidence, task_passed, + feedback, evidence_ref, elapsed_ms, created_at + FROM audience_validation_responses + WHERE run_id IN ({placeholders}) + ORDER BY run_id ASC, created_at ASC, id ASC" + ); + let mut statement = connection + .prepare(&sql) + .map_err(|error| format!("prepare audience response history: {error}"))?; + let rows = statement + .query_map(params_from_iter(run_ids), |row| { + let run_id: String = row.get(0)?; + let task_passed: Option = row.get(10)?; + Ok(( + run_id.clone(), + json!({ + "id": row.get::<_, String>(1)?, + "run_id": run_id, + "participant_id": row.get::<_, String>(2)?, + "provenance": row.get::<_, String>(3)?, + "criterion": row.get::<_, String>(4)?, + "candidate_a": row.get::<_, String>(5)?, + "candidate_b": row.get::<_, Option>(6)?, + "preferred_candidate": row.get::<_, Option>(7)?, + "reverse_preferred_candidate": row.get::<_, Option>(8)?, + "confidence": row.get::<_, f64>(9)?, + "task_passed": task_passed.map(|value| value != 0), + "feedback": row.get::<_, Option>(11)?, + "evidence_ref": row.get::<_, Option>(12)?, + "elapsed_ms": row.get::<_, Option>(13)?, + "created_at": row.get::<_, String>(14)?, + }), + )) + }) + .map_err(|error| format!("read audience response history: {error}"))?; + let mut responses = HashMap::>::new(); + for row in rows { + let (run_id, response) = + row.map_err(|error| format!("decode audience response history: {error}"))?; + responses.entry(run_id).or_default().push(response); + } + Ok(responses) +} + +fn filtered_sql( + base: &str, + repo_path: Option<&str>, + order_column: &str, + identity_column: &str, +) -> String { + let filter = if repo_path.is_some() { + " WHERE repo_path = ?1" + } else { + "" + }; + let limit_parameter = if repo_path.is_some() { "?2" } else { "?1" }; + format!( + "{base}{filter} ORDER BY {order_column} DESC, {identity_column} DESC LIMIT {limit_parameter}" + ) +} + +fn query_rows( + connection: &Connection, + sql: &str, + repo_path: Option<&str>, + limit: usize, + label: &str, + map: F, +) -> Result, String> +where + F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result, +{ + let mut statement = connection + .prepare(sql) + .map_err(|error| format!("prepare {label} history: {error}"))?; + let rows = match repo_path { + Some(repo_path) => statement.query_map(params![repo_path, limit as i64], map), + None => statement.query_map(params![limit as i64], map), + } + .map_err(|error| format!("read {label} history: {error}"))?; + rows.collect::>>() + .map_err(|error| format!("decode {label} history: {error}")) +} + +fn decode_json(text: String, label: &str) -> rusqlite::Result { + serde_json::from_str(&text).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + format!("invalid stored {label} JSON: {error}").into(), + ) + }) +} + +fn decode_json_or_raw(text: Option) -> Value { + match text { + Some(text) => serde_json::from_str(&text).unwrap_or(Value::String(text)), + None => Value::Null, + } +} + +fn non_empty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +fn string_field<'a>(value: &'a Value, field: &str) -> Option<&'a str> { + value.get(field).and_then(Value::as_str) +} + +fn string_array(value: &Value, field: &str) -> Vec { + value + .get(field) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect() +} + +fn short_identity(identity: &str) -> String { + identity.chars().take(12).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Instant; + + #[test] + fn projects_all_retained_run_families_in_one_bounded_order() { + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + seed_run_families(&connection); + + let history = list_run_history(&connection, Some("/repo"), 7).expect("history"); + + assert_eq!(history.schema_version, "codevetter.run-history/v1"); + assert_eq!(history.returned, 7); + assert_eq!(history.runs[0].kind, RunKind::TrexPr); + assert_eq!(history.runs[1].kind, RunKind::SyntheticQa); + assert_eq!(history.runs[2].kind, RunKind::AudienceValidation); + assert_eq!(history.runs[3].kind, RunKind::DifferentialVerification); + assert_eq!(history.runs[4].kind, RunKind::WarmVerification); + assert_eq!(history.runs[5].kind, RunKind::Preview); + assert_eq!(history.runs[6].kind, RunKind::LocalCheck); + assert_eq!(history.runs[2].receipt["response_count"], 1); + assert_eq!( + history.runs[2].receipt["responses"][0]["participant_id"], + "participant-1" + ); + assert!(history + .runs + .iter() + .all(|run| run.repo_path.as_deref() == Some("/repo"))); + } + + #[test] + fn global_history_includes_null_repo_audience_rows_without_weakening_filtering() { + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + seed_run_families(&connection); + connection + .execute( + "UPDATE audience_validation_runs SET repo_path = NULL WHERE id = 'audience-1'", + [], + ) + .expect("null repo audience"); + + let global = list_run_history(&connection, None, 100).expect("global history"); + let filtered = list_run_history(&connection, Some("/repo"), 100).expect("filtered history"); + + assert!(global.runs.iter().any(|run| run.id == "audience-1")); + assert!(!filtered.runs.iter().any(|run| run.id == "audience-1")); + } + + #[test] + #[ignore = "release-mode performance evidence; run explicitly with --nocapture"] + fn benchmark_seven_family_projection_over_seven_hundred_rows() { + let connection = Connection::open_in_memory().expect("database"); + crate::db::schema::run_migrations(&connection).expect("migrations"); + seed_large_ledger(&connection); + + for _ in 0..20 { + assert_eq!( + list_run_history(&connection, Some("/repo"), 100) + .expect("warm projection") + .returned, + 100 + ); + } + + let mut samples_us = Vec::with_capacity(250); + for _ in 0..250 { + let started = Instant::now(); + let history = + list_run_history(&connection, Some("/repo"), 100).expect("measured projection"); + samples_us.push(started.elapsed().as_micros() as u64); + assert_eq!(history.returned, 100); + } + samples_us.sort_unstable(); + let median_us = samples_us[samples_us.len() / 2]; + let p95_us = samples_us[(samples_us.len() * 95 / 100).min(samples_us.len() - 1)]; + println!( + "RUN_HISTORY_BENCHMARK_JSON {}", + json!({ + "schema_version": "codevetter.native-run-history-benchmark/v1", + "stored_run_rows": 700, + "stored_audience_response_rows": 100, + "returned_rows": 100, + "families": 7, + "warmups": 20, + "samples": 250, + "median_us": median_us, + "p95_us": p95_us, + }) + ); + } + + fn seed_run_families(connection: &Connection) { + connection + .execute_batch( + r#" + INSERT INTO local_reviews(id, review_type, source_label, repo_path, repo_full_name, + pr_number, status, created_at) + VALUES('review-1', 'pull_request', 'PR #1', '/repo', 'fleet/codevetter', 1, 'complete', + '2026-08-31T00:00:00Z'); + INSERT INTO local_check_runs(run_id, schema_version, repo_path, base_sha, head_sha, + verdict, task, receipt_json, ran_at) + VALUES('local-1', 'codevetter.local-check/v1', '/repo', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'passed_with_limits', 'Local check', + '{"schema_version":"codevetter.local-check/v1","limitations":["bounded"]}', + '2026-08-31T01:00:00Z'); + INSERT INTO trex_preview_runs(id, repo_path, source_kind, source_input, base_sha, + head_sha, preview_url, preview_identity, verdict, summary, receipt_json, + duration_ms, ran_at) + VALUES('preview-1', '/repo', 'range', 'main...HEAD', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'https://preview.test', 'identity', + 'passed_with_limits', 'Preview check', '{"limitations":[]}', 1, + '2026-08-31T02:00:00Z'); + INSERT INTO warm_verification_runs(id, repo_path, run_id, schema_version, + protocol_version, outcome, target_sha, change_set_kind, change_set_id, started_at, + finished_at, warm, stale, result_json, created_at) + VALUES('warm-1', '/repo', 'warm-run-1', 1, 1, 'passed', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'range', 'identity', + '2026-08-31T03:00:00Z', '2026-08-31T03:00:01Z', 1, 0, + '{"warm":true,"limitations":[]}', '2026-08-31T03:00:01Z'); + INSERT INTO differential_verification_runs(id, repo_path, run_id, schema_version, + status, classification, reference_sha, candidate_kind, candidate_identity, + plan_identity, duration_ms, cleanup_complete, summary_json, created_at) + VALUES('differential-1', '/repo', 'diff-run-1', 1, 'complete', 'unchanged', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'worktree', 'identity', 'plan', 1, 1, + '{"limitations":[]}', '2026-08-31T04:00:00Z'); + INSERT INTO audience_validation_runs(id, review_id, repo_path, audience, task, + candidate_a, candidate_b, criteria_json, min_responses, required, status, + created_at, updated_at) + VALUES('audience-1', 'review-1', '/repo', 'maintainers', 'Audience check', 'a', 'b', + '["correctness"]', 3, 1, 'collecting', '2026-08-31T05:00:00Z', + '2026-08-31T05:00:00Z'); + INSERT INTO audience_validation_responses(id, run_id, participant_id, provenance, + criterion, candidate_a, candidate_b, preferred_candidate, confidence, task_passed, + feedback, created_at) + VALUES('response-1', 'audience-1', 'participant-1', 'human', 'correctness', 'a', 'b', + 'a', 0.9, 1, 'Clearer evidence', '2026-08-31T05:00:01Z'); + INSERT INTO synthetic_qa_runs(id, review_id, repo_path, loop_id, runner_type, base_url, + route, goal, pass, duration_ms, notes, screenshot_path, artifacts, console_errors, + error, trace_json, created_at) + VALUES('synthetic-1', 'review-1', '/repo', 'loop-1', 'playwright_builtin', + 'http://127.0.0.1:1420', '/review', 'Verify the review flow', 0, 240, 'blocked', + NULL, '["trace.zip"]', 1, 'Expected evidence was missing', + '{"final_url":"http://127.0.0.1:1420/review"}', '2026-08-31T06:00:00Z'); + INSERT INTO trex_pr_runs(id, repo_path, pr_number, head_sha, verdict, confidence, + summary, status_state, duration_ms, ran_at) + VALUES('trex-pr-1', '/repo', 201, + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'NEEDS_REVIEW', 0.82, + 'One evidence gap remains', 'pending', 320, '2026-08-31T07:00:00Z'); + "#, + ) + .expect("seed run families"); + } + + fn seed_large_ledger(connection: &Connection) { + connection + .execute_batch( + r#" + INSERT INTO local_reviews(id, review_type, source_label, repo_path, + repo_full_name, pr_number, status, created_at) + VALUES('review-1', 'pull_request', 'PR #1', '/repo', 'fleet/codevetter', 1, + 'complete', '2026-08-31T00:00:00Z'); + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO local_check_runs(run_id, schema_version, repo_path, base_sha, + head_sha, verdict, task, receipt_json, ran_at) + SELECT printf('local-%03d', x), 'codevetter.local-check/v1', '/repo', + printf('%040d', x), printf('%040d', x + 1), 'passed_with_limits', + printf('Local check %03d', x), + '{"schema_version":"codevetter.local-check/v1","limitations":[]}', + printf('2026-08-31T01:%02d:%02dZ', x / 60, x % 60) FROM n; + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO trex_preview_runs(id, repo_path, source_kind, source_input, base_sha, + head_sha, preview_url, preview_identity, verdict, summary, receipt_json, + duration_ms, ran_at) + SELECT printf('preview-%03d', x), '/repo', 'range', 'main...HEAD', + printf('%040d', x), printf('%040d', x + 1), 'https://preview.test', + printf('preview-identity-%03d', x), 'passed_with_limits', + printf('Preview %03d', x), '{"limitations":[]}', 1, + printf('2026-08-31T02:%02d:%02dZ', x / 60, x % 60) FROM n; + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO warm_verification_runs(id, repo_path, run_id, schema_version, + protocol_version, outcome, target_sha, change_set_kind, change_set_id, + started_at, finished_at, warm, stale, result_json, created_at) + SELECT printf('warm-%03d', x), '/repo', printf('warm-run-%03d', x), 1, 1, + 'passed', printf('%040d', x + 1), 'range', printf('warm-change-%03d', x), + printf('2026-08-31T03:%02d:%02dZ', x / 60, x % 60), + printf('2026-08-31T03:%02d:%02dZ', x / 60, x % 60), 1, 0, + '{"warm":true,"limitations":[]}', + printf('2026-08-31T03:%02d:%02dZ', x / 60, x % 60) FROM n; + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO differential_verification_runs(id, repo_path, run_id, + schema_version, status, classification, reference_sha, candidate_kind, + candidate_identity, plan_identity, duration_ms, cleanup_complete, + summary_json, created_at) + SELECT printf('differential-%03d', x), '/repo', printf('diff-run-%03d', x), 1, + 'complete', 'unchanged', printf('%040d', x), 'worktree', + printf('candidate-%03d', x), printf('plan-%03d', x), 1, 1, + '{"limitations":[]}', + printf('2026-08-31T04:%02d:%02dZ', x / 60, x % 60) FROM n; + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO audience_validation_runs(id, review_id, repo_path, audience, task, + candidate_a, candidate_b, criteria_json, min_responses, required, status, + created_at, updated_at) + SELECT printf('audience-%03d', x), 'review-1', '/repo', 'maintainers', + printf('Audience check %03d', x), 'a', 'b', '["correctness"]', 3, 1, + 'complete', printf('2026-08-31T05:%02d:%02dZ', x / 60, x % 60), + printf('2026-08-31T05:%02d:%02dZ', x / 60, x % 60) FROM n; + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO audience_validation_responses(id, run_id, participant_id, provenance, + criterion, candidate_a, candidate_b, preferred_candidate, confidence, + task_passed, created_at) + SELECT printf('response-%03d', x), printf('audience-%03d', x), + printf('participant-%03d', x), 'human', 'correctness', 'a', 'b', 'a', 0.9, 1, + printf('2026-08-31T05:%02d:%02dZ', x / 60, x % 60) FROM n; + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO synthetic_qa_runs(id, review_id, repo_path, loop_id, runner_type, + route, goal, pass, duration_ms, artifacts, console_errors, trace_json, created_at) + SELECT printf('synthetic-%03d', x), 'review-1', '/repo', + printf('loop-%03d', x), 'playwright_builtin', '/review', + printf('Synthetic QA %03d', x), 1, 10, '[]', 0, + '{"final_url":"http://127.0.0.1/review"}', + printf('2026-08-31T06:%02d:%02dZ', x / 60, x % 60) FROM n; + + WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM n WHERE x < 100) + INSERT INTO trex_pr_runs(id, repo_path, pr_number, head_sha, verdict, confidence, + summary, status_state, duration_ms, ran_at) + SELECT printf('trex-pr-%03d', x), '/repo', x, printf('%040d', x + 1), + 'APPROVE', 0.9, printf('PR run %03d', x), 'success', 10, + printf('2026-08-31T07:%02d:%02dZ', x / 60, x % 60) FROM n; + "#, + ) + .expect("seed 700-run ledger"); + } +}