huskies: merge 604_story_service_module_conventions_and_first_extraction
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
//! Agent I/O wrappers — the ONLY place in `service/agents/` that may perform
|
||||
//! filesystem reads, process invocations, or other side effects.
|
||||
//!
|
||||
//! Every function here is a thin adapter over an existing lower-level call.
|
||||
//! No business logic lives here; all branching belongs in the pure topic files
|
||||
//! or in `mod.rs`.
|
||||
use crate::agent_log::{self, LogEntry};
|
||||
use crate::agents::token_usage::{self, TokenUsageRecord};
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::worktree::{self, WorktreeListEntry};
|
||||
use std::path::Path;
|
||||
|
||||
use super::Error;
|
||||
|
||||
/// Return `true` if the story's `.md` file exists in `5_done/` or `6_archived/`.
|
||||
pub fn is_archived(project_root: &Path, story_id: &str) -> bool {
|
||||
let work = project_root.join(".huskies").join("work");
|
||||
let filename = format!("{story_id}.md");
|
||||
work.join("5_done").join(&filename).exists() || work.join("6_archived").join(&filename).exists()
|
||||
}
|
||||
|
||||
/// Read and return all log entries for the most recent session of an agent.
|
||||
///
|
||||
/// Returns `Ok(vec![])` when no log file exists yet.
|
||||
pub fn read_agent_log(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
) -> Result<Vec<LogEntry>, Error> {
|
||||
let log_path = agent_log::find_latest_log(project_root, story_id, agent_name);
|
||||
let Some(path) = log_path else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
agent_log::read_log(&path).map_err(Error::Io)
|
||||
}
|
||||
|
||||
/// Read all token usage records from the persistent JSONL file.
|
||||
///
|
||||
/// Returns an empty vec when the file does not yet exist.
|
||||
pub fn read_token_records(project_root: &Path) -> Result<Vec<TokenUsageRecord>, Error> {
|
||||
token_usage::read_all(project_root).map_err(Error::Io)
|
||||
}
|
||||
|
||||
/// Load the project configuration from `project.toml`.
|
||||
///
|
||||
/// Falls back to default config when the file is absent.
|
||||
pub fn load_config(project_root: &Path) -> Result<ProjectConfig, Error> {
|
||||
ProjectConfig::load(project_root).map_err(Error::Config)
|
||||
}
|
||||
|
||||
/// List all worktrees under `.huskies/worktrees/`.
|
||||
pub fn list_worktrees(project_root: &Path) -> Result<Vec<WorktreeListEntry>, Error> {
|
||||
worktree::list_worktrees(project_root).map_err(Error::Io)
|
||||
}
|
||||
|
||||
/// Remove the git worktree for a story by ID.
|
||||
///
|
||||
/// Loads the project config to honour teardown commands. Returns an error if
|
||||
/// the worktree directory does not exist.
|
||||
pub async fn remove_worktree(project_root: &Path, story_id: &str) -> Result<(), Error> {
|
||||
let config = load_config(project_root)?;
|
||||
worktree::remove_worktree_by_story_id(project_root, story_id, &config)
|
||||
.await
|
||||
.map_err(Error::Worktree)
|
||||
}
|
||||
|
||||
/// Read test results persisted in a story's markdown file.
|
||||
///
|
||||
/// Returns `None` when the story has no test results section.
|
||||
pub fn read_test_results_from_file(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
) -> Option<crate::workflow::StoryTestResults> {
|
||||
crate::http::workflow::read_test_results_from_story_file(project_root, story_id)
|
||||
}
|
||||
|
||||
/// Read a work item file from a pipeline stage directory.
|
||||
///
|
||||
/// Returns `Ok(Some(content))` when found, `Ok(None)` when absent.
|
||||
pub fn read_work_item_from_stage(
|
||||
work_dir: &std::path::Path,
|
||||
stage_dir: &str,
|
||||
filename: &str,
|
||||
) -> Result<Option<String>, Error> {
|
||||
let file_path = work_dir.join(stage_dir).join(filename);
|
||||
if file_path.exists() {
|
||||
let content = std::fs::read_to_string(&file_path)
|
||||
.map_err(|e| Error::Io(format!("Failed to read work item: {e}")))?;
|
||||
Ok(Some(content))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_work_dirs(tmp: &TempDir) {
|
||||
for stage in &["5_done", "6_archived"] {
|
||||
std::fs::create_dir_all(tmp.path().join(".huskies").join("work").join(stage)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// ── is_archived ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn is_archived_false_when_file_absent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
make_work_dirs(&tmp);
|
||||
assert!(!is_archived(tmp.path(), "42_story_foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_archived_true_when_in_5_done() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
make_work_dirs(&tmp);
|
||||
std::fs::write(
|
||||
tmp.path().join(".huskies/work/5_done/42_story_foo.md"),
|
||||
"---\nname: test\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(is_archived(tmp.path(), "42_story_foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_archived_true_when_in_6_archived() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
make_work_dirs(&tmp);
|
||||
std::fs::write(
|
||||
tmp.path().join(".huskies/work/6_archived/42_story_foo.md"),
|
||||
"---\nname: test\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(is_archived(tmp.path(), "42_story_foo"));
|
||||
}
|
||||
|
||||
// ── read_agent_log ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn read_agent_log_returns_empty_when_no_log() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let entries = read_agent_log(tmp.path(), "42_story_foo", "coder-1").unwrap();
|
||||
assert!(entries.is_empty());
|
||||
}
|
||||
|
||||
// ── read_token_records ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn read_token_records_returns_empty_when_no_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let records = read_token_records(tmp.path()).unwrap();
|
||||
assert!(records.is_empty());
|
||||
}
|
||||
|
||||
// ── load_config ───────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn load_config_returns_default_when_no_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join(".huskies")).unwrap();
|
||||
let config = load_config(tmp.path()).unwrap();
|
||||
// Default config has one "default" agent
|
||||
assert_eq!(config.agent.len(), 1);
|
||||
assert_eq!(config.agent[0].name, "default");
|
||||
}
|
||||
|
||||
// ── list_worktrees ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn list_worktrees_empty_when_no_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let entries = list_worktrees(tmp.path()).unwrap();
|
||||
assert!(entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_worktrees_returns_subdirs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let wt_dir = tmp.path().join(".huskies").join("worktrees");
|
||||
std::fs::create_dir_all(wt_dir.join("42_story_foo")).unwrap();
|
||||
std::fs::create_dir_all(wt_dir.join("43_story_bar")).unwrap();
|
||||
let mut entries = list_worktrees(tmp.path()).unwrap();
|
||||
entries.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0].story_id, "42_story_foo");
|
||||
assert_eq!(entries[1].story_id, "43_story_bar");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//! 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<String>,
|
||||
pub agent: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
pub model: Option<String>,
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
pub max_turns: Option<u32>,
|
||||
pub max_budget_usd: Option<f64>,
|
||||
}
|
||||
|
||||
// ── 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<String>,
|
||||
) -> Result<AgentInfo, Error> {
|
||||
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)
|
||||
}
|
||||
|
||||
/// List all agents, optionally filtering out those belonging to archived stories.
|
||||
///
|
||||
/// When `project_root` is `None` the archive filter is skipped and all agents
|
||||
/// are returned (safe default when the server is not yet fully configured).
|
||||
pub fn list_agents(pool: &AgentPool, project_root: Option<&Path>) -> Result<Vec<AgentInfo>, Error> {
|
||||
let agents = pool.list_agents().map_err(Error::Io)?;
|
||||
match project_root {
|
||||
Some(root) => Ok(selection::filter_non_archived(agents, |id| {
|
||||
io::is_archived(root, id)
|
||||
})),
|
||||
None => Ok(agents),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a git worktree for a story.
|
||||
pub async fn create_worktree(
|
||||
pool: &AgentPool,
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
) -> Result<WorktreeInfo, Error> {
|
||||
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<Vec<WorktreeListEntry>, 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<Vec<AgentConfigEntry>, 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<Vec<AgentConfigEntry>, 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<String, Error> {
|
||||
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<WorkItemContent, Error> {
|
||||
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<StoryTestResults> {
|
||||
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<TokenCostSummary, Error> {
|
||||
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<Vec<TokenUsageRecord>, Error> {
|
||||
io::read_token_records(project_root)
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn config_to_entries(config: &ProjectConfig) -> Vec<AgentConfigEntry> {
|
||||
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 crate::agents::AgentStatus;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_pool(tmp: &TempDir) -> Arc<AgentPool> {
|
||||
let (tx, _) = tokio::sync::broadcast::channel(64);
|
||||
let pool = AgentPool::new(3001, tx);
|
||||
let state = crate::state::SessionState::default();
|
||||
*state.project_root.lock().unwrap() = Some(tmp.path().to_path_buf());
|
||||
Arc::new(pool)
|
||||
}
|
||||
|
||||
fn make_work_dirs(tmp: &TempDir) {
|
||||
for stage in &["5_done", "6_archived"] {
|
||||
std::fs::create_dir_all(tmp.path().join(".huskies").join("work").join(stage)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn make_stage_dirs(tmp: &TempDir) {
|
||||
for stage in &[
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
] {
|
||||
std::fs::create_dir_all(tmp.path().join(".huskies").join("work").join(stage)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn make_project_toml(tmp: &TempDir, content: &str) {
|
||||
let sk_dir = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&sk_dir).unwrap();
|
||||
std::fs::write(sk_dir.join("project.toml"), content).unwrap();
|
||||
}
|
||||
|
||||
// ── list_agents ───────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_agents_excludes_archived_stories() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
make_work_dirs(&tmp);
|
||||
std::fs::write(
|
||||
tmp.path()
|
||||
.join(".huskies/work/6_archived/79_story_archived.md"),
|
||||
"---\nname: archived\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pool = make_pool(&tmp);
|
||||
pool.inject_test_agent("79_story_archived", "coder-1", AgentStatus::Completed);
|
||||
pool.inject_test_agent("80_story_active", "coder-1", AgentStatus::Running);
|
||||
|
||||
let agents = list_agents(&pool, Some(tmp.path())).unwrap();
|
||||
assert!(!agents.iter().any(|a| a.story_id == "79_story_archived"));
|
||||
assert!(agents.iter().any(|a| a.story_id == "80_story_active"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_agents_includes_all_when_no_project_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let pool = make_pool(&tmp);
|
||||
pool.inject_test_agent("42_story_whatever", "coder-1", AgentStatus::Completed);
|
||||
|
||||
let agents = list_agents(&pool, None).unwrap();
|
||||
assert!(agents.iter().any(|a| a.story_id == "42_story_whatever"));
|
||||
}
|
||||
|
||||
// ── get_agent_config ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn get_agent_config_returns_default_when_no_toml() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join(".huskies")).unwrap();
|
||||
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);
|
||||
std::fs::write(
|
||||
tmp.path().join(".huskies/work/1_backlog/42_story_foo.md"),
|
||||
"---\nname: \"Foo Story\"\n---\n\nSome content.",
|
||||
)
|
||||
.unwrap();
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Pure agent selection and filtering logic — no I/O, no side effects.
|
||||
//!
|
||||
//! All functions in this module are pure: they take data, transform it, and
|
||||
//! return a result without touching the filesystem, network, or any mutable
|
||||
//! global state. This makes them fast to test without tempdirs or async runtimes.
|
||||
use crate::agent_log::LogEntry;
|
||||
use crate::agents::AgentInfo;
|
||||
|
||||
/// Filter a list of agents, removing any whose story is archived.
|
||||
///
|
||||
/// `is_archived` is a predicate injected by the caller — typically a closure
|
||||
/// over the project root that calls `io::is_archived`. This keeps the function
|
||||
/// pure: it never touches the filesystem itself.
|
||||
pub fn filter_non_archived<F>(agents: Vec<AgentInfo>, is_archived: F) -> Vec<AgentInfo>
|
||||
where
|
||||
F: Fn(&str) -> bool,
|
||||
{
|
||||
agents
|
||||
.into_iter()
|
||||
.filter(|info| !is_archived(&info.story_id))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Concatenate the text of all `output` events from an agent log.
|
||||
///
|
||||
/// Non-output events (status, done, error, agent_json, thinking) are silently
|
||||
/// skipped. Returns an empty string when `entries` is empty or contains no
|
||||
/// output events.
|
||||
pub fn collect_output_text(entries: &[LogEntry]) -> String {
|
||||
entries
|
||||
.iter()
|
||||
.filter(|e| e.event.get("type").and_then(|t| t.as_str()) == Some("output"))
|
||||
.filter_map(|e| {
|
||||
e.event
|
||||
.get("text")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::AgentStatus;
|
||||
|
||||
fn make_agent(story_id: &str) -> AgentInfo {
|
||||
AgentInfo {
|
||||
story_id: story_id.to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
status: AgentStatus::Running,
|
||||
session_id: None,
|
||||
worktree_path: None,
|
||||
base_branch: None,
|
||||
completion: None,
|
||||
log_session_id: None,
|
||||
throttled: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_log_entry(event_type: &str, text: Option<&str>) -> LogEntry {
|
||||
let mut obj = serde_json::Map::new();
|
||||
obj.insert(
|
||||
"type".to_string(),
|
||||
serde_json::Value::String(event_type.to_string()),
|
||||
);
|
||||
if let Some(t) = text {
|
||||
obj.insert("text".to_string(), serde_json::Value::String(t.to_string()));
|
||||
}
|
||||
LogEntry {
|
||||
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||
event: serde_json::Value::Object(obj),
|
||||
}
|
||||
}
|
||||
|
||||
// ── filter_non_archived ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn filter_keeps_non_archived_agents() {
|
||||
let agents = vec![make_agent("10_active"), make_agent("11_active")];
|
||||
let result = filter_non_archived(agents, |_| false);
|
||||
assert_eq!(result.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_removes_archived_agents() {
|
||||
let agents = vec![make_agent("10_archived"), make_agent("11_active")];
|
||||
let result = filter_non_archived(agents, |id| id == "10_archived");
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].story_id, "11_active");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_removes_all_when_all_archived() {
|
||||
let agents = vec![make_agent("10_a"), make_agent("11_b")];
|
||||
let result = filter_non_archived(agents, |_| true);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_returns_empty_for_empty_input() {
|
||||
let result = filter_non_archived(vec![], |_| false);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_preserves_order() {
|
||||
let agents = vec![
|
||||
make_agent("1_a"),
|
||||
make_agent("2_b"),
|
||||
make_agent("3_c"),
|
||||
make_agent("4_d"),
|
||||
];
|
||||
let result = filter_non_archived(agents, |id| id == "2_b");
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0].story_id, "1_a");
|
||||
assert_eq!(result[1].story_id, "3_c");
|
||||
assert_eq!(result[2].story_id, "4_d");
|
||||
}
|
||||
|
||||
// ── collect_output_text ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn collect_output_text_empty_entries() {
|
||||
let result = collect_output_text(&[]);
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_output_text_skips_non_output_events() {
|
||||
let entries = vec![
|
||||
make_log_entry("status", Some("running")),
|
||||
make_log_entry("done", None),
|
||||
];
|
||||
let result = collect_output_text(&entries);
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_output_text_concatenates_output_events() {
|
||||
let entries = vec![
|
||||
make_log_entry("output", Some("Hello ")),
|
||||
make_log_entry("output", Some("world\n")),
|
||||
];
|
||||
let result = collect_output_text(&entries);
|
||||
assert_eq!(result, "Hello world\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_output_text_skips_output_without_text_field() {
|
||||
let entry = LogEntry {
|
||||
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||
event: serde_json::json!({"type": "output"}),
|
||||
};
|
||||
let result = collect_output_text(&[entry]);
|
||||
assert_eq!(result, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_output_text_mixed_event_types() {
|
||||
let entries = vec![
|
||||
make_log_entry("status", Some("running")),
|
||||
make_log_entry("output", Some("line1\n")),
|
||||
make_log_entry("agent_json", None),
|
||||
make_log_entry("output", Some("line2\n")),
|
||||
make_log_entry("done", None),
|
||||
];
|
||||
let result = collect_output_text(&entries);
|
||||
assert_eq!(result, "line1\nline2\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Pure token usage aggregation — no I/O, no side effects.
|
||||
//!
|
||||
//! Functions here take slices of `TokenUsageRecord` (already loaded by `io.rs`)
|
||||
//! and compute summaries. Tests cover every branch without touching the filesystem.
|
||||
use crate::agents::token_usage::TokenUsageRecord;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Per-agent cost breakdown entry.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AgentTokenCost {
|
||||
pub agent_name: String,
|
||||
pub model: Option<String>,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_creation_input_tokens: u64,
|
||||
pub cache_read_input_tokens: u64,
|
||||
pub total_cost_usd: f64,
|
||||
}
|
||||
|
||||
/// Aggregated token cost for a story.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TokenCostSummary {
|
||||
pub total_cost_usd: f64,
|
||||
pub agents: Vec<AgentTokenCost>,
|
||||
}
|
||||
|
||||
/// Aggregate token usage records for a single story.
|
||||
///
|
||||
/// Records for other stories are ignored. The returned `agents` list is sorted
|
||||
/// alphabetically by `agent_name` for deterministic output. Returns a zero-cost
|
||||
/// summary when no records match the given `story_id`.
|
||||
pub fn aggregate_for_story(records: &[TokenUsageRecord], story_id: &str) -> TokenCostSummary {
|
||||
let mut agent_map: HashMap<String, AgentTokenCost> = HashMap::new();
|
||||
let mut total_cost_usd = 0.0_f64;
|
||||
|
||||
for record in records.iter().filter(|r| r.story_id == story_id) {
|
||||
total_cost_usd += record.usage.total_cost_usd;
|
||||
let entry = agent_map
|
||||
.entry(record.agent_name.clone())
|
||||
.or_insert_with(|| AgentTokenCost {
|
||||
agent_name: record.agent_name.clone(),
|
||||
model: record.model.clone(),
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_cost_usd: 0.0,
|
||||
});
|
||||
entry.input_tokens += record.usage.input_tokens;
|
||||
entry.output_tokens += record.usage.output_tokens;
|
||||
entry.cache_creation_input_tokens += record.usage.cache_creation_input_tokens;
|
||||
entry.cache_read_input_tokens += record.usage.cache_read_input_tokens;
|
||||
entry.total_cost_usd += record.usage.total_cost_usd;
|
||||
}
|
||||
|
||||
let mut agents: Vec<AgentTokenCost> = agent_map.into_values().collect();
|
||||
agents.sort_by(|a, b| a.agent_name.cmp(&b.agent_name));
|
||||
|
||||
TokenCostSummary {
|
||||
total_cost_usd,
|
||||
agents,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::TokenUsage;
|
||||
|
||||
fn make_record(story_id: &str, agent: &str, cost: f64) -> TokenUsageRecord {
|
||||
TokenUsageRecord {
|
||||
story_id: story_id.to_string(),
|
||||
agent_name: agent.to_string(),
|
||||
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||
model: None,
|
||||
usage: TokenUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 10,
|
||||
cache_read_input_tokens: 20,
|
||||
total_cost_usd: cost,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_returns_zero_when_no_records() {
|
||||
let summary = aggregate_for_story(&[], "42_story_foo");
|
||||
assert_eq!(summary.total_cost_usd, 0.0);
|
||||
assert!(summary.agents.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_filters_to_story_id() {
|
||||
let records = vec![
|
||||
make_record("42_story_foo", "coder-1", 1.0),
|
||||
make_record("99_story_other", "coder-1", 5.0),
|
||||
];
|
||||
let summary = aggregate_for_story(&records, "42_story_foo");
|
||||
assert!((summary.total_cost_usd - 1.0).abs() < f64::EPSILON);
|
||||
assert_eq!(summary.agents.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_sums_tokens_per_agent() {
|
||||
let records = vec![
|
||||
make_record("42_story_foo", "coder-1", 1.0),
|
||||
make_record("42_story_foo", "coder-1", 2.0),
|
||||
];
|
||||
let summary = aggregate_for_story(&records, "42_story_foo");
|
||||
assert!((summary.total_cost_usd - 3.0).abs() < f64::EPSILON);
|
||||
assert_eq!(summary.agents.len(), 1);
|
||||
assert_eq!(summary.agents[0].input_tokens, 200);
|
||||
assert_eq!(summary.agents[0].output_tokens, 100);
|
||||
assert!((summary.agents[0].total_cost_usd - 3.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_splits_by_agent() {
|
||||
let records = vec![
|
||||
make_record("42_story_foo", "coder-1", 1.0),
|
||||
make_record("42_story_foo", "qa", 0.5),
|
||||
];
|
||||
let summary = aggregate_for_story(&records, "42_story_foo");
|
||||
assert!((summary.total_cost_usd - 1.5).abs() < f64::EPSILON);
|
||||
assert_eq!(summary.agents.len(), 2);
|
||||
// sorted alphabetically
|
||||
assert_eq!(summary.agents[0].agent_name, "coder-1");
|
||||
assert_eq!(summary.agents[1].agent_name, "qa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_sorts_agents_alphabetically() {
|
||||
let records = vec![
|
||||
make_record("42_story_foo", "z-agent", 1.0),
|
||||
make_record("42_story_foo", "a-agent", 1.0),
|
||||
make_record("42_story_foo", "m-agent", 1.0),
|
||||
];
|
||||
let summary = aggregate_for_story(&records, "42_story_foo");
|
||||
assert_eq!(summary.agents[0].agent_name, "a-agent");
|
||||
assert_eq!(summary.agents[1].agent_name, "m-agent");
|
||||
assert_eq!(summary.agents[2].agent_name, "z-agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_returns_zero_when_no_matching_story() {
|
||||
let records = vec![make_record("99_other", "coder-1", 5.0)];
|
||||
let summary = aggregate_for_story(&records, "42_story_foo");
|
||||
assert_eq!(summary.total_cost_usd, 0.0);
|
||||
assert!(summary.agents.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_preserves_model_from_first_record() {
|
||||
let mut r = make_record("42_story_foo", "coder-1", 1.0);
|
||||
r.model = Some("claude-sonnet".to_string());
|
||||
let summary = aggregate_for_story(&[r], "42_story_foo");
|
||||
assert_eq!(summary.agents[0].model, Some("claude-sonnet".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Service layer — domain logic extracted from HTTP handlers.
|
||||
//!
|
||||
//! Each sub-module follows the conventions documented in
|
||||
//! `docs/architecture/service-modules.md`:
|
||||
//! - `mod.rs` orchestrates and owns the typed `Error` type
|
||||
//! - `io.rs` is the only file that performs side effects
|
||||
//! - Topic-named pure files contain branching logic with no I/O
|
||||
pub mod agents;
|
||||
Reference in New Issue
Block a user