huskies: merge 950
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user