use crate::config::ProjectConfig; use crate::http::context::{AppContext, OpenApiResult, bad_request}; use crate::worktree; use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json}; use serde::Serialize; use std::path; use std::sync::Arc; #[derive(Tags)] enum AgentsTags { Agents, } #[derive(Object)] struct StartAgentPayload { story_id: String, agent_name: Option, } #[derive(Object)] struct StopAgentPayload { story_id: String, agent_name: String, } #[derive(Object, Serialize)] struct AgentInfoResponse { story_id: String, agent_name: String, status: String, session_id: Option, worktree_path: Option, } #[derive(Object, Serialize)] struct AgentConfigInfoResponse { name: String, role: String, model: Option, allowed_tools: Option>, max_turns: Option, max_budget_usd: Option, } #[derive(Object)] struct CreateWorktreePayload { story_id: String, } #[derive(Object, Serialize)] struct WorktreeInfoResponse { story_id: String, worktree_path: String, branch: String, base_branch: String, } #[derive(Object, Serialize)] struct WorktreeListEntry { story_id: String, path: String, } /// Returns true if the story file exists in `work/5_archived/`. /// /// Used to exclude agents for already-archived stories from the `list_agents` /// response so the agents panel is not cluttered with old completed items on /// frontend startup. fn story_is_archived(project_root: &path::Path, story_id: &str) -> bool { project_root .join(".story_kit") .join("work") .join("5_archived") .join(format!("{story_id}.md")) .exists() } pub struct AgentsApi { pub ctx: Arc, } #[OpenApi(tag = "AgentsTags::Agents")] impl AgentsApi { /// Start an agent for a given story (creates worktree, runs setup, spawns agent). /// If agent_name is omitted, the first configured agent is used. #[oai(path = "/agents/start", method = "post")] async fn start_agent( &self, payload: Json, ) -> OpenApiResult> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let info = self .ctx .agents .start_agent( &project_root, &payload.0.story_id, payload.0.agent_name.as_deref(), None, ) .await .map_err(bad_request)?; Ok(Json(AgentInfoResponse { story_id: info.story_id, agent_name: info.agent_name, status: info.status.to_string(), session_id: info.session_id, worktree_path: info.worktree_path, })) } /// Stop a running agent and clean up its worktree. #[oai(path = "/agents/stop", method = "post")] async fn stop_agent(&self, payload: Json) -> OpenApiResult> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; self.ctx .agents .stop_agent( &project_root, &payload.0.story_id, &payload.0.agent_name, ) .await .map_err(bad_request)?; Ok(Json(true)) } /// List all agents with their status. /// /// Agents for stories that have been archived (`work/5_archived/`) are /// excluded so the agents panel is not cluttered with old completed items /// on frontend startup. #[oai(path = "/agents", method = "get")] async fn list_agents(&self) -> OpenApiResult>> { let project_root = self.ctx.agents.get_project_root(&self.ctx.state).ok(); let agents = self.ctx.agents.list_agents().map_err(bad_request)?; Ok(Json( agents .into_iter() .filter(|info| { project_root .as_deref() .map(|root| !story_is_archived(root, &info.story_id)) .unwrap_or(true) }) .map(|info| AgentInfoResponse { story_id: info.story_id, agent_name: info.agent_name, status: info.status.to_string(), session_id: info.session_id, worktree_path: info.worktree_path, }) .collect(), )) } /// Get the configured agent roster from project.toml. #[oai(path = "/agents/config", method = "get")] async fn get_agent_config( &self, ) -> OpenApiResult>> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let config = ProjectConfig::load(&project_root).map_err(bad_request)?; Ok(Json( config .agent .iter() .map(|a| AgentConfigInfoResponse { name: a.name.clone(), role: a.role.clone(), model: a.model.clone(), allowed_tools: a.allowed_tools.clone(), max_turns: a.max_turns, max_budget_usd: a.max_budget_usd, }) .collect(), )) } /// Reload project config and return the updated agent roster. #[oai(path = "/agents/config/reload", method = "post")] async fn reload_config( &self, ) -> OpenApiResult>> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let config = ProjectConfig::load(&project_root).map_err(bad_request)?; Ok(Json( config .agent .iter() .map(|a| AgentConfigInfoResponse { name: a.name.clone(), role: a.role.clone(), model: a.model.clone(), allowed_tools: a.allowed_tools.clone(), max_turns: a.max_turns, max_budget_usd: a.max_budget_usd, }) .collect(), )) } /// Create a git worktree for a story under .story_kit/worktrees/{story_id}. #[oai(path = "/agents/worktrees", method = "post")] async fn create_worktree( &self, payload: Json, ) -> OpenApiResult> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let info = self .ctx .agents .create_worktree(&project_root, &payload.0.story_id) .await .map_err(bad_request)?; Ok(Json(WorktreeInfoResponse { story_id: payload.0.story_id, worktree_path: info.path.to_string_lossy().to_string(), branch: info.branch, base_branch: info.base_branch, })) } /// List all worktrees under .story_kit/worktrees/. #[oai(path = "/agents/worktrees", method = "get")] async fn list_worktrees(&self) -> OpenApiResult>> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let entries = worktree::list_worktrees(&project_root).map_err(bad_request)?; Ok(Json( entries .into_iter() .map(|e| WorktreeListEntry { story_id: e.story_id, path: e.path.to_string_lossy().to_string(), }) .collect(), )) } /// Remove a git worktree and its feature branch for a story. #[oai(path = "/agents/worktrees/:story_id", method = "delete")] async fn remove_worktree(&self, story_id: Path) -> OpenApiResult> { let project_root = self .ctx .agents .get_project_root(&self.ctx.state) .map_err(bad_request)?; let config = ProjectConfig::load(&project_root).map_err(bad_request)?; worktree::remove_worktree_by_story_id(&project_root, &story_id.0, &config) .await .map_err(bad_request)?; Ok(Json(true)) } } #[cfg(test)] mod tests { use super::*; use crate::agents::AgentStatus; use tempfile::TempDir; fn make_archived_dir(tmp: &TempDir) -> path::PathBuf { let root = tmp.path().to_path_buf(); let archived = root .join(".story_kit") .join("work") .join("5_archived"); std::fs::create_dir_all(&archived).unwrap(); root } #[test] fn story_is_archived_false_when_file_absent() { let tmp = TempDir::new().unwrap(); let root = make_archived_dir(&tmp); assert!(!story_is_archived(&root, "79_story_foo")); } #[test] fn story_is_archived_true_when_file_present() { let tmp = TempDir::new().unwrap(); let root = make_archived_dir(&tmp); std::fs::write( root.join(".story_kit/work/5_archived/79_story_foo.md"), "---\nname: test\n---\n", ) .unwrap(); assert!(story_is_archived(&root, "79_story_foo")); } #[tokio::test] async fn list_agents_excludes_archived_stories() { let tmp = TempDir::new().unwrap(); let root = make_archived_dir(&tmp); // Place an archived story file std::fs::write( root.join(".story_kit/work/5_archived/79_story_archived.md"), "---\nname: archived story\n---\n", ) .unwrap(); let ctx = AppContext::new_test(root); // Inject an agent for the archived story (completed) and one for an active story ctx.agents .inject_test_agent("79_story_archived", "coder-1", AgentStatus::Completed); ctx.agents .inject_test_agent("80_story_active", "coder-1", AgentStatus::Running); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api.list_agents().await.unwrap().0; // Archived story's agent should not appear assert!( !result.iter().any(|a| a.story_id == "79_story_archived"), "archived story agent should be excluded from list_agents" ); // Active story's agent should still appear assert!( result.iter().any(|a| a.story_id == "80_story_active"), "active story agent should be included in list_agents" ); } #[tokio::test] async fn list_agents_includes_all_when_no_project_root() { // When no project root is configured, all agents are returned (safe default). let tmp = TempDir::new().unwrap(); let ctx = AppContext::new_test(tmp.path().to_path_buf()); // Clear the project_root so get_project_root returns Err *ctx.state.project_root.lock().unwrap() = None; ctx.agents .inject_test_agent("42_story_whatever", "coder-1", AgentStatus::Completed); let api = AgentsApi { ctx: Arc::new(ctx), }; let result = api.list_agents().await.unwrap().0; assert!(result.iter().any(|a| a.story_id == "42_story_whatever")); } }