From 60e1d7bf64ea9e828b1f4812bf2f6b81f4cb5330 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 20 Mar 2026 09:10:56 +0000 Subject: [PATCH] story-kit: merge 342_story_web_ui_button_to_delete_a_story_from_the_pipeline --- frontend/src/api/client.ts | 4 ++ frontend/src/components/Chat.tsx | 14 ++++ frontend/src/components/StagePanel.tsx | 56 ++++++++++++++-- server/src/http/mcp/mod.rs | 19 +++++- server/src/http/mcp/story_tools.rs | 93 ++++++++++++++++++++++++++ 5 files changed, 180 insertions(+), 6 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c468d24..6a3997d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -373,6 +373,10 @@ export const api = { launchQaApp(storyId: string) { return callMcpTool("launch_qa_app", { story_id: storyId }); }, + /** Delete a story from the pipeline, stopping any running agent and removing the worktree. */ + deleteStory(storyId: string) { + return callMcpTool("delete_story", { story_id: storyId }); + }, }; async function callMcpTool( diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 5f7ce55..5666478 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -213,6 +213,15 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const [selectedWorkItemId, setSelectedWorkItemId] = useState( null, ); + + const handleDeleteItem = React.useCallback( + (item: import("../api/client").PipelineStageItem) => { + api.deleteStory(item.story_id).catch((err: unknown) => { + console.error("Failed to delete story:", err); + }); + }, + [], + ); const [queuedMessages, setQueuedMessages] = useState< { id: string; text: string }[] >([]); @@ -1089,6 +1098,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { costs={storyTokenCosts} onItemClick={(item) => setSelectedWorkItemId(item.story_id)} onStopAgent={handleStopAgent} + onDeleteItem={handleDeleteItem} /> setSelectedWorkItemId(item.story_id)} onStopAgent={handleStopAgent} + onDeleteItem={handleDeleteItem} /> setSelectedWorkItemId(item.story_id)} onStopAgent={handleStopAgent} + onDeleteItem={handleDeleteItem} /> diff --git a/frontend/src/components/StagePanel.tsx b/frontend/src/components/StagePanel.tsx index 7189f5a..d92a0fa 100644 --- a/frontend/src/components/StagePanel.tsx +++ b/frontend/src/components/StagePanel.tsx @@ -44,6 +44,7 @@ interface StagePanelProps { emptyMessage?: string; onItemClick?: (item: PipelineStageItem) => void; onStopAgent?: (storyId: string, agentName: string) => void; + onDeleteItem?: (item: PipelineStageItem) => void; /** Map of story_id → total_cost_usd for displaying cost badges. */ costs?: Map; /** Agent roster to populate the start agent dropdown. */ @@ -253,6 +254,7 @@ export function StagePanel({ emptyMessage = "Empty.", onItemClick, onStopAgent, + onDeleteItem, costs, agentRoster, busyAgentNames, @@ -444,9 +446,8 @@ export function StagePanel({ )} ); - return onItemClick ? ( + const card = onItemClick ? ( ) : ( +
+ {cardInner} +
+ ); + return (
- {cardInner} + {card} + {onDeleteItem && ( + + )}
); })} diff --git a/server/src/http/mcp/mod.rs b/server/src/http/mcp/mod.rs index 83e563e..54ffb18 100644 --- a/server/src/http/mcp/mod.rs +++ b/server/src/http/mcp/mod.rs @@ -967,6 +967,20 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { } } }, + { + "name": "delete_story", + "description": "Delete a work item from the pipeline entirely. Stops any running agent, removes the worktree, and deletes the story file. Use only for removing obsolete or duplicate items.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Work item identifier (filename stem, e.g. '28_story_my_feature')" + } + }, + "required": ["story_id"] + } + }, { "name": "move_story", "description": "Move a work item (story, bug, spike, or refactor) to an arbitrary pipeline stage. Prefer dedicated tools when available: use accept_story to mark items done, move_story_to_merge to queue for merging, or request_qa to trigger QA review. Use move_story only for arbitrary moves that lack a dedicated tool — for example, moving a story back to backlog or recovering a ghost story by moving it back to current.", @@ -1061,6 +1075,8 @@ async fn handle_tools_call( "prompt_permission" => diagnostics::tool_prompt_permission(&args, ctx).await, // Token usage "get_token_usage" => diagnostics::tool_get_token_usage(&args, ctx), + // Delete story + "delete_story" => story_tools::tool_delete_story(&args, ctx).await, // Arbitrary pipeline movement "move_story" => diagnostics::tool_move_story(&args, ctx), _ => Err(format!("Unknown tool: {tool_name}")), @@ -1171,7 +1187,8 @@ mod tests { assert!(names.contains(&"rebuild_and_restart")); assert!(names.contains(&"get_token_usage")); assert!(names.contains(&"move_story")); - assert_eq!(tools.len(), 41); + assert!(names.contains(&"delete_story")); + assert_eq!(tools.len(), 42); } #[test] diff --git a/server/src/http/mcp/story_tools.rs b/server/src/http/mcp/story_tools.rs index 3d270fd..0ed80dd 100644 --- a/server/src/http/mcp/story_tools.rs +++ b/server/src/http/mcp/story_tools.rs @@ -390,6 +390,53 @@ pub(super) fn tool_close_bug(args: &Value, ctx: &AppContext) -> Result Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + let project_root = ctx.agents.get_project_root(&ctx.state)?; + + // 1. Stop any running agents for this story (best-effort) + if let Ok(agents) = ctx.agents.list_agents() { + for agent in agents.iter().filter(|a| a.story_id == story_id) { + let _ = ctx.agents.stop_agent(&project_root, story_id, &agent.agent_name).await; + } + } + + // 2. Remove agent pool entries + ctx.agents.remove_agents_for_story(story_id); + + // 3. Remove worktree (best-effort) + if let Ok(config) = crate::config::ProjectConfig::load(&project_root) { + let _ = crate::worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await; + } + + // 4. Find and delete the story file from any pipeline stage + let sk = project_root.join(".story_kit").join("work"); + let stage_dirs = ["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"]; + let mut deleted = false; + for stage in &stage_dirs { + let path = sk.join(stage).join(format!("{story_id}.md")); + if path.exists() { + fs::remove_file(&path) + .map_err(|e| format!("Failed to delete story file: {e}"))?; + slog_warn!("[delete_story] Deleted '{story_id}' from work/{stage}/"); + deleted = true; + break; + } + } + + if !deleted { + return Err(format!( + "Story '{story_id}' not found in any pipeline stage." + )); + } + + Ok(format!("Story '{story_id}' deleted from pipeline.")) +} + pub(super) fn tool_create_refactor(args: &Value, ctx: &AppContext) -> Result { let name = args .get("name") @@ -1129,6 +1176,52 @@ mod tests { assert!(result.unwrap_err().contains("blocked")); } + #[tokio::test] + async fn tool_delete_story_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_delete_story(&json!({}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[tokio::test] + async fn tool_delete_story_not_found_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_delete_story(&json!({"story_id": "99_nonexistent"}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found in any pipeline stage")); + } + + #[tokio::test] + async fn tool_delete_story_deletes_file_from_backlog() { + let tmp = tempfile::tempdir().unwrap(); + let backlog = tmp.path().join(".story_kit/work/1_backlog"); + fs::create_dir_all(&backlog).unwrap(); + let story_file = backlog.join("10_story_cleanup.md"); + fs::write(&story_file, "---\nname: Cleanup\n---\n").unwrap(); + + let ctx = test_ctx(tmp.path()); + let result = tool_delete_story(&json!({"story_id": "10_story_cleanup"}), &ctx).await; + assert!(result.is_ok(), "expected ok: {result:?}"); + assert!(!story_file.exists(), "story file should be deleted"); + } + + #[tokio::test] + async fn tool_delete_story_deletes_file_from_current() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let story_file = current.join("11_story_active.md"); + fs::write(&story_file, "---\nname: Active\n---\n").unwrap(); + + let ctx = test_ctx(tmp.path()); + let result = tool_delete_story(&json!({"story_id": "11_story_active"}), &ctx).await; + assert!(result.is_ok(), "expected ok: {result:?}"); + assert!(!story_file.exists(), "story file should be deleted"); + } + #[test] fn tool_accept_story_missing_story_id() { let tmp = tempfile::tempdir().unwrap();