huskies: merge 950

This commit is contained in:
dave
2026-05-13 08:41:57 +00:00
parent 7491eec257
commit 4a8ed4348b
38 changed files with 354 additions and 4329 deletions
-39
View File
@@ -7,7 +7,6 @@
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;
@@ -48,22 +47,6 @@ 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.
@@ -208,26 +191,4 @@ mod tests {
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");
}
}
+3 -41
View File
@@ -17,7 +17,6 @@ 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;
@@ -35,8 +34,6 @@ pub enum Error {
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.
@@ -48,7 +45,6 @@ impl std::fmt::Display for Error {
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}"),
}
@@ -62,8 +58,6 @@ impl std::fmt::Display for Error {
pub struct WorkItemContent {
pub content: String,
pub stage: crate::pipeline_state::Stage,
/// Whether the item is frozen — orthogonal to [`Self::stage`].
pub frozen: bool,
pub name: Option<String>,
pub agent: Option<String>,
}
@@ -117,41 +111,12 @@ pub async fn stop_agent(
.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<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.
@@ -207,7 +172,6 @@ pub fn get_work_item_content(
return Ok(WorkItemContent {
content,
stage: stage.clone(),
frozen: false,
name: crdt_name.clone(),
agent: crdt_agent.clone(),
});
@@ -218,14 +182,13 @@ pub fn get_work_item_content(
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, frozen) = match item.as_ref() {
Some(i) => (i.stage.clone(), i.is_frozen()),
None => (Stage::Upcoming, false),
let stage = match item.as_ref() {
Some(i) => i.stage.clone(),
None => Stage::Upcoming,
};
return Ok(WorkItemContent {
content,
stage,
frozen,
name: crdt_name,
agent: crdt_agent,
});
@@ -359,7 +322,6 @@ max_budget_usd = 5.0
let item = get_work_item_content(tmp.path(), "42_story_foo").unwrap();
assert!(item.content.contains("Some content."));
assert_eq!(item.stage, crate::pipeline_state::Stage::Backlog);
assert!(!item.frozen);
assert_eq!(item.name, Some("Foo Story".to_string()));
}
+1 -1
View File
@@ -44,7 +44,7 @@ impl std::fmt::Display for Error {
// ── Types ─────────────────────────────────────────────────────────────────────
/// A summary of an Anthropic model as returned by the `/v1/models` endpoint.
#[derive(Serialize, Deserialize, Debug, PartialEq, poem_openapi::Object)]
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct ModelSummary {
pub id: String,
pub context_window: u64,
+5
View File
@@ -16,6 +16,7 @@ pub(super) async fn read_file(path: String, state: &SessionState) -> Result<Stri
.map_err(Error::Filesystem)
}
#[allow(dead_code)]
pub(super) async fn write_file(
path: String,
content: String,
@@ -26,6 +27,7 @@ pub(super) async fn write_file(
.map_err(Error::Filesystem)
}
#[allow(dead_code)]
pub(super) async fn list_directory(
path: String,
state: &SessionState,
@@ -41,6 +43,7 @@ pub(super) async fn list_directory_absolute(path: String) -> Result<Vec<FileEntr
.map_err(Error::Filesystem)
}
#[allow(dead_code)]
pub(super) async fn create_directory_absolute(path: String) -> Result<(), Error> {
crate::io::fs::create_directory_absolute(path)
.await
@@ -58,6 +61,7 @@ pub(super) async fn list_project_files(state: &SessionState) -> Result<Vec<Strin
.map_err(Error::Filesystem)
}
#[allow(dead_code)]
pub(super) async fn search_files(
query: String,
state: &SessionState,
@@ -67,6 +71,7 @@ pub(super) async fn search_files(
.map_err(Error::Filesystem)
}
#[allow(dead_code)]
pub(super) async fn exec_shell(
command: String,
args: Vec<String>,
+5
View File
@@ -65,12 +65,14 @@ pub async fn read_file(path: String, state: &SessionState) -> Result<String, Err
}
/// Write a file to the project root, creating parent directories as needed.
#[allow(dead_code)]
pub async fn write_file(path: String, content: String, state: &SessionState) -> Result<(), Error> {
validate_path(&path)?;
io::write_file(path, content, state).await
}
/// List directory entries at a project-relative path.
#[allow(dead_code)]
pub async fn list_directory(path: String, state: &SessionState) -> Result<Vec<FileEntry>, Error> {
io::list_directory(path, state).await
}
@@ -81,6 +83,7 @@ pub async fn list_directory_absolute(path: String) -> Result<Vec<FileEntry>, Err
}
/// Create a directory (and all parents) at an absolute path.
#[allow(dead_code)]
pub async fn create_directory_absolute(path: String) -> Result<(), Error> {
io::create_directory_absolute(path).await
}
@@ -96,11 +99,13 @@ pub async fn list_project_files(state: &SessionState) -> Result<Vec<String>, Err
}
/// Search the project for files whose contents contain `query`.
#[allow(dead_code)]
pub async fn search_files(query: String, state: &SessionState) -> Result<Vec<SearchResult>, Error> {
io::search_files(query, state).await
}
/// Execute an allowlisted shell command in the project root directory.
#[allow(dead_code)]
pub async fn exec_shell(
command: String,
args: Vec<String>,
+1 -2
View File
@@ -6,7 +6,6 @@
//! write path in `mod.rs` + `io.rs`).
use crate::config::ProjectConfig;
use poem_openapi::Object;
use serde::{Deserialize, Serialize};
/// Project-level settings exposed via `GET /api/settings` and `PUT /api/settings`.
@@ -14,7 +13,7 @@ use serde::{Deserialize, Serialize};
/// Only contains the scalar fields of `ProjectConfig` — array sections
/// (`[[component]]`, `[[agent]]`, `[watcher]`) are preserved in the TOML file
/// and are not editable through this API.
#[derive(Debug, Object, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectSettings {
/// Project-wide default QA mode: "server", "agent", or "human". Default: "server".
pub default_qa: String,
+28 -41
View File
@@ -52,35 +52,6 @@ impl std::fmt::Display for Error {
}
}
// ── Public API — used by HTTP handlers ────────────────────────────────────────
/// Load and return the current wizard state.
///
/// # Errors
/// - [`Error::NotActive`] if `wizard_state.json` does not exist.
pub fn get_state(root: &Path) -> Result<WizardState, Error> {
io::load(root).ok_or(Error::NotActive)
}
/// Set content for `step` and mark it as awaiting confirmation.
///
/// Content is staged in `wizard_state.json` but **not** written to disk until
/// [`confirm`] is called.
///
/// # Errors
/// - [`Error::NotActive`] if no wizard is active.
/// - [`Error::PersistenceFailure`] if saving state fails.
pub fn set_step_content(
root: &Path,
step: WizardStep,
content: Option<String>,
) -> Result<WizardState, Error> {
let mut state = io::load(root).ok_or(Error::NotActive)?;
state.set_step_status(step, StepStatus::AwaitingConfirmation, content);
io::save(&state, root)?;
Ok(state)
}
/// Mark `step` as confirmed and advance the wizard.
///
/// Enforces sequential ordering — only the current step may be confirmed.
@@ -113,18 +84,6 @@ pub fn mark_step_skipped(root: &Path, step: WizardStep) -> Result<WizardState, E
Ok(state)
}
/// Mark `step` as generating (agent is working on it).
///
/// # Errors
/// - [`Error::NotActive`] if no wizard is active.
/// - [`Error::PersistenceFailure`] if saving state fails.
pub fn mark_step_generating(root: &Path, step: WizardStep) -> Result<WizardState, Error> {
let mut state = io::load(root).ok_or(Error::NotActive)?;
state.set_step_status(step, StepStatus::Generating, None);
io::save(&state, root)?;
Ok(state)
}
// ── Public API — used by MCP tool handlers ─────────────────────────────────
/// Return the current wizard state as a human-readable summary.
@@ -300,6 +259,34 @@ pub fn retry(root: &Path) -> Result<String, Error> {
))
}
/// Return the current wizard state.
///
/// # Errors
/// - [`Error::NotActive`] if no wizard is active.
#[cfg(test)]
pub fn get_state(root: &Path) -> Result<WizardState, Error> {
io::load(root).ok_or(Error::NotActive)
}
/// Stage `content` for `step` and transition its status to `AwaitingConfirmation`.
///
/// Content is not written to disk until [`confirm`] is called.
///
/// # Errors
/// - [`Error::NotActive`] if no wizard is active.
/// - [`Error::PersistenceFailure`] if saving state fails.
#[cfg(test)]
pub fn set_step_content(
root: &Path,
step: WizardStep,
content: Option<String>,
) -> Result<WizardState, Error> {
let mut state = io::load(root).ok_or(Error::NotActive)?;
state.set_step_status(step, StepStatus::AwaitingConfirmation, content);
io::save(&state, root)?;
Ok(state)
}
/// Write `content` to `path` if no real content already exists there.
///
/// Thin public wrapper around `io::write_step_file` for use by HTTP/chat