//! Agent service — public API for the agent domain. //! //! This module orchestrates calls to `io.rs` (side effects) and the pure //! topic modules (`selection`, `token`) to implement the full agent service //! surface. HTTP handlers call these functions instead of reaching directly //! into `AgentPool` or the filesystem. //! //! Conventions: `docs/architecture/service-modules.md` mod io; pub mod selection; pub mod token; use crate::agents::AgentInfo; use crate::agents::AgentPool; use crate::agents::token_usage::TokenUsageRecord; use crate::config::ProjectConfig; use crate::workflow::StoryTestResults; use crate::worktree::{WorktreeInfo, WorktreeListEntry}; use std::path::Path; pub use io::is_archived; pub use token::TokenCostSummary; // ── Error type ──────────────────────────────────────────────────────────────── /// Typed errors returned by `service::agents` functions. /// /// HTTP handlers map these to specific status codes — see the conventions doc /// for the full mapping table. #[derive(Debug)] pub enum Error { /// No agent with the given name/story exists in the pool. AgentNotFound(String), /// No work item found for the requested story ID. WorkItemNotFound(String), /// A worktree operation failed. Worktree(String), /// Project configuration could not be loaded. Config(String), /// A filesystem or I/O operation failed. Io(String), } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::AgentNotFound(msg) => write!(f, "Agent not found: {msg}"), Self::WorkItemNotFound(msg) => write!(f, "Work item not found: {msg}"), Self::Worktree(msg) => write!(f, "Worktree error: {msg}"), Self::Config(msg) => write!(f, "Config error: {msg}"), Self::Io(msg) => write!(f, "I/O error: {msg}"), } } } // ── Shared service types ───────────────────────────────────────────────────── /// Content and metadata for a work-item (story) file. #[derive(Debug, Clone)] pub struct WorkItemContent { pub content: String, pub stage: String, pub name: Option, pub agent: Option, } /// A single entry in the project's configured agent roster. #[derive(Debug, Clone)] pub struct AgentConfigEntry { pub name: String, pub role: String, pub stage: Option, pub model: Option, pub allowed_tools: Option>, pub max_turns: Option, pub max_budget_usd: Option, } // ── Public API ──────────────────────────────────────────────────────────────── /// Start an agent for a story. /// /// Takes only what it needs: the pool (for spawning) and the project root /// (for config and worktree creation). Does not touch `AppContext`. pub async fn start_agent( pool: &AgentPool, project_root: &Path, story_id: &str, agent_name: Option<&str>, resume_context: Option<&str>, session_id_to_resume: Option, ) -> Result { pool.start_agent( project_root, story_id, agent_name, resume_context, session_id_to_resume, ) .await .map_err(Error::AgentNotFound) } /// Stop a running agent. pub async fn stop_agent( pool: &AgentPool, project_root: &Path, story_id: &str, agent_name: &str, ) -> Result<(), Error> { pool.stop_agent(project_root, story_id, agent_name) .await .map_err(Error::AgentNotFound) } /// Create a git worktree for a story. pub async fn create_worktree( pool: &AgentPool, project_root: &Path, story_id: &str, ) -> Result { pool.create_worktree(project_root, story_id) .await .map_err(Error::Worktree) } /// List all worktrees under `.huskies/worktrees/`. pub fn list_worktrees(project_root: &Path) -> Result, Error> { io::list_worktrees(project_root) } /// Remove the git worktree for a story. pub async fn remove_worktree(project_root: &Path, story_id: &str) -> Result<(), Error> { io::remove_worktree(project_root, story_id).await } /// Get the configured agent roster from `project.toml`. pub fn get_agent_config(project_root: &Path) -> Result, Error> { let config = io::load_config(project_root)?; Ok(config_to_entries(&config)) } /// Reload and return the project's agent configuration. /// /// Semantically identical to `get_agent_config`; provided as a distinct /// function so callers can express intent (UI "Reload" button). pub fn reload_config(project_root: &Path) -> Result, Error> { get_agent_config(project_root) } /// Get the concatenated output text for an agent's most recent session. /// /// Returns an empty string when no log file exists yet. pub fn get_agent_output( project_root: &Path, story_id: &str, agent_name: &str, ) -> Result { let entries = io::read_agent_log(project_root, story_id, agent_name)?; Ok(selection::collect_output_text(&entries)) } /// Get the markdown content and metadata for a work item. /// /// Searches all pipeline stage directories, falling back to the CRDT content /// store when no file is present on disk. Returns `Error::WorkItemNotFound` /// when neither source has the item. pub fn get_work_item_content( project_root: &Path, story_id: &str, ) -> Result { let stages = [ ("1_backlog", "backlog"), ("2_current", "current"), ("3_qa", "qa"), ("4_merge", "merge"), ("5_done", "done"), ("6_archived", "archived"), ]; let work_dir = project_root.join(".huskies").join("work"); let filename = format!("{story_id}.md"); for (stage_dir, stage_name) in &stages { if let Some(content) = io::read_work_item_from_stage(&work_dir, stage_dir, &filename)? { let metadata = crate::io::story_metadata::parse_front_matter(&content).ok(); return Ok(WorkItemContent { content, stage: stage_name.to_string(), name: metadata.as_ref().and_then(|m| m.name.clone()), agent: metadata.and_then(|m| m.agent), }); } } // CRDT-only fallback if let Some(content) = crate::db::read_content(story_id) { let item = crate::pipeline_state::read_typed(story_id) .map_err(|e| Error::Io(format!("Pipeline read error: {e}")))?; let stage = item .as_ref() .map(|i| match &i.stage { crate::pipeline_state::Stage::Backlog => "backlog", crate::pipeline_state::Stage::Coding => "current", crate::pipeline_state::Stage::Qa => "qa", crate::pipeline_state::Stage::Merge { .. } => "merge", crate::pipeline_state::Stage::Done { .. } => "done", crate::pipeline_state::Stage::Archived { .. } => "archived", }) .unwrap_or("unknown") .to_string(); let metadata = crate::io::story_metadata::parse_front_matter(&content).ok(); return Ok(WorkItemContent { content, stage, name: metadata.as_ref().and_then(|m| m.name.clone()), agent: metadata.and_then(|m| m.agent), }); } Err(Error::WorkItemNotFound(format!( "Work item not found: {story_id}" ))) } /// Get test results for a work item. /// /// Checks in-memory workflow state first (fast path), then falls back to /// results persisted in the story file. pub fn get_test_results( project_root: &Path, story_id: &str, workflow: &crate::workflow::WorkflowState, ) -> Option { if let Some(results) = workflow.results.get(story_id) { return Some(results.clone()); } io::read_test_results_from_file(project_root, story_id) } /// Get the aggregated token cost for a specific story. pub fn get_work_item_token_cost( project_root: &Path, story_id: &str, ) -> Result { let records = io::read_token_records(project_root)?; Ok(token::aggregate_for_story(&records, story_id)) } /// Get all token usage records across all stories. pub fn get_all_token_usage(project_root: &Path) -> Result, Error> { io::read_token_records(project_root) } // ── Helpers ─────────────────────────────────────────────────────────────────── fn config_to_entries(config: &ProjectConfig) -> Vec { config .agent .iter() .map(|a| AgentConfigEntry { name: a.name.clone(), role: a.role.clone(), stage: a.stage.clone(), model: a.model.clone(), allowed_tools: a.allowed_tools.clone(), max_turns: a.max_turns, max_budget_usd: a.max_budget_usd, }) .collect() } // ── Integration tests ───────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use io::test_helpers::*; use tempfile::TempDir; // ── get_agent_config ────────────────────────────────────────────────────── #[test] fn get_agent_config_returns_default_when_no_toml() { let tmp = TempDir::new().unwrap(); make_huskies_dir(&tmp); let entries = get_agent_config(tmp.path()).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].name, "default"); } #[test] fn get_agent_config_returns_configured_agents() { let tmp = TempDir::new().unwrap(); make_project_toml( &tmp, r#" [[agent]] name = "coder-1" role = "Full-stack engineer" model = "sonnet" max_turns = 30 max_budget_usd = 5.0 "#, ); let entries = get_agent_config(tmp.path()).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].name, "coder-1"); assert_eq!(entries[0].model, Some("sonnet".to_string())); assert_eq!(entries[0].max_turns, Some(30)); } // ── get_agent_output ────────────────────────────────────────────────────── #[test] fn get_agent_output_returns_empty_when_no_log() { let tmp = TempDir::new().unwrap(); let output = get_agent_output(tmp.path(), "42_story_foo", "coder-1").unwrap(); assert_eq!(output, ""); } // ── get_work_item_content ───────────────────────────────────────────────── #[test] fn get_work_item_content_reads_from_backlog() { let tmp = TempDir::new().unwrap(); make_stage_dirs(&tmp); write_story_file( &tmp, ".huskies/work/1_backlog/42_story_foo.md", "---\nname: \"Foo Story\"\n---\n\nSome content.", ); let item = get_work_item_content(tmp.path(), "42_story_foo").unwrap(); assert!(item.content.contains("Some content.")); assert_eq!(item.stage, "backlog"); assert_eq!(item.name, Some("Foo Story".to_string())); } #[test] fn get_work_item_content_returns_not_found_for_absent_story() { let tmp = TempDir::new().unwrap(); make_stage_dirs(&tmp); let result = get_work_item_content(tmp.path(), "99_story_nonexistent"); assert!(matches!(result, Err(Error::WorkItemNotFound(_)))); } // ── get_work_item_token_cost ────────────────────────────────────────────── #[test] fn get_work_item_token_cost_returns_zero_when_no_records() { let tmp = TempDir::new().unwrap(); let summary = get_work_item_token_cost(tmp.path(), "42_story_foo").unwrap(); assert_eq!(summary.total_cost_usd, 0.0); assert!(summary.agents.is_empty()); } // ── get_all_token_usage ─────────────────────────────────────────────────── #[test] fn get_all_token_usage_returns_empty_when_no_file() { let tmp = TempDir::new().unwrap(); let records = get_all_token_usage(tmp.path()).unwrap(); assert!(records.is_empty()); } // ── get_test_results ────────────────────────────────────────────────────── #[test] fn get_test_results_returns_none_when_no_results() { let tmp = TempDir::new().unwrap(); let workflow = crate::workflow::WorkflowState::default(); let result = get_test_results(tmp.path(), "42_story_foo", &workflow); assert!(result.is_none()); } #[test] fn get_test_results_returns_in_memory_results_first() { let tmp = TempDir::new().unwrap(); let mut workflow = crate::workflow::WorkflowState::default(); workflow .record_test_results_validated( "42_story_foo".to_string(), vec![crate::workflow::TestCaseResult { name: "test1".to_string(), status: crate::workflow::TestStatus::Pass, details: None, }], vec![], ) .unwrap(); let result = get_test_results(tmp.path(), "42_story_foo", &workflow).expect("should have results"); assert_eq!(result.unit.len(), 1); assert_eq!(result.unit[0].name, "test1"); } }