desktop: Fix with AI on scan findings that need a code change (aiFix: true, AI_FIX_RULES). The host (src-tauri/src/ai_fix.rs) runs the owner's local Claude Code headless in the app folder with Read / Edit / Write / Grep / Glob only (Bash, web tools, sub-agents disallowed) and a prompt it builds from the finding's fields; it refuses while the folder has uncommitted files, streams progress, times out after 10 min with a heartbeat, kills the process group on Cancel or exit, and records the run's changed files so Undo restores exactly those; the window shows the diff with Keep / Undo and rescans. Without claude the button copies the prompt (Copy for AI). Only the claude binary, git and kill / taskkill are started there (tests/security.rs); tests/ai_fix.rs covers the prompt, dirty-tree refusal, undo scope, timeout and cancel
This commit is contained in:
parent
992f5bcc0e
commit
c95f2af8ff
24 changed files with 1023 additions and 10 deletions
|
|
@ -12,6 +12,7 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
|||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
use crate::accounts::{self, Account, AccountKind, Secret, SecretStore};
|
||||
use crate::ai_fix;
|
||||
use crate::profiles::{self, ProfileStore};
|
||||
use crate::registry;
|
||||
use crate::cache::{Cache, Entry};
|
||||
|
|
@ -256,7 +257,7 @@ pub async fn job_cancel(window: WebviewWindow, state: State<'_, AppState>, job:
|
|||
if !valid_job(&job) {
|
||||
return Err(Fail::host("bad job id"));
|
||||
}
|
||||
let mut hit = state.downloads.cancel(&job);
|
||||
let mut hit = state.downloads.cancel(&job) | ai_fix::cancel(&job);
|
||||
if let Some(r) = state.sidecar.current().await {
|
||||
hit |= r.cancel_job(&job) > 0;
|
||||
hit |= r.cancel_job(&format!("{job}:p")) > 0;
|
||||
|
|
@ -1489,3 +1490,66 @@ async fn open_folder(app: &AppHandle, dir: &Path) -> Res<()> {
|
|||
let _ = app.emit_to(policy::MAIN, "deep-link-folder", id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Fix with AI (src/ai_fix.rs): the owner's local Claude Code CLI on one scan finding ----
|
||||
|
||||
fn project_dir(state: &AppState, project_id: &str) -> Res<PathBuf> {
|
||||
let p = saved(state)?.projects.iter().find(|p| p.id == project_id).cloned().ok_or_else(|| Fail::host("unknown project"))?;
|
||||
Ok(PathBuf::from(p.path))
|
||||
}
|
||||
|
||||
/// Whether claude is installed, the prompt (also what "Copy for AI" copies) and what is dirty in git.
|
||||
#[tauri::command]
|
||||
pub async fn ai_fix_info(window: WebviewWindow, state: State<'_, AppState>, project_id: String, finding: ai_fix::AiFinding) -> Res<Value> {
|
||||
guard(&window)?;
|
||||
let prompt = ai_fix::build_prompt(&finding).map_err(Fail::host)?;
|
||||
let dir = project_dir(&state, &project_id)?;
|
||||
let claude = ai_fix::find_claude();
|
||||
let (git, dirty) = match tauri::async_runtime::spawn_blocking(move || ai_fix::status(&dir)).await.map_err(|e| Fail::host(e.to_string()))? {
|
||||
Ok((_, d)) => (Ok(()), d),
|
||||
Err(e) => (Err(e), Vec::new()),
|
||||
};
|
||||
Ok(json!({ "claude": claude.is_some(), "prompt": prompt, "git": git.is_ok(), "gitError": git.err(), "dirty": dirty }))
|
||||
}
|
||||
|
||||
/// Runs claude on one finding in the app folder (clean git tree only), streams "ai-fix" lines, returns the run's changes.
|
||||
#[tauri::command]
|
||||
pub async fn ai_fix_run(window: WebviewWindow, app: AppHandle, state: State<'_, AppState>, project_id: String, finding: ai_fix::AiFinding, job: String) -> Res<Value> {
|
||||
guard(&window)?;
|
||||
if !valid_job(&job) {
|
||||
return Err(Fail::host("bad job id"));
|
||||
}
|
||||
let prompt = ai_fix::build_prompt(&finding).map_err(Fail::host)?;
|
||||
let dir = project_dir(&state, &project_id)?;
|
||||
let claude = ai_fix::find_claude().ok_or_else(|| Fail::host("Claude Code (claude) was not found on PATH"))?;
|
||||
let handle = app.clone();
|
||||
let res = tauri::async_runtime::spawn_blocking(move || -> Result<Value, String> {
|
||||
let (top, dirty) = ai_fix::status(&dir)?;
|
||||
if !dirty.is_empty() {
|
||||
return Ok(json!({ "refused": "dirty", "dirty": dirty }));
|
||||
}
|
||||
let cmd = ai_fix::claude_command(&claude, &dir, &prompt)?;
|
||||
let emit_job = job.clone();
|
||||
let out = ai_fix::run(cmd, &job, ai_fix::TIMEOUT, ai_fix::HEARTBEAT, |line| {
|
||||
let _ = handle.emit_to(policy::MAIN, "ai-fix", json!({ "job": emit_job, "line": line }));
|
||||
})?;
|
||||
let (_, changes) = ai_fix::status(&dir)?;
|
||||
let diff = ai_fix::diff(&top, &changes);
|
||||
let run_id = job.clone();
|
||||
if let Ok(mut m) = ai_fix::runs().lock() {
|
||||
m.insert(run_id.clone(), (top, changes.clone()));
|
||||
}
|
||||
Ok(json!({ "runId": run_id, "outcome": out, "changes": changes, "diff": diff }))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| Fail::host(e.to_string()))?;
|
||||
res.map_err(Fail::host)
|
||||
}
|
||||
|
||||
/// Undo of one run: restores exactly the files that run changed (recorded by the host) and removes the ones it created.
|
||||
#[tauri::command]
|
||||
pub async fn ai_fix_undo(window: WebviewWindow, run_id: String) -> Res<Vec<String>> {
|
||||
guard(&window)?;
|
||||
let rec = ai_fix::runs().lock().ok().and_then(|mut m| m.remove(&run_id)).ok_or_else(|| Fail::host("no such AI fix run"))?;
|
||||
tauri::async_runtime::spawn_blocking(move || ai_fix::undo(&rec.0, &rec.1)).await.map_err(|e| Fail::host(e.to_string()))?.map_err(Fail::host)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue