Revert "refactor: split top-5 largest files into mod.rs + tests.rs"

This reverts commit 65a3767a7a.
This commit is contained in:
dave
2026-04-26 20:15:58 +00:00
parent 65a3767a7a
commit 795b172bba
13 changed files with 8918 additions and 8904 deletions
-725
View File
@@ -1,725 +0,0 @@
//! MCP story tools — create, update, move, and manage stories, bugs, and refactors via MCP.
//!
//! This file is a thin adapter: it deserialises MCP payloads, delegates to
//! `crate::service::story` and `crate::http::workflow` for business logic,
//! and serialises responses.
use crate::agents::{
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
};
use crate::http::context::AppContext;
use crate::http::workflow::{
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
create_spike_file, create_story_file, edit_criterion_in_file, list_bug_files,
list_refactor_files, load_pipeline_state, load_upcoming_stories, remove_criterion_from_file,
update_story_in_file, validate_story_dirs,
};
use crate::io::story_metadata::{
check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos,
};
use crate::service::story::parse_test_cases;
use crate::slog_warn;
#[allow(unused_imports)]
use crate::workflow::{TestCaseResult, TestStatus, evaluate_acceptance_with_coverage};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::fs;
pub(super) fn tool_create_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: name")?;
let user_story = args.get("user_story").and_then(|v| v.as_str());
let description = args.get("description").and_then(|v| v.as_str());
let acceptance_criteria: Option<Vec<String>> = args
.get("acceptance_criteria")
.and_then(|v| serde_json::from_value(v.clone()).ok());
let depends_on: Option<Vec<u32>> = args
.get("depends_on")
.and_then(|v| serde_json::from_value(v.clone()).ok());
// Spike 61: write the file only — the filesystem watcher detects the new
// .md file in work/1_backlog/ and auto-commits with a deterministic message.
let commit = false;
let root = ctx.state.get_project_root()?;
let story_id = create_story_file(
&root,
name,
user_story,
description,
acceptance_criteria.as_deref(),
depends_on.as_deref(),
commit,
)?;
// Bug 503: warn at creation time if any depends_on points at an already-archived story.
// Archived = satisfied semantics: the dep will resolve immediately on the next promotion
// tick, which is surprising if the archived story was abandoned rather than cleanly done.
let archived_deps = depends_on
.as_deref()
.map(|deps| check_archived_deps_from_list(&root, deps))
.unwrap_or_default();
if !archived_deps.is_empty() {
slog_warn!(
"[create-story] Story '{story_id}' depends_on {archived_deps:?} which \
are already in 6_archived. The dep will be treated as satisfied on the \
next promotion tick. If these deps were abandoned (not cleanly completed), \
consider removing the depends_on or keeping the story in backlog manually."
);
return Ok(format!(
"Created story: {story_id}\n\n\
WARNING: depends_on {archived_deps:?} point at stories already in \
6_archived. These deps are treated as satisfied (archived = satisfied \
semantics), so this story may be auto-promoted from backlog immediately. \
If the archived deps were abandoned rather than completed, remove the \
depends_on or move the story back to backlog manually after promotion."
));
}
Ok(format!("Created story: {story_id}"))
}
/// Purge a story from the in-memory CRDT by writing a tombstone op (story 521).
///
/// This is the eviction primitive for the four-state-machine drift problem
/// we hit on 2026-04-09 — when a story gets stuck in the running server's
/// in-memory CRDT and can't be cleared by sqlite deletes alone (because the
/// in-memory state outlives any pipeline_items / crdt_ops manipulation),
/// this tool writes a proper CRDT delete op via `crdt_state::evict_item`.
///
/// The tombstone op:
/// - Marks the in-memory CRDT item as `is_deleted = true` immediately
/// (so subsequent `read_all_items` / `read_item` calls skip it)
/// - Is persisted to `crdt_ops` so the eviction survives a server restart
/// - Drops the in-memory `CONTENT_STORE` entry for the story
///
/// This tool does NOT touch: running agents, worktrees, the `pipeline_items`
/// shadow table, `timers.json`, or filesystem shadows. Compose with
/// `stop_agent`, `remove_worktree`, etc. as needed for a full purge — or
/// see story 514 (delete_story full cleanup) for a future "do it all" tool.
pub(super) fn tool_purge_story(args: &Value, _ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
crate::crdt_state::evict_item(story_id)?;
Ok(format!(
"Evicted '{story_id}' from in-memory CRDT (tombstone op persisted to crdt_ops; CONTENT_STORE entry dropped)."
))
}
pub(super) fn tool_validate_stories(ctx: &AppContext) -> Result<String, String> {
let root = ctx.state.get_project_root()?;
let results = validate_story_dirs(&root)?;
serde_json::to_string_pretty(&json!(
results
.iter()
.map(|r| json!({
"story_id": r.story_id,
"valid": r.valid,
"error": r.error,
}))
.collect::<Vec<_>>()
))
.map_err(|e| format!("Serialization error: {e}"))
}
pub(super) fn tool_list_upcoming(ctx: &AppContext) -> Result<String, String> {
let stories = load_upcoming_stories(ctx)?;
serde_json::to_string_pretty(&json!(
stories
.iter()
.map(|s| json!({
"story_id": s.story_id,
"name": s.name,
"error": s.error,
}))
.collect::<Vec<_>>()
))
.map_err(|e| format!("Serialization error: {e}"))
}
pub(super) fn tool_get_pipeline_status(ctx: &AppContext) -> Result<String, String> {
let state = load_pipeline_state(ctx)?;
fn map_items(items: &[crate::http::workflow::UpcomingStory], stage: &str) -> Vec<Value> {
items
.iter()
.map(|s| {
let mut item = json!({
"story_id": s.story_id,
"name": s.name,
"stage": stage,
"agent": s.agent.as_ref().map(|a| json!({
"agent_name": a.agent_name,
"model": a.model,
"status": a.status,
})),
});
// Include blocked/retry_count when present so callers can
// identify stories stuck in the pipeline.
if let Some(true) = s.blocked {
item["blocked"] = json!(true);
}
if let Some(rc) = s.retry_count {
item["retry_count"] = json!(rc);
}
if let Some(ref mf) = s.merge_failure {
item["merge_failure"] = json!(mf);
}
item
})
.collect()
}
let mut active: Vec<Value> = Vec::new();
active.extend(map_items(&state.current, "current"));
active.extend(map_items(&state.qa, "qa"));
active.extend(map_items(&state.merge, "merge"));
active.extend(map_items(&state.done, "done"));
let backlog: Vec<Value> = state
.backlog
.iter()
.map(|s| json!({ "story_id": s.story_id, "name": s.name }))
.collect();
serde_json::to_string_pretty(&json!({
"active": active,
"backlog": backlog,
"backlog_count": backlog.len(),
}))
.map_err(|e| format!("Serialization error: {e}"))
}
pub(super) fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_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()?;
// Read from DB content store, falling back to filesystem.
let contents = crate::http::workflow::read_story_content(&root, story_id)
.map_err(|_| format!("Story file not found: {story_id}.md"))?;
let story_name = parse_front_matter(&contents).ok().and_then(|m| m.name);
let todos = parse_unchecked_todos(&contents);
serde_json::to_string_pretty(&json!({
"story_id": story_id,
"story_name": story_name,
"todos": todos,
}))
.map_err(|e| format!("Serialization error: {e}"))
}
pub(super) fn tool_record_tests(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let unit = parse_test_cases(args.get("unit"))?;
let integration = parse_test_cases(args.get("integration"))?;
let mut workflow = ctx
.workflow
.lock()
.map_err(|e| format!("Lock error: {e}"))?;
workflow.record_test_results_validated(story_id.to_string(), unit, integration)?;
// Persist to story file (best-effort — file write errors are warnings, not failures).
if let Ok(project_root) = ctx.state.get_project_root()
&& let Some(results) = workflow.results.get(story_id)
&& let Err(e) = crate::http::workflow::write_test_results_to_story_file(
&project_root,
story_id,
results,
)
{
slog_warn!("[record_tests] Could not persist results to story file: {e}");
}
Ok("Test results recorded.".to_string())
}
pub(super) fn tool_ensure_acceptance(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let workflow = ctx
.workflow
.lock()
.map_err(|e| format!("Lock error: {e}"))?;
// Use in-memory results if present; otherwise fall back to file-persisted results.
let file_results;
let results = if let Some(r) = workflow.results.get(story_id) {
r
} else {
let project_root = ctx.state.get_project_root().ok();
file_results = project_root.as_deref().and_then(|root| {
crate::http::workflow::read_test_results_from_story_file(root, story_id)
});
file_results.as_ref().map_or_else(
|| {
// No results anywhere — use empty default for the acceptance check
// (it will fail with "No test results recorded")
static EMPTY: std::sync::OnceLock<crate::workflow::StoryTestResults> =
std::sync::OnceLock::new();
EMPTY.get_or_init(Default::default)
},
|r| r,
)
};
let coverage = workflow.coverage.get(story_id);
let decision = evaluate_acceptance_with_coverage(results, coverage);
if decision.can_accept {
Ok("Story can be accepted. All gates pass.".to_string())
} else {
let mut parts = decision.reasons;
if let Some(w) = decision.warning {
parts.push(w);
}
Err(format!("Acceptance blocked: {}", parts.join("; ")))
}
}
pub(super) fn tool_accept_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
// Bug 226: Refuse to accept if the feature branch has unmerged code.
// The code must be squash-merged via merge_agent_work first.
if feature_branch_has_unmerged_changes(&project_root, story_id) {
return Err(format!(
"Cannot accept story '{story_id}': feature branch 'feature/story-{story_id}' \
has unmerged changes. Use merge_agent_work to squash-merge the code into \
master first."
));
}
move_story_to_done(&project_root, story_id)?;
ctx.services.agents.remove_agents_for_story(story_id);
Ok(format!(
"Story '{story_id}' accepted, moved to done/, and committed to master."
))
}
pub(super) fn tool_check_criterion(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let criterion_index = args
.get("criterion_index")
.and_then(|v| v.as_u64())
.ok_or("Missing required argument: criterion_index")? as usize;
let root = ctx.state.get_project_root()?;
check_criterion_in_file(&root, story_id, criterion_index)?;
Ok(format!(
"Criterion {criterion_index} checked for story '{story_id}'. Committed to master."
))
}
pub(super) fn tool_edit_criterion(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let criterion_index = args
.get("criterion_index")
.and_then(|v| v.as_u64())
.ok_or("Missing required argument: criterion_index")? as usize;
let new_text = args
.get("new_text")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: new_text")?;
let root = ctx.state.get_project_root()?;
edit_criterion_in_file(&root, story_id, criterion_index, new_text)?;
Ok(format!(
"Criterion {criterion_index} updated for story '{story_id}'."
))
}
pub(super) fn tool_add_criterion(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let criterion = args
.get("criterion")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: criterion")?;
let root = ctx.state.get_project_root()?;
add_criterion_to_file(&root, story_id, criterion)?;
Ok(format!(
"Added criterion to story '{story_id}': - [ ] {criterion}"
))
}
pub(super) fn tool_remove_criterion(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let criterion_index = args
.get("criterion_index")
.and_then(|v| v.as_u64())
.ok_or("Missing required argument: criterion_index")? as usize;
let root = ctx.state.get_project_root()?;
remove_criterion_from_file(&root, story_id, criterion_index)?;
Ok(format!(
"Removed criterion {criterion_index} from story '{story_id}'."
))
}
pub(super) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let user_story = args.get("user_story").and_then(|v| v.as_str());
let description = args.get("description").and_then(|v| v.as_str());
// Collect front matter fields: explicit `agent` param + arbitrary `front_matter` object.
// Values are passed as serde_json::Value so native booleans, numbers, and arrays are
// preserved and encoded correctly as unquoted YAML by update_story_in_file.
let mut front_matter: HashMap<String, Value> = HashMap::new();
if let Some(agent) = args.get("agent").and_then(|v| v.as_str()) {
front_matter.insert("agent".to_string(), Value::String(agent.to_string()));
}
if let Some(obj) = args.get("front_matter").and_then(|v| v.as_object()) {
for (k, v) in obj {
front_matter.insert(k.clone(), v.clone());
}
}
let front_matter_opt = if front_matter.is_empty() {
None
} else {
Some(&front_matter)
};
let root = ctx.state.get_project_root()?;
update_story_in_file(&root, story_id, user_story, description, front_matter_opt)?;
// Bug 503: warn if any depends_on in the (now updated) story points at an archived story.
let stage = crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
.map(|i| i.stage.dir_name().to_string())
.unwrap_or_else(|| "1_backlog".to_string());
let archived_deps = check_archived_deps(&root, &stage, story_id);
if !archived_deps.is_empty() {
slog_warn!(
"[update-story] Story '{story_id}' depends_on {archived_deps:?} which \
are already in 6_archived. The dep will be treated as satisfied on the \
next promotion tick. If these deps were abandoned (not cleanly completed), \
consider removing the depends_on or keeping the story in backlog manually."
);
return Ok(format!(
"Updated story '{story_id}'.\n\n\
WARNING: depends_on {archived_deps:?} point at stories already in \
6_archived. These deps are treated as satisfied (archived = satisfied \
semantics), so this story may be auto-promoted from backlog immediately. \
If the archived deps were abandoned rather than completed, remove the \
depends_on or move the story back to backlog manually after promotion."
));
}
Ok(format!("Updated story '{story_id}'."))
}
pub(super) fn tool_create_spike(args: &Value, ctx: &AppContext) -> Result<String, String> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: name")?;
let description = args.get("description").and_then(|v| v.as_str());
let root = ctx.state.get_project_root()?;
let spike_id = create_spike_file(&root, name, description)?;
Ok(format!("Created spike: {spike_id}"))
}
pub(super) fn tool_create_bug(args: &Value, ctx: &AppContext) -> Result<String, String> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: name")?;
let description = args
.get("description")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: description")?;
let steps_to_reproduce = args
.get("steps_to_reproduce")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: steps_to_reproduce")?;
let actual_result = args
.get("actual_result")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: actual_result")?;
let expected_result = args
.get("expected_result")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: expected_result")?;
let acceptance_criteria: Option<Vec<String>> = args
.get("acceptance_criteria")
.and_then(|v| serde_json::from_value(v.clone()).ok());
let depends_on: Option<Vec<u32>> = args
.get("depends_on")
.and_then(|v| serde_json::from_value(v.clone()).ok());
let root = ctx.state.get_project_root()?;
let bug_id = create_bug_file(
&root,
name,
description,
steps_to_reproduce,
actual_result,
expected_result,
acceptance_criteria.as_deref(),
depends_on.as_deref(),
)?;
Ok(format!("Created bug: {bug_id}"))
}
pub(super) fn tool_list_bugs(ctx: &AppContext) -> Result<String, String> {
let root = ctx.state.get_project_root()?;
let bugs = list_bug_files(&root)?;
serde_json::to_string_pretty(&json!(
bugs.iter()
.map(|(id, name)| json!({ "bug_id": id, "name": name }))
.collect::<Vec<_>>()
))
.map_err(|e| format!("Serialization error: {e}"))
}
pub(super) fn tool_close_bug(args: &Value, ctx: &AppContext) -> Result<String, String> {
let bug_id = args
.get("bug_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: bug_id")?;
let root = ctx.services.agents.get_project_root(&ctx.state)?;
close_bug_to_archive(&root, bug_id)?;
ctx.services.agents.remove_agents_for_story(bug_id);
Ok(format!(
"Bug '{bug_id}' closed, moved to bugs/archive/, and committed to master."
))
}
pub(super) fn tool_unblock_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_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()?;
// Extract the numeric prefix (e.g. "42" from "42_story_foo")
let story_number = story_id
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.ok_or_else(|| format!("Invalid story_id format: '{story_id}'. Expected a numeric prefix (e.g. '42_story_foo')."))?;
Ok(crate::chat::commands::unblock::unblock_by_number(
&root,
story_number,
))
}
pub(super) async fn tool_delete_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
let mut failed_steps: Vec<String> = Vec::new();
// 0. Cancel any pending rate-limit retry timers for this story (bug 514).
// Must happen before stopping agents so the tick loop cannot re-spawn
// an agent after we tear everything else down.
let timer_removed = ctx.timer_store.remove(story_id);
if timer_removed {
slog_warn!("[delete_story] Cancelled pending timer for '{story_id}'");
} else {
slog_warn!("[delete_story] No pending timer found for '{story_id}'");
}
// 1. Stop any running agents for this story (best-effort).
if let Ok(agents) = ctx.services.agents.list_agents() {
for agent in agents.iter().filter(|a| a.story_id == story_id) {
match ctx
.services
.agents
.stop_agent(&project_root, story_id, &agent.agent_name)
.await
{
Ok(()) => {
slog_warn!(
"[delete_story] Stopped agent '{}' for '{story_id}'",
agent.agent_name
);
}
Err(e) => {
slog_warn!(
"[delete_story] Failed to stop agent '{}' for '{story_id}': {e}",
agent.agent_name
);
failed_steps.push(format!("stop_agent({}): {e}", agent.agent_name));
}
}
}
}
// 2. Remove agent pool entries.
let removed_count = ctx.services.agents.remove_agents_for_story(story_id);
slog_warn!("[delete_story] Removed {removed_count} agent pool entries for '{story_id}'");
// 3. Remove worktree (best-effort).
if let Ok(config) = crate::config::ProjectConfig::load(&project_root) {
match crate::worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await {
Ok(()) => slog_warn!("[delete_story] Removed worktree for '{story_id}'"),
Err(e) => slog_warn!("[delete_story] Worktree removal for '{story_id}': {e}"),
}
}
// 4. Write a CRDT tombstone op so the story is evicted from the in-memory
// state machine and the deletion is persisted to crdt_ops (survives
// restart). Best-effort: legacy filesystem-only stories may not have a
// CRDT entry, so a "not found" error is expected and non-fatal.
match crate::crdt_state::evict_item(story_id) {
Ok(()) => {
slog_warn!(
"[delete_story] Evicted '{story_id}' from CRDT (tombstone persisted to crdt_ops)"
);
}
Err(e) => {
slog_warn!("[delete_story] CRDT eviction for '{story_id}': {e}");
}
}
// 5. Delete from database content store and shadow table.
let found_in_db = crate::db::read_content(story_id).is_some()
|| crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
.is_some();
crate::db::delete_item(story_id);
slog_warn!("[delete_story] Deleted '{story_id}' from content store / shadow table");
// 6. Remove the filesystem shadow file from work/N_stage/.
let sk = project_root.join(".huskies").join("work");
let stage_dirs = [
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
let mut deleted_from_fs = false;
for stage in &stage_dirs {
let path = sk.join(stage).join(format!("{story_id}.md"));
if path.exists() {
match fs::remove_file(&path) {
Ok(()) => {
slog_warn!(
"[delete_story] Deleted filesystem shadow '{story_id}' from work/{stage}/"
);
deleted_from_fs = true;
}
Err(e) => {
slog_warn!(
"[delete_story] Failed to delete filesystem shadow '{story_id}' from work/{stage}/: {e}"
);
failed_steps.push(format!("delete_filesystem({stage}): {e}"));
}
}
break;
}
}
if !found_in_db && !deleted_from_fs && !timer_removed {
return Err(format!(
"Story '{story_id}' not found in any pipeline stage."
));
}
if !failed_steps.is_empty() {
return Err(format!(
"Story '{story_id}' partially deleted. Failed steps: {}.",
failed_steps.join("; ")
));
}
Ok(format!("Story '{story_id}' deleted from pipeline."))
}
pub(super) fn tool_create_refactor(args: &Value, ctx: &AppContext) -> Result<String, String> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: name")?;
let description = args.get("description").and_then(|v| v.as_str());
let acceptance_criteria: Option<Vec<String>> = args
.get("acceptance_criteria")
.and_then(|v| serde_json::from_value(v.clone()).ok());
let depends_on: Option<Vec<u32>> = args
.get("depends_on")
.and_then(|v| serde_json::from_value(v.clone()).ok());
let root = ctx.state.get_project_root()?;
let refactor_id = create_refactor_file(
&root,
name,
description,
acceptance_criteria.as_deref(),
depends_on.as_deref(),
)?;
Ok(format!("Created refactor: {refactor_id}"))
}
pub(super) fn tool_list_refactors(ctx: &AppContext) -> Result<String, String> {
let root = ctx.state.get_project_root()?;
let refactors = list_refactor_files(&root)?;
serde_json::to_string_pretty(&json!(
refactors
.iter()
.map(|(id, name)| json!({ "refactor_id": id, "name": name }))
.collect::<Vec<_>>()
))
.map_err(|e| format!("Serialization error: {e}"))
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff