Files
zesdex/src/app/review/mod.rs
T

438 lines
15 KiB
Rust
Raw Normal View History

use std::process::Command;
use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::TurnEvent;
use crate::app::state::types::{Origin, Toast, ToastKind};
use crate::app::subagent::context::build_subagent_context;
use crate::app::subagent::engine::run_subagent;
use crate::app::subagent::spawn::AgentDefinition;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Confidence {
Human,
Verified,
Unverified,
Auto,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum LessonLifecycle {
New,
Active,
Stale,
Contradicted,
Superseded,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum LessonScope {
Project,
Global,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Provenance {
pub session_turn: String,
pub session_id: String,
pub reviewer: Origin,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lesson {
pub name: String,
pub content: String,
pub confidence: Confidence,
pub outcome: Option<String>,
pub lifecycle: LessonLifecycle,
pub scope: LessonScope,
pub contradiction_with: Option<String>,
pub provenance: Provenance,
}
pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
if origin != Origin::Main {
return false;
}
let runtime = match &state.session_runtime {
Some(r) => r,
None => return false,
};
if !state.settings.review_enabled {
return false;
}
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
return true;
}
let base: u32 = state.settings.adaptive_review_max_skip.max(2);
let consecutive = runtime.consecutive_empty_reviews;
if consecutive >= base {
let skip = 1u32 << (consecutive - base).min(10);
if runtime.edit_count > 0 && (runtime.edit_count % skip == 0) {
return true;
}
return false;
}
false
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeResult {
pub command: String,
pub passed: bool,
pub output: String,
pub timed_out: bool,
}
pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option<ProbeResult> {
let probe_dir = workspaces.first()?;
let cmd = resolve_verify_command(probe_dir, verify_command)?;
let (cmd_prog, cmd_args) = cmd.split_once(' ').map(|(p, a)| (p.to_string(), a.to_string()))
.unwrap_or_else(|| (cmd.clone(), String::new()));
let Ok(mut child) = Command::new(&cmd_prog)
.args(cmd_args.split_whitespace())
.current_dir(probe_dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn() else { return None };
let start = std::time::Instant::now();
let timed_out = loop {
if start.elapsed().as_millis() as u64 >= timeout_ms {
let _ = child.kill();
break true;
}
match child.try_wait() {
Ok(Some(status)) => {
let output = child.wait_with_output().ok();
let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default();
let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) };
return Some(ProbeResult {
command: cmd.clone(),
passed: status.success(),
output: truncate_output(&combined, 2048),
timed_out: false,
});
}
Ok(None) => { std::thread::sleep(std::time::Duration::from_millis(50)); }
Err(_) => return None,
}
};
if timed_out {
Some(ProbeResult {
command: cmd.clone(),
passed: false,
output: "timed out".to_string(),
timed_out: true,
})
} else {
None
}
}
fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str>) -> Option<String> {
if let Some(cmd) = override_cmd {
if !cmd.trim().is_empty() {
return Some(cmd.trim().to_string());
}
}
let has_file = |name: &str| probe_dir.join(name).exists();
let has_dir = |name: &str| probe_dir.join(name).is_dir();
if has_file("Cargo.toml") {
if has_dir("src") || has_dir("tests") {
return Some("cargo build 2>&1 && cargo test 2>&1".to_string());
}
return Some("cargo build 2>&1".to_string());
}
if has_file("go.mod") {
return Some("go build ./... 2>&1 && go test ./... 2>&1".to_string());
}
if has_file("package.json") {
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
let scripts = v.get("scripts")?;
if scripts.get("test").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() {
return Some("npm test 2>&1".to_string());
}
if scripts.get("build").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() {
return Some("npm run build 2>&1".to_string());
}
}
return Some("npm test 2>&1".to_string());
}
if has_file("pyproject.toml") || has_file("requirements.txt") || has_file("setup.py") || has_file("setup.cfg") || has_file("Pipfile") || has_file("poetry.lock") {
if has_file("pyproject.toml") {
let content = std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default();
if content.contains("[tool.pytest") {
return Some("python -m pytest --tb=short -q 2>&1".to_string());
}
}
if has_dir("tests") || has_dir("test") {
return Some("python -m pytest --tb=short -q 2>&1".to_string());
}
return None;
}
if has_file("Cargo.lock") {
return Some("cargo build 2>&1".to_string());
}
if has_file("Gemfile") || has_file("Rakefile") || has_file("*.gemspec") {
return Some("bundle exec rake 2>&1".to_string());
}
if has_file("Makefile") || has_file("makefile") || has_file("GNUmakefile") {
return Some("make test 2>&1 || make build 2>&1".to_string());
}
if has_file("justfile") || has_file("justfile") {
return Some("just test 2>&1 || just build 2>&1".to_string());
}
if has_file("deno.json") || has_file("deno.jsonc") {
return Some("deno test 2>&1".to_string());
}
if has_file("bun.lock") || has_file("bun.lockb") {
return Some("bun test 2>&1".to_string());
}
if has_file("pnpm-lock.yaml") {
return Some("pnpm test 2>&1 || pnpm build 2>&1".to_string());
}
if has_file("yarn.lock") {
return Some("yarn test 2>&1 || yarn build 2>&1".to_string());
}
if has_file("composer.json") {
return Some("composer test 2>&1 || composer run build 2>&1".to_string());
}
if has_file("build.gradle") || has_file("build.gradle.kts") || has_file("gradlew") {
return Some("gradle build 2>&1 && gradle test 2>&1".to_string());
}
if has_file("pom.xml") || has_file("mvnw") {
return Some("mvn test 2>&1".to_string());
}
if has_file("stack.yaml") || has_file("package.yaml") || has_file("cabal.project") {
return Some("cabal test all 2>&1 || stack test 2>&1".to_string());
}
if has_file("mix.exs") {
return Some("mix test 2>&1".to_string());
}
if has_file("rebar.config") || has_file("rebar.lock") {
return Some("rebar3 ct 2>&1 || rebar3 eunit 2>&1".to_string());
}
if has_file("dune-project") || has_file("jbuild") || has_file("Makefile") {
return Some("dune runtest 2>&1".to_string());
}
if has_file("shard.yml") {
return Some("crystal spec 2>&1".to_string());
}
if has_file("Project.toml") || has_file("JuliaProject.toml") {
return Some("julia --project=. -e 'using Pkg; Pkg.test()' 2>&1".to_string());
}
None
}
fn truncate_output(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let mut t: String = s.chars().take(max).collect();
t.push_str("... (truncated)");
t
}
}
pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
let def = AgentDefinition::new(
"quality-reviewer".to_string(),
"reviewer".to_string(),
);
let mut ctx = build_subagent_context(def);
ctx.session_dir = state.session_dir.clone();
let probe_result = probe_build_test(
&state.workspace_roots,
state.settings.verify_command.as_deref(),
state.settings.verify_timeout_ms,
);
let probe_note = match &probe_result {
Some(r) => {
if r.passed {
format!("Build/test verification passed ({}). Confidence: verified.", r.command)
} else if r.timed_out {
format!("Build/test verification timed out ({}). Confidence: opinion (no reproducible result).", r.command)
} else {
format!("Build/test verification failed ({}). Output: {}", r.command, r.output)
}
}
None => "No build/test probe matched. Confidence: opinion (reasoning-based).".to_string(),
};
ctx.system_prompt = format!(
"You are a code quality reviewer. Review the recent code changes \
for correctness, security, and adherence to best practices. \
Use read-only tools (read, grep, glob, recall, remember) to \
inspect the session files and provide a concise review verdict. \
Session directory: {:?}\n\n\
Build/Test Probe:\n{}\n\n\
When writing a lesson via remember(), set tags appropriately:\n\
- If build/test verification printed any FAILED/ERROR lines, tag\n\
the lesson as \"confidence: verified\" (backed by a real failure).\n\
- If the probe passed or was skipped, tag as \"confidence: opinion\"\n\
(reviewer judgment only).\n\
Check for duplicate lessons via recall before writing a new one.",
state.session_dir,
probe_note,
);
let (tx, _rx) = tokio::sync::mpsc::channel(32);
let turn_events = state.turn_events.clone();
std::thread::spawn(move || {
let result = run_subagent(ctx, tx);
let message = match result {
Ok(verdict) => {
let first_line = verdict.lines().next().unwrap_or(&verdict);
format!("Quality review: {}", first_line)
}
Err(e) => format!("Quality review failed: {}", e),
};
if let Ok(mut q) = turn_events.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "review".to_string(),
message,
});
}
});
state.push_toast(Toast::new(
ToastKind::Info,
"Quality review triggered".to_string(),
));
Ok(())
}
const STALE_AFTER_DAYS: i64 = 60;
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
let mut flagged = Vec::new();
let names = crate::model::memory::Memory::list(memory_dir);
let now = chrono::Utc::now().timestamp_millis();
let cutoff = now - STALE_AFTER_DAYS * 24 * 3600 * 1000;
for name in names {
if let Ok(mut mem) = crate::model::memory::Memory::read(memory_dir, &name) {
if mem.updated_at < cutoff && mem.lifecycle != "stale" {
mem.lifecycle = "stale".to_string();
mem.write(memory_dir)?;
flagged.push(name);
}
}
}
Ok(flagged)
}
pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
let now = chrono::Utc::now().timestamp_millis();
if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 {
return;
}
state.misc.last_staleness_sweep_ms = now;
if let Ok(flagged) = run_staleness_sweep(&state.memory_dir) {
if !flagged.is_empty() {
state.push_toast(Toast::new(
ToastKind::Info,
format!("Staleness sweep: {} lesson(s) flagged as stale: {}", flagged.len(), flagged.join(", ")),
));
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingLesson {
pub lesson: Lesson,
pub created_at: i64,
pub auto_resolve: bool,
}
pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec<PendingLesson> {
let path = session_dir.join("pending_lessons.json");
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
pub fn save_pending_lessons(session_dir: &std::path::Path, pending: &[PendingLesson]) -> std::io::Result<()> {
let path = session_dir.join("pending_lessons.json");
let data = serde_json::to_string_pretty(pending)?;
std::fs::write(&path, data)
}
pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result<Vec<PendingLesson>> {
let pending = load_pending_lessons(session_dir);
let now = chrono::Utc::now().timestamp_millis();
let grace_window = 5_000;
let mut remaining = Vec::new();
let mut to_keep = Vec::new();
for p in &pending {
if p.auto_resolve && now.saturating_sub(p.created_at) >= grace_window {
to_keep.push(p.lesson.clone());
} else {
remaining.push(p.clone());
}
}
for lesson in &to_keep {
let mem = crate::model::memory::Memory {
name: lesson.name.clone(),
description: lesson.content.chars().take(80).collect(),
content: lesson.content.clone(),
kind: "lesson".to_string(),
created_at: now,
updated_at: now,
outcome: None,
lifecycle: "active".to_string(),
scope: Some("project".to_string()),
before_snippet: None,
after_snippet: None,
provenances: vec![],
};
mem.write(memory_dir)?;
}
save_pending_lessons(session_dir, &remaining)?;
Ok(remaining)
}
pub fn resolve_pending_lesson(
session_dir: &std::path::Path,
memory_dir: &std::path::Path,
lesson_name: &str,
keep: bool,
) -> std::io::Result<()> {
let pending = load_pending_lessons(session_dir);
let mut remaining = Vec::new();
let now = chrono::Utc::now().timestamp_millis();
for p in pending {
if p.lesson.name == lesson_name {
if keep {
let mem = crate::model::memory::Memory {
name: p.lesson.name.clone(),
description: p.lesson.content.chars().take(80).collect(),
content: p.lesson.content.clone(),
kind: "lesson".to_string(),
created_at: now,
updated_at: now,
outcome: None,
lifecycle: "active".to_string(),
scope: Some("project".to_string()),
before_snippet: None,
after_snippet: None,
provenances: vec![],
};
mem.write(memory_dir)?;
}
} else {
remaining.push(p);
}
}
save_pending_lessons(session_dir, &remaining)
}