huskies: merge 544_story_add_run_build_and_run_lint_mcp_tools_backed_by_script_build_and_script_lint

This commit is contained in:
dave
2026-04-12 13:16:45 +00:00
parent cec62dad1c
commit a344cfadee
2 changed files with 185 additions and 1 deletions
+33 -1
View File
@@ -1161,6 +1161,34 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
"required": [] "required": []
} }
}, },
{
"name": "run_build",
"description": "Run the project's build script (script/build) in the given worktree and return the result as truncated JSON with passed, exit_code, and output fields.",
"inputSchema": {
"type": "object",
"properties": {
"worktree_path": {
"type": "string",
"description": "Optional absolute path to a worktree to run the build in. Must be inside .huskies/worktrees/. Defaults to the project root."
}
},
"required": []
}
},
{
"name": "run_lint",
"description": "Run the project's lint script (script/lint) in the given worktree and return the result as truncated JSON with passed, exit_code, and output fields.",
"inputSchema": {
"type": "object",
"properties": {
"worktree_path": {
"type": "string",
"description": "Optional absolute path to a worktree to run lint in. Must be inside .huskies/worktrees/. Defaults to the project root."
}
},
"required": []
}
},
{ {
"name": "git_status", "name": "git_status",
"description": "Return the working tree status of an agent's worktree (staged, unstaged, and untracked files). The worktree_path must be inside .huskies/worktrees/. Push and remote operations are not available.", "description": "Return the working tree status of an agent's worktree (staged, unstaged, and untracked files). The worktree_path must be inside .huskies/worktrees/. Push and remote operations are not available.",
@@ -1418,6 +1446,8 @@ async fn handle_tools_call(
"run_command" => shell_tools::tool_run_command(&args, ctx).await, "run_command" => shell_tools::tool_run_command(&args, ctx).await,
"run_tests" => shell_tools::tool_run_tests(&args, ctx).await, "run_tests" => shell_tools::tool_run_tests(&args, ctx).await,
"get_test_result" => shell_tools::tool_get_test_result(&args, ctx).await, "get_test_result" => shell_tools::tool_get_test_result(&args, ctx).await,
"run_build" => shell_tools::tool_run_build(&args, ctx).await,
"run_lint" => shell_tools::tool_run_lint(&args, ctx).await,
// Git operations // Git operations
"git_status" => git_tools::tool_git_status(&args, ctx).await, "git_status" => git_tools::tool_git_status(&args, ctx).await,
"git_diff" => git_tools::tool_git_diff(&args, ctx).await, "git_diff" => git_tools::tool_git_diff(&args, ctx).await,
@@ -1543,6 +1573,8 @@ mod tests {
assert!(names.contains(&"run_command")); assert!(names.contains(&"run_command"));
assert!(names.contains(&"run_tests")); assert!(names.contains(&"run_tests"));
assert!(names.contains(&"get_test_result")); assert!(names.contains(&"get_test_result"));
assert!(names.contains(&"run_build"));
assert!(names.contains(&"run_lint"));
assert!(names.contains(&"git_status")); assert!(names.contains(&"git_status"));
assert!(names.contains(&"git_diff")); assert!(names.contains(&"git_diff"));
assert!(names.contains(&"git_add")); assert!(names.contains(&"git_add"));
@@ -1551,7 +1583,7 @@ mod tests {
assert!(names.contains(&"status")); assert!(names.contains(&"status"));
assert!(names.contains(&"loc_file")); assert!(names.contains(&"loc_file"));
assert!(names.contains(&"dump_crdt")); assert!(names.contains(&"dump_crdt"));
assert_eq!(tools.len(), 60); assert_eq!(tools.len(), 62);
} }
#[test] #[test]
+152
View File
@@ -639,6 +639,69 @@ fn collect_child_result(
} }
} }
/// Shared implementation for run_build and run_lint: runs a named script
/// (`script/<name>`) in the working directory, captures output, and returns
/// truncated JSON with `passed`, `exit_code`, and `output`.
async fn run_script_tool(
script_name: &str,
args: &Value,
ctx: &AppContext,
) -> Result<String, String> {
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let working_dir = match args.get("worktree_path").and_then(|v| v.as_str()) {
Some(wt) => validate_working_dir(wt, ctx)?,
None => project_root
.canonicalize()
.map_err(|e| format!("Cannot canonicalize project root: {e}"))?,
};
let script_path = working_dir.join("script").join(script_name);
if !script_path.exists() {
return Err(format!(
"{script_name} script not found: {}",
script_path.display()
));
}
let result = tokio::task::spawn_blocking({
let script = script_path.clone();
let dir = working_dir.clone();
move || {
std::process::Command::new("bash")
.arg(&script)
.current_dir(&dir)
.output()
}
})
.await
.map_err(|e| format!("Task join error: {e}"))?
.map_err(|e| format!("Failed to spawn {script_name} script: {e}"))?;
let stdout = String::from_utf8_lossy(&result.stdout);
let stderr = String::from_utf8_lossy(&result.stderr);
let combined = format!("{stdout}{stderr}");
let output = truncate_output(&combined, MAX_OUTPUT_LINES);
let exit_code = result.status.code().unwrap_or(-1);
serde_json::to_string_pretty(&json!({
"passed": result.status.success(),
"exit_code": exit_code,
"output": output,
}))
.map_err(|e| format!("Serialization error: {e}"))
}
/// Run the project's build script (`script/build`) and return the result.
pub(super) async fn tool_run_build(args: &Value, ctx: &AppContext) -> Result<String, String> {
run_script_tool("build", args, ctx).await
}
/// Run the project's lint script (`script/lint`) and return the result.
pub(super) async fn tool_run_lint(args: &Value, ctx: &AppContext) -> Result<String, String> {
run_script_tool("lint", args, ctx).await
}
/// Format a `TestJobResult` as the JSON string returned to the agent. /// Format a `TestJobResult` as the JSON string returned to the agent.
fn format_test_result( fn format_test_result(
result: &crate::http::context::TestJobResult, result: &crate::http::context::TestJobResult,
@@ -1019,6 +1082,95 @@ mod tests {
); );
} }
// ── tool_run_build / tool_run_lint ────────────────────────────────
#[tokio::test]
async fn tool_run_build_missing_script_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_run_build(&json!({}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[tokio::test]
async fn tool_run_build_passes_when_script_exits_zero() {
let tmp = tempfile::tempdir().unwrap();
let script_dir = tmp.path().join("script");
std::fs::create_dir_all(&script_dir).unwrap();
let script_path = script_dir.join("build");
std::fs::write(&script_path, "#!/usr/bin/env bash\nexit 0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let ctx = test_ctx(tmp.path());
let result = tool_run_build(&json!({}), &ctx).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["passed"], true);
assert_eq!(parsed["exit_code"], 0);
}
#[tokio::test]
async fn tool_run_build_worktree_path_must_be_inside_worktrees() {
let tmp = tempfile::tempdir().unwrap();
let wt_dir = tmp.path().join(".huskies").join("worktrees");
std::fs::create_dir_all(&wt_dir).unwrap();
let ctx = test_ctx(tmp.path());
let result =
tool_run_build(&json!({"worktree_path": tmp.path().to_str().unwrap()}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("worktrees"));
}
#[tokio::test]
async fn tool_run_lint_missing_script_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_run_lint(&json!({}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[tokio::test]
async fn tool_run_lint_passes_when_script_exits_zero() {
let tmp = tempfile::tempdir().unwrap();
let script_dir = tmp.path().join("script");
std::fs::create_dir_all(&script_dir).unwrap();
let script_path = script_dir.join("lint");
std::fs::write(&script_path, "#!/usr/bin/env bash\nexit 0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let ctx = test_ctx(tmp.path());
let result = tool_run_lint(&json!({}), &ctx).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["passed"], true);
assert_eq!(parsed["exit_code"], 0);
}
#[tokio::test]
async fn tool_run_lint_fails_when_script_exits_nonzero() {
let tmp = tempfile::tempdir().unwrap();
let script_dir = tmp.path().join("script");
std::fs::create_dir_all(&script_dir).unwrap();
let script_path = script_dir.join("lint");
std::fs::write(&script_path, "#!/usr/bin/env bash\nexit 1\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let ctx = test_ctx(tmp.path());
let result = tool_run_lint(&json!({}), &ctx).await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["passed"], false);
assert_eq!(parsed["exit_code"], 1);
}
// ── truncate_output ─────────────────────────────────────────────── // ── truncate_output ───────────────────────────────────────────────
#[test] #[test]