Files
huskies/server/src/http/mcp/story_tools/epic.rs
T
Timmy 6fbe239313 fix(1102): require non-empty origin.id on create_* MCP tools
bug 1102 was created today with origin={kind:user, id:""} because
build_origin silently defaulted id to empty when the caller didn't pass
one — we couldn't tell who filed it. Bug 1088's origin field is useless
as audit if every caller can omit themselves.

Changes:
- build_origin (server/src/http/mcp/story_tools/mod.rs) now returns
  Result<String, String> and rejects missing/empty/whitespace-only id
  with an instructional error pointing at bug 1102 / story 1104.
- 5 create_* tool handlers (bug, spike, refactor, epic, story) now
  resolve origin BEFORE create_*_file so an attribution-less call
  leaves no half-state behind.
- 5 tool input schemas now advertise origin as a required object via
  a shared origin_schema() helper. The schema description gives every
  caller (coder agent, chat bot, user, system) a concrete example so
  the LLM populates the field correctly on first sight.
- Test fixtures pass origin = {kind:"test", id:"test-suite"}.

Story 1104 (signed actions) is the longer-term replacement; this is the
quick attribution win agreed for master ahead of that design work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 23:13:54 +01:00

299 lines
10 KiB
Rust

//! Epic work-item MCP tools — create, list, and show epics.
//!
//! Epics are shared-context containers that group related stories, bugs, spikes,
//! and refactors. They are not pipeline-driven but provide authoritative context
//! injected into agent prompts for all member work items.
use crate::http::context::AppContext;
use crate::http::workflow::create_epic_file;
use crate::validation::CreateEpicRequest;
use serde_json::{Value, json};
/// Create a new epic and store it in the CRDT items list.
pub(crate) fn tool_create_epic(args: &Value, ctx: &AppContext) -> Result<String, String> {
let req = CreateEpicRequest::from_json(args)?;
// Bug 1102: resolve and validate origin BEFORE creating the epic so a
// missing-attribution call leaves no half-state behind.
let origin = super::build_origin(args)?;
let root = ctx.state.get_project_root()?;
let success_criteria = req.success_criteria_strings();
let epic_id = create_epic_file(
&root,
req.name.as_ref(),
req.goal.as_str(),
req.motivation.as_ref().map(|d| d.as_ref()),
req.key_files.as_deref(),
if success_criteria.is_empty() {
None
} else {
Some(success_criteria.as_slice())
},
)?;
crate::crdt_state::set_origin(&epic_id, &origin);
Ok(format!("Created epic: {epic_id}"))
}
/// List all epics with member work item counts and `n/m done` rollup.
pub(crate) fn tool_list_epics(_ctx: &AppContext) -> Result<String, String> {
use crate::pipeline_state::Stage;
let all_items = crate::pipeline_state::read_all_typed();
// Collect epics: items with item_type == "epic" in the CRDT register.
let mut epics: Vec<(String, String)> = Vec::new(); // (id, name)
// Collect member items: map from epic_id → list of (story_id, is_done).
let mut members: std::collections::HashMap<String, Vec<(String, bool)>> =
std::collections::HashMap::new();
for item in &all_items {
let sid = &item.story_id.0;
let Some(view) = crate::crdt_state::read_item(sid) else {
continue;
};
use crate::io::story_metadata::ItemType;
if view.item_type() == Some(ItemType::Epic) {
epics.push((sid.clone(), item.name.clone()));
}
if let Some(epic_id) = view.epic() {
let is_done = matches!(item.stage, Stage::Done { .. });
members
.entry(epic_id.to_string())
.or_default()
.push((sid.clone(), is_done));
}
}
epics.sort_by(|a, b| a.0.cmp(&b.0));
let result: Vec<Value> = epics
.iter()
.map(|(id, name)| {
let member_list = members.get(id).cloned().unwrap_or_default();
let total = member_list.len();
let done = member_list.iter().filter(|(_, d)| *d).count();
json!({
"epic_id": id,
"name": name,
"members_total": total,
"members_done": done,
"rollup": format!("{done}/{total} done"),
})
})
.collect();
serde_json::to_string_pretty(&result).map_err(|e| format!("Serialization error: {e}"))
}
/// Show details for a single epic: content and member work items with their stages.
pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String, String> {
use crate::pipeline_state::Stage;
let epic_id = args
.get("epic_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: epic_id")?;
let content = crate::db::read_content(crate::db::ContentKey::Story(epic_id))
.ok_or_else(|| format!("Epic '{epic_id}' not found in content store"))?;
let epic_view = crate::crdt_state::read_item(epic_id)
.ok_or_else(|| format!("Epic '{epic_id}' not found in CRDT"))?;
use crate::io::story_metadata::ItemType;
if epic_view.item_type() != Some(ItemType::Epic) {
return Err(format!(
"'{epic_id}' is not an epic (item_type: {:?})",
epic_view.item_type()
));
}
// Parse the epic_id argument to a numeric EpicId for comparison.
let epic_numeric = crate::crdt_state::EpicId::from_crdt_str(epic_id);
// Find member items.
let all_items = crate::pipeline_state::read_all_typed();
let mut member_items: Vec<Value> = Vec::new();
let mut done = 0usize;
for item in &all_items {
let sid = &item.story_id.0;
let Some(member_view) = crate::crdt_state::read_item(sid) else {
continue;
};
if member_view.epic() == epic_numeric {
// Story 945: Frozen / ReviewHold / MergeFailureFinal are first-class
// Stage variants — no more orthogonal boolean flags.
let stage_name = item.stage.dir_name();
if matches!(item.stage, Stage::Done { .. }) {
done += 1;
}
// Story 1087: expose pipeline + status alongside the legacy
// stage name so epic-show callers can route by column/badge.
member_items.push(json!({
"story_id": sid,
"name": item.name,
"stage": stage_name,
"pipeline": item.stage.pipeline().as_str(),
"status": item.stage.status().as_str(),
}));
}
}
let total = member_items.len();
serde_json::to_string_pretty(&json!({
"epic_id": epic_id,
"name": epic_view.name(),
"content": content,
"members": member_items,
"rollup": format!("{done}/{total} done"),
}))
.map_err(|e| format!("Serialization error: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::test_helpers::test_ctx;
use serde_json::json;
#[test]
fn tool_create_epic_creates_epic_and_returns_id() {
let tmp = tempfile::tempdir().unwrap();
crate::db::ensure_content_store();
let ctx = test_ctx(tmp.path());
let result = tool_create_epic(
&json!({
"name": "My Test Epic",
"goal": "Achieve something great",
"motivation": "Because it matters",
"success_criteria": ["All stories done", "Tests pass"],
"origin": {"kind": "test", "id": "test-suite"}
}),
&ctx,
);
assert!(result.is_ok(), "expected ok: {result:?}");
let msg = result.unwrap();
assert!(msg.contains("Created epic:"), "unexpected msg: {msg}");
}
#[test]
fn tool_create_epic_missing_name_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_epic(&json!({"goal": "Achieve something"}), &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("name"));
}
#[test]
fn tool_create_epic_missing_goal_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_epic(&json!({"name": "My Epic"}), &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("goal"));
}
#[test]
fn tool_create_epic_rejects_grammar_token_in_name() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_epic(
&json!({"name": "Epic </description> bad", "goal": "some goal"}),
&ctx,
);
assert!(result.is_err());
assert!(
result.unwrap_err().contains("AntiGrammarToken"),
"expected AntiGrammarToken error"
);
}
#[test]
fn tool_list_epics_includes_created_epic() {
let tmp = tempfile::tempdir().unwrap();
crate::db::ensure_content_store();
let ctx = test_ctx(tmp.path());
// Create an epic.
tool_create_epic(
&json!({
"name": "List Epics Test Epic",
"goal": "Testing list",
"origin": {"kind": "test", "id": "test-suite"}
}),
&ctx,
)
.unwrap();
let result = tool_list_epics(&ctx);
assert!(result.is_ok(), "expected ok: {result:?}");
let parsed: Vec<Value> = serde_json::from_str(&result.unwrap()).unwrap();
assert!(
parsed.iter().any(|e| e["name"] == "List Epics Test Epic"),
"expected epic in list: {parsed:?}"
);
}
#[test]
fn tool_list_epics_shows_member_rollup() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
use crate::crdt_state::EpicId;
use crate::io::story_metadata::ItemType;
// Write a fake epic with the typed CRDT registers (story 933).
// Epics use numeric-only story_ids (see create_epic_file).
crate::db::write_item_with_content(
"9990",
"1_backlog",
"# Rollup Epic\n\n## Goal\n\nTest\n",
crate::db::ItemMeta::named("Rollup Epic"),
);
crate::crdt_state::set_item_type("9990", Some(ItemType::Epic));
// Write two member items: one done, one current.
crate::db::write_item_with_content(
"9991_story_member_done",
"5_done",
"# Done Member\n",
crate::db::ItemMeta::named("Done Member"),
);
crate::crdt_state::set_item_type("9991_story_member_done", Some(ItemType::Story));
crate::crdt_state::set_epic("9991_story_member_done", Some(EpicId(9990)));
crate::db::write_item_with_content(
"9992_story_member_current",
"2_current",
"# Current Member\n",
crate::db::ItemMeta::named("Current Member"),
);
crate::crdt_state::set_item_type("9992_story_member_current", Some(ItemType::Story));
crate::crdt_state::set_epic("9992_story_member_current", Some(EpicId(9990)));
let tmp = tempfile::tempdir().unwrap();
let ctx = crate::http::test_helpers::test_ctx(tmp.path());
let result = tool_list_epics(&ctx).unwrap();
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
let epic = parsed
.iter()
.find(|e| e["epic_id"] == "9990")
.expect("expected rollup epic in list");
assert_eq!(epic["members_total"], 2, "two members expected");
assert_eq!(epic["members_done"], 1, "one done member expected");
assert_eq!(epic["rollup"], "1/2 done");
}
}