462 lines
16 KiB
Rust
462 lines
16 KiB
Rust
//! MCP status tools — pipeline status, story triage, and acceptance criteria inspection.
|
|
use crate::http::context::AppContext;
|
|
use serde_json::{Value, json};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Parse all AC items from a story file, returning (text, is_checked) pairs.
|
|
fn parse_ac_items(contents: &str) -> Vec<(String, bool)> {
|
|
let mut in_ac_section = false;
|
|
let mut items = Vec::new();
|
|
|
|
for line in contents.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed == "## Acceptance Criteria" {
|
|
in_ac_section = true;
|
|
continue;
|
|
}
|
|
// Stop at the next heading
|
|
if in_ac_section && trimmed.starts_with("## ") {
|
|
break;
|
|
}
|
|
if in_ac_section {
|
|
if let Some(rest) = trimmed
|
|
.strip_prefix("- [x] ")
|
|
.or(trimmed.strip_prefix("- [X] "))
|
|
{
|
|
items.push((rest.to_string(), true));
|
|
} else if let Some(rest) = trimmed.strip_prefix("- [ ] ") {
|
|
items.push((rest.to_string(), false));
|
|
}
|
|
}
|
|
}
|
|
|
|
items
|
|
}
|
|
|
|
/// Find the most recent log file for any agent under `.huskies/logs/{story_id}/`.
|
|
fn find_most_recent_log(project_root: &Path, story_id: &str) -> Option<PathBuf> {
|
|
let dir = project_root.join(".huskies").join("logs").join(story_id);
|
|
|
|
if !dir.is_dir() {
|
|
return None;
|
|
}
|
|
|
|
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
|
|
|
|
let entries = fs::read_dir(&dir).ok()?;
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let name = match path.file_name().and_then(|n| n.to_str()) {
|
|
Some(n) => n.to_string(),
|
|
None => continue,
|
|
};
|
|
if !name.ends_with(".log") {
|
|
continue;
|
|
}
|
|
let modified = match entry.metadata().and_then(|m| m.modified()) {
|
|
Ok(t) => t,
|
|
Err(_) => continue,
|
|
};
|
|
if best.as_ref().is_none_or(|(_, t)| modified > *t) {
|
|
best = Some((path, modified));
|
|
}
|
|
}
|
|
|
|
best.map(|(p, _)| p)
|
|
}
|
|
|
|
/// Return the last N raw lines from a file.
|
|
fn last_n_lines(path: &Path, n: usize) -> Result<Vec<String>, String> {
|
|
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read log file: {e}"))?;
|
|
let lines: Vec<String> = content
|
|
.lines()
|
|
.rev()
|
|
.take(n)
|
|
.map(|l| l.to_string())
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
.rev()
|
|
.collect();
|
|
Ok(lines)
|
|
}
|
|
|
|
/// Run `git diff --stat {base}...HEAD` in the worktree.
|
|
async fn git_diff_stat(worktree: &Path, base: &str) -> Option<String> {
|
|
let dir = worktree.to_path_buf();
|
|
let base_arg = format!("{base}...HEAD");
|
|
tokio::task::spawn_blocking(move || {
|
|
let output = std::process::Command::new("git")
|
|
.args(["diff", "--stat", &base_arg])
|
|
.current_dir(&dir)
|
|
.output()
|
|
.ok()?;
|
|
if output.status.success() {
|
|
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
}
|
|
|
|
/// Return the last N commit messages on the current branch relative to base.
|
|
async fn git_log_commits(worktree: &Path, base: &str, count: usize) -> Option<Vec<String>> {
|
|
let dir = worktree.to_path_buf();
|
|
let range = format!("{base}..HEAD");
|
|
let count_str = count.to_string();
|
|
tokio::task::spawn_blocking(move || {
|
|
let output = std::process::Command::new("git")
|
|
.args(["log", &range, "--oneline", &format!("-{count_str}")])
|
|
.current_dir(&dir)
|
|
.output()
|
|
.ok()?;
|
|
if output.status.success() {
|
|
let lines: Vec<String> = String::from_utf8(output.stdout)
|
|
.ok()?
|
|
.lines()
|
|
.filter(|l| !l.is_empty())
|
|
.map(|l| l.to_string())
|
|
.collect();
|
|
Some(lines)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
}
|
|
|
|
/// Return the active branch name for the given directory.
|
|
async fn git_branch(dir: &Path) -> Option<String> {
|
|
let dir = dir.to_path_buf();
|
|
tokio::task::spawn_blocking(move || {
|
|
let output = std::process::Command::new("git")
|
|
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
.current_dir(&dir)
|
|
.output()
|
|
.ok()?;
|
|
if output.status.success() {
|
|
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
}
|
|
|
|
/// MCP tool: show full details for a work item in any pipeline stage.
|
|
pub(super) async fn tool_show(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
|
let raw_id = args
|
|
.get("story_id")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("Missing required argument: story_id")?;
|
|
|
|
let root = ctx.state.get_project_root()?;
|
|
|
|
// Resolve numeric prefix (e.g. "5") to full story_id via find_story_by_number.
|
|
let (story_id, contents) = if raw_id.chars().all(|c| c.is_ascii_digit()) {
|
|
let (sid, _, _, content) = crate::chat::lookup::find_story_by_number(&root, raw_id)
|
|
.ok_or_else(|| {
|
|
format!("No work item with number '{raw_id}' found in any pipeline stage.")
|
|
})?;
|
|
let body = content.ok_or_else(|| {
|
|
format!("Work item '{sid}' found in pipeline but its content is unavailable.")
|
|
})?;
|
|
(sid, body)
|
|
} else {
|
|
let body = crate::db::read_content(crate::db::ContentKey::Story(raw_id))
|
|
.ok_or_else(|| format!("Work item '{raw_id}' not found in any pipeline stage."))?;
|
|
(raw_id.to_string(), body)
|
|
};
|
|
|
|
let story_id = story_id.as_str();
|
|
|
|
let typed_item = crate::pipeline_state::read_typed(story_id)
|
|
.map_err(|e| format!("Failed to read pipeline state: {e}"))?;
|
|
|
|
// --- Metadata (story 929: CRDT-first, yaml_residue marks gaps) ---
|
|
let mut front_matter = serde_json::Map::new();
|
|
if let Some(view) = crate::crdt_state::read_item(story_id) {
|
|
front_matter.insert("name".to_string(), json!(view.name()));
|
|
front_matter.insert("stage".to_string(), json!(view.stage().dir_name()));
|
|
if let Some(agent) = view.agent() {
|
|
front_matter.insert("agent".to_string(), json!(agent));
|
|
}
|
|
if let Some(qa) = view.qa_mode() {
|
|
front_matter.insert("qa".to_string(), json!(qa.as_str()));
|
|
}
|
|
let rc = view.retry_count();
|
|
if rc > 0 {
|
|
front_matter.insert("retry_count".to_string(), json!(rc));
|
|
}
|
|
let deps = view.depends_on();
|
|
if !deps.is_empty() {
|
|
front_matter.insert("depends_on".to_string(), json!(deps));
|
|
}
|
|
let origin_str = view.origin().unwrap_or("unknown");
|
|
front_matter.insert("origin".to_string(), json!(origin_str));
|
|
let stage_claim = typed_item.as_ref().and_then(|t| match &t.stage {
|
|
crate::pipeline_state::Stage::Coding { claim, .. } => claim.as_ref(),
|
|
crate::pipeline_state::Stage::Merge { claim, .. } => claim.as_ref(),
|
|
_ => None,
|
|
});
|
|
if let Some(claim) = stage_claim {
|
|
front_matter.insert("claimed_by".to_string(), json!(claim.agent.0.as_str()));
|
|
front_matter.insert(
|
|
"claimed_at".to_string(),
|
|
json!(claim.claimed_at.timestamp() as f64),
|
|
);
|
|
}
|
|
}
|
|
|
|
if let Some(job) = crate::crdt_state::read_merge_job(story_id)
|
|
&& let Some(mf) = job.error
|
|
{
|
|
front_matter.insert("merge_failure".to_string(), json!(mf));
|
|
}
|
|
|
|
// --- AC checklist ---
|
|
let ac_items: Vec<Value> = parse_ac_items(&contents)
|
|
.into_iter()
|
|
.map(|(text, checked)| json!({ "text": text, "checked": checked }))
|
|
.collect();
|
|
|
|
// --- Worktree ---
|
|
let worktree_path = root.join(".huskies").join("worktrees").join(story_id);
|
|
let (_, worktree_info) = if worktree_path.is_dir() {
|
|
let branch = git_branch(&worktree_path).await;
|
|
(
|
|
branch.clone(),
|
|
Some(json!({
|
|
"path": worktree_path.to_string_lossy(),
|
|
"branch": branch,
|
|
})),
|
|
)
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
// --- Git diff stat ---
|
|
let diff_stat = if worktree_path.is_dir() {
|
|
git_diff_stat(&worktree_path, "master").await
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// --- Last 5 commits ---
|
|
let commits = if worktree_path.is_dir() {
|
|
git_log_commits(&worktree_path, "master", 5).await
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// --- Most recent agent log (last 20 lines) ---
|
|
let agent_log = match find_most_recent_log(&root, story_id) {
|
|
Some(log_path) => {
|
|
let filename = log_path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
match last_n_lines(&log_path, 20) {
|
|
Ok(lines) => Some(json!({
|
|
"file": filename,
|
|
"lines": lines,
|
|
})),
|
|
Err(_) => None,
|
|
}
|
|
}
|
|
None => None,
|
|
};
|
|
|
|
let result = json!({
|
|
"story_id": story_id,
|
|
"front_matter": front_matter,
|
|
"acceptance_criteria": ac_items,
|
|
"worktree": worktree_info,
|
|
"git_diff_stat": diff_stat,
|
|
"commits": commits,
|
|
"agent_log": agent_log,
|
|
});
|
|
|
|
serde_json::to_string_pretty(&result).map_err(|e| format!("Serialization error: {e}"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn parse_ac_items_returns_checked_and_unchecked() {
|
|
let content = "---\nname: test\n---\n\n## Acceptance Criteria\n\n- [ ] item one\n- [x] item two\n- [X] item three\n\n## Out of Scope\n\n- [ ] not an ac\n";
|
|
let items = parse_ac_items(content);
|
|
assert_eq!(items.len(), 3);
|
|
assert_eq!(items[0], ("item one".to_string(), false));
|
|
assert_eq!(items[1], ("item two".to_string(), true));
|
|
assert_eq!(items[2], ("item three".to_string(), true));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_ac_items_empty_when_no_section() {
|
|
let content = "---\nname: test\n---\n\nNo AC section here.\n";
|
|
let items = parse_ac_items(content);
|
|
assert!(items.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn find_most_recent_log_returns_none_for_missing_dir() {
|
|
let tmp = tempdir().unwrap();
|
|
let result = find_most_recent_log(tmp.path(), "nonexistent_story");
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn find_most_recent_log_returns_newest_file() {
|
|
let tmp = tempdir().unwrap();
|
|
let log_dir = tmp
|
|
.path()
|
|
.join(".huskies")
|
|
.join("logs")
|
|
.join("42_story_foo");
|
|
fs::create_dir_all(&log_dir).unwrap();
|
|
|
|
let old_path = log_dir.join("coder-1-sess-old.log");
|
|
fs::write(&old_path, "old content").unwrap();
|
|
|
|
// Ensure different mtime
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
|
|
let new_path = log_dir.join("coder-1-sess-new.log");
|
|
fs::write(&new_path, "new content").unwrap();
|
|
|
|
let result = find_most_recent_log(tmp.path(), "42_story_foo").unwrap();
|
|
assert!(
|
|
result.to_string_lossy().contains("sess-new"),
|
|
"Expected newest file, got: {}",
|
|
result.display()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tool_show_returns_error_for_missing_story() {
|
|
let tmp = tempdir().unwrap();
|
|
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
|
let result = tool_show(&json!({"story_id": "999_story_nonexistent"}), &ctx).await;
|
|
assert!(result.is_err());
|
|
assert!(result.unwrap_err().contains("not found"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tool_show_returns_retry_count_and_depends_on() {
|
|
let tmp = tempdir().unwrap();
|
|
|
|
crate::crdt_state::init_for_test();
|
|
crate::db::ensure_content_store();
|
|
let story_content = "# Story\n\n## Acceptance Criteria\n\n- [ ] Do the thing\n";
|
|
crate::db::write_item_with_content(
|
|
"9887_story_blocked_test",
|
|
"2_current",
|
|
story_content,
|
|
crate::db::ItemMeta::named("Blocked Story"),
|
|
);
|
|
crate::crdt_state::set_retry_count("9887_story_blocked_test", 3);
|
|
crate::crdt_state::set_depends_on("9887_story_blocked_test", &[100, 200]);
|
|
|
|
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
|
let result = tool_show(&json!({"story_id": "9887_story_blocked_test"}), &ctx)
|
|
.await
|
|
.unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
|
|
|
assert_eq!(parsed["front_matter"]["retry_count"], 3);
|
|
let depends_on = parsed["front_matter"]["depends_on"].as_array().unwrap();
|
|
assert_eq!(depends_on.len(), 2);
|
|
assert_eq!(depends_on[0], 100);
|
|
assert_eq!(depends_on[1], 200);
|
|
}
|
|
|
|
/// Story 1222 regression: `show` must return full content for a story
|
|
/// after it reaches Done and again after it reaches Archived — the
|
|
/// terminal-stage content purge must never evict the story body, only
|
|
/// its ephemeral bookkeeping keys.
|
|
#[tokio::test]
|
|
async fn tool_show_returns_content_after_done_and_after_archived() {
|
|
let tmp = tempdir().unwrap();
|
|
crate::crdt_state::init_for_test();
|
|
crate::db::ensure_content_store();
|
|
|
|
let story_id = "1222_story_done_archived_test";
|
|
let story_content = "# Story\n\n## Acceptance Criteria\n\n- [ ] Ship it\n";
|
|
crate::db::write_item_with_content(
|
|
story_id,
|
|
"5_done",
|
|
story_content,
|
|
crate::db::ItemMeta::named("Done Archived Test"),
|
|
);
|
|
|
|
// Simulate the terminal-transition purge that fires when a story
|
|
// reaches Done (story 996 GC subscriber / sweep).
|
|
crate::db::gc::purge_ephemeral_content_keys_for_story(story_id);
|
|
|
|
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
|
let result = tool_show(&json!({"story_id": story_id}), &ctx)
|
|
.await
|
|
.expect("show must succeed for a Done story, not return content unavailable");
|
|
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
|
assert_eq!(parsed["story_id"], story_id);
|
|
assert_eq!(parsed["front_matter"]["name"], "Done Archived Test");
|
|
|
|
// Move to Archived and purge again — content must still be readable.
|
|
crate::db::move_item_stage(story_id, "6_archived", None);
|
|
crate::db::gc::purge_ephemeral_content_keys_for_story(story_id);
|
|
|
|
let result = tool_show(&json!({"story_id": story_id}), &ctx)
|
|
.await
|
|
.expect("show must succeed for an Archived story, not return content unavailable");
|
|
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
|
assert_eq!(parsed["story_id"], story_id);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tool_show_returns_story_data() {
|
|
let tmp = tempdir().unwrap();
|
|
|
|
crate::db::ensure_content_store();
|
|
let story_content = "# Story\n\n## Acceptance Criteria\n\n- [ ] First criterion\n- [x] Second criterion\n\n## Out of Scope\n\n- nothing\n";
|
|
crate::db::write_item_with_content(
|
|
"9886_story_status_test",
|
|
"2_current",
|
|
story_content,
|
|
crate::db::ItemMeta::named("My Test Story"),
|
|
);
|
|
crate::crdt_state::set_agent(
|
|
"9886_story_status_test",
|
|
Some(crate::config::AgentName::Coder1),
|
|
);
|
|
|
|
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
|
let result = tool_show(&json!({"story_id": "9886_story_status_test"}), &ctx)
|
|
.await
|
|
.unwrap();
|
|
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
|
|
|
assert_eq!(parsed["story_id"], "9886_story_status_test");
|
|
assert_eq!(parsed["front_matter"]["name"], "My Test Story");
|
|
assert_eq!(parsed["front_matter"]["agent"], "coder-1");
|
|
|
|
let ac = parsed["acceptance_criteria"].as_array().unwrap();
|
|
assert_eq!(ac.len(), 2);
|
|
assert_eq!(ac[0]["text"], "First criterion");
|
|
assert_eq!(ac[0]["checked"], false);
|
|
assert_eq!(ac[1]["text"], "Second criterion");
|
|
assert_eq!(ac[1]["checked"], true);
|
|
}
|
|
}
|