diff --git a/src-tauri/src/services/orchestrator.rs b/src-tauri/src/services/orchestrator.rs index a73b982..84495bd 100644 --- a/src-tauri/src/services/orchestrator.rs +++ b/src-tauri/src/services/orchestrator.rs @@ -1,4 +1,4 @@ -use crate::models::agent::{Agent, AgentRole}; +use crate::models::agent::{Agent, AgentRole, AgentTool}; use crate::models::graylog::GraylogCredentials; use crate::models::module::{ ProjectModule, MODULE_GRAYLOG_AUTO_RESOLVE, MODULE_TULEAP_AUTO_RESOLVE, @@ -11,6 +11,7 @@ use crate::services::process_registry::ProcessRegistry; use crate::services::{cli_process, notifier, worktree_manager}; use rusqlite::Connection; use std::mem; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter}; @@ -153,6 +154,64 @@ pub fn parse_verdict(report: &str) -> Verdict { Verdict::FixNeeded } +fn resolve_path_from_working_dir(working_dir: &Path, path: &str) -> Option { + let trimmed = path.trim(); + if trimmed.is_empty() { + return None; + } + + let candidate = PathBuf::from(trimmed); + if candidate.is_absolute() { + Some(candidate) + } else { + Some(working_dir.join(candidate)) + } +} + +fn codex_additional_writable_dirs(working_dir: &str) -> Vec { + let output = std::process::Command::new("git") + .args(["rev-parse", "--git-dir", "--git-common-dir"]) + .current_dir(working_dir) + .output(); + + let output = match output { + Ok(value) if value.status.success() => value, + _ => return Vec::new(), + }; + + let working_dir_path = Path::new(working_dir); + let normalized_working_dir = + std::fs::canonicalize(working_dir_path).unwrap_or_else(|_| working_dir_path.to_path_buf()); + + let mut dirs = Vec::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + let Some(path) = resolve_path_from_working_dir(working_dir_path, line) else { + continue; + }; + let normalized = std::fs::canonicalize(&path).unwrap_or(path); + if normalized.starts_with(&normalized_working_dir) { + continue; + } + let normalized = normalized.to_string_lossy().to_string(); + if !dirs.contains(&normalized) { + dirs.push(normalized); + } + } + + dirs +} + +fn build_agent_cli_args(agent: &Agent, working_dir: &str) -> Vec { + let mut args = agent.tool.to_non_interactive_args(); + if agent.tool == AgentTool::Codex { + for dir in codex_additional_writable_dirs(working_dir) { + args.push("--add-dir".to_string()); + args.push(dir); + } + } + args +} + fn developer_report_indicates_permission_block(report: &str) -> bool { let lowered = report.to_lowercase(); [ @@ -184,7 +243,9 @@ fn evaluate_developer_completion( } if !has_diff { - return Err("Developer run completed without any diff against the base branch.".to_string()); + return Err( + "Developer run completed without any diff against the base branch.".to_string(), + ); } Ok(()) @@ -199,19 +260,18 @@ fn validate_developer_completion( return evaluate_developer_completion(developer_report, 0, false); } - let commits = worktree_manager::list_commits( - &project.path, - &project.base_branch, - branch_name, - ) - .map_err(|e| format!("Failed to verify developer commits: {}", e))?; + let commits = worktree_manager::list_commits(&project.path, &project.base_branch, branch_name) + .map_err(|e| format!("Failed to verify developer commits: {}", e))?; let diff = worktree_manager::get_diff(&project.path, &project.base_branch, branch_name) .map_err(|e| format!("Failed to verify developer diff: {}", e))?; evaluate_developer_completion(developer_report, commits.len(), !diff.trim().is_empty()) } -fn should_flush_progress_buffer(buffer: &str, elapsed_since_last_emit: std::time::Duration) -> bool { +fn should_flush_progress_buffer( + buffer: &str, + elapsed_since_last_emit: std::time::Duration, +) -> bool { if buffer.is_empty() { return false; } @@ -424,24 +484,18 @@ async fn process_ticket( None => None, }; - let enabled = ProjectModule::is_enabled( - &conn, - &project.id, - MODULE_TULEAP_AUTO_RESOLVE, - ) - .map_err(|e| format!("module lookup failed: {}", e))?; + let enabled = + ProjectModule::is_enabled(&conn, &project.id, MODULE_TULEAP_AUTO_RESOLVE) + .map_err(|e| format!("module lookup failed: {}", e))?; if enabled { selected = Some((ticket, project, tracker)); break; } } "graylog" => { - let enabled = ProjectModule::is_enabled( - &conn, - &project.id, - MODULE_GRAYLOG_AUTO_RESOLVE, - ) - .map_err(|e| format!("module lookup failed: {}", e))?; + let enabled = + ProjectModule::is_enabled(&conn, &project.id, MODULE_GRAYLOG_AUTO_RESOLVE) + .map_err(|e| format!("module lookup failed: {}", e))?; if enabled { selected = Some((ticket, project, None)); break; @@ -640,7 +694,7 @@ async fn process_ticket( build_analyst_prompt(&ticket, &project), &analyst_agent.custom_prompt, ); - let analyst_args = analyst_agent.tool.to_non_interactive_args(); + let analyst_args = build_agent_cli_args(&analyst_agent, &project.path); let analyst_result = run_cli_command( analyst_agent.tool.to_command(), &analyst_args, @@ -753,7 +807,7 @@ async fn process_ticket( build_developer_prompt(&ticket, &project, &analyst_report, &wt_path), &developer_agent.custom_prompt, ); - let developer_args = developer_agent.tool.to_non_interactive_args(); + let developer_args = build_agent_cli_args(&developer_agent, &wt_path); let developer_result = run_cli_command( developer_agent.tool.to_command(), &developer_args, @@ -998,20 +1052,16 @@ mod tests { #[test] fn test_should_flush_progress_buffer_when_elapsed_interval_exceeded() { - let should_flush = should_flush_progress_buffer( - "some progress", - std::time::Duration::from_millis(300), - ); + let should_flush = + should_flush_progress_buffer("some progress", std::time::Duration::from_millis(300)); assert!(should_flush); } #[test] fn test_should_flush_progress_buffer_when_buffer_reaches_size_limit() { let payload = "x".repeat(2048); - let should_flush = should_flush_progress_buffer( - &payload, - std::time::Duration::from_millis(10), - ); + let should_flush = + should_flush_progress_buffer(&payload, std::time::Duration::from_millis(10)); assert!(should_flush); } @@ -1029,11 +1079,7 @@ mod tests { true, ); assert!(result.is_err()); - assert!( - result - .unwrap_err() - .contains("permission") - ); + assert!(result.unwrap_err().contains("permission")); } #[test] @@ -1081,8 +1127,7 @@ mod tests { .expect("git config name"); assert!(set_name.status.success()); - std::fs::write(dir.path().join("README.md"), "# Orchai test repo") - .expect("write readme"); + std::fs::write(dir.path().join("README.md"), "# Orchai test repo").expect("write readme"); let add = Command::new("git") .args(["add", "."]) @@ -1122,14 +1167,86 @@ mod tests { } } + fn collect_add_dirs(args: &[String]) -> Vec { + let mut dirs = Vec::new(); + let mut index = 0usize; + while index + 1 < args.len() { + if args[index] == "--add-dir" { + dirs.push(args[index + 1].clone()); + index += 2; + continue; + } + index += 1; + } + dirs + } + + fn build_test_agent(tool: crate::models::agent::AgentTool) -> Agent { + Agent { + id: "a-test".into(), + name: "Agent test".into(), + role: AgentRole::Developer, + tool, + custom_prompt: String::new(), + is_default: false, + created_at: "2026-01-01T00:00:00Z".into(), + updated_at: "2026-01-01T00:00:00Z".into(), + } + } + + #[test] + fn test_build_agent_cli_args_adds_git_metadata_dirs_for_codex_worktree() { + let repo = setup_test_repo(); + let repo_path = repo.path().to_str().expect("utf8 path"); + let base_branch = current_branch(repo_path); + let (worktree_path, _branch_name) = + worktree_manager::create_worktree(repo_path, &base_branch, 202) + .expect("worktree creation should succeed"); + + let agent = build_test_agent(crate::models::agent::AgentTool::Codex); + let args = build_agent_cli_args(&agent, &worktree_path); + let add_dirs = collect_add_dirs(&args); + + let rev_parse = Command::new("git") + .args(["rev-parse", "--git-dir", "--git-common-dir"]) + .current_dir(&worktree_path) + .output() + .expect("git rev-parse should succeed"); + assert!(rev_parse.status.success(), "git rev-parse should succeed"); + + let expected_dirs: Vec = String::from_utf8_lossy(&rev_parse.stdout) + .lines() + .map(|line| { + let path = Path::new(line); + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + Path::new(&worktree_path).join(path) + }; + std::fs::canonicalize(&absolute) + .unwrap_or(absolute) + .to_string_lossy() + .to_string() + }) + .collect(); + + for expected in expected_dirs { + assert!( + add_dirs.contains(&expected), + "Expected --add-dir to contain '{}', got {:?}", + expected, + add_dirs + ); + } + } + #[test] fn test_validate_developer_completion_rejects_branch_without_commit() { let repo = setup_test_repo(); let repo_path = repo.path().to_str().expect("utf8 path"); let base_branch = current_branch(repo_path); let (_wt_path, branch_name) = - worktree_manager::create_worktree(repo_path, &base_branch, 100) - .expect("worktree"); + worktree_manager::create_worktree(repo_path, &base_branch, 100).expect("worktree"); let project = build_project(repo_path, &base_branch); let result = validate_developer_completion(&project, &branch_name, "Correction appliquee."); @@ -1143,8 +1260,7 @@ mod tests { let repo_path = repo.path().to_str().expect("utf8 path"); let base_branch = current_branch(repo_path); let (wt_path, branch_name) = - worktree_manager::create_worktree(repo_path, &base_branch, 101) - .expect("worktree"); + worktree_manager::create_worktree(repo_path, &base_branch, 101).expect("worktree"); let fix_file = Path::new(&wt_path).join("fix.txt"); std::fs::write(&fix_file, "critical fix").expect("write fix");