huskies: merge 950
This commit is contained in:
@@ -17,7 +17,6 @@ ignore = { workspace = true }
|
||||
mime_guess = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
poem = { workspace = true, features = ["websocket"] }
|
||||
poem-openapi = { workspace = true, features = ["swagger-ui"] }
|
||||
portable-pty = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "stream", "form"] }
|
||||
rust-embed = { workspace = true }
|
||||
|
||||
@@ -70,8 +70,8 @@ mod wire;
|
||||
pub use auth::{add_join_token, init_token_auth, init_trusted_keys};
|
||||
pub(crate) use client::connect_and_sync;
|
||||
pub use client::{RENDEZVOUS_ERROR_THRESHOLD, spawn_rendezvous_client};
|
||||
pub use rpc::init_rpc_context;
|
||||
pub(crate) use rpc::try_handle_rpc_text;
|
||||
pub use rpc::{init_rpc_agents, init_rpc_context};
|
||||
pub use server::crdt_sync_handler;
|
||||
|
||||
// Test-only re-export used by `crdt_snapshot` tests.
|
||||
|
||||
@@ -37,6 +37,7 @@ use super::rpc_contract::{
|
||||
SetAnthropicApiKeyParams, SetModelPreferenceParams,
|
||||
};
|
||||
use super::wire::RpcFrame;
|
||||
use crate::agents::AgentPool;
|
||||
use crate::state::SessionState;
|
||||
use crate::store::JsonFileStore;
|
||||
use crate::workflow::WorkflowState;
|
||||
@@ -57,6 +58,9 @@ pub struct RpcState {
|
||||
/// Global RPC context, initialised once at server startup via [`init_rpc_context`].
|
||||
static RPC_CTX: OnceLock<RpcState> = OnceLock::new();
|
||||
|
||||
/// Global agent pool, registered once at startup via [`init_rpc_agents`].
|
||||
static RPC_AGENTS: OnceLock<Arc<AgentPool>> = OnceLock::new();
|
||||
|
||||
/// Register the global RPC context.
|
||||
///
|
||||
/// Must be called before any handler that accesses project state is invoked.
|
||||
@@ -73,6 +77,14 @@ pub fn init_rpc_context(
|
||||
});
|
||||
}
|
||||
|
||||
/// Register the agent pool for use in RPC handlers.
|
||||
///
|
||||
/// Must be called after [`AgentPool`] is constructed (after `init_rpc_context`).
|
||||
/// Subsequent calls are silently ignored (OnceLock semantics).
|
||||
pub fn init_rpc_agents(agents: Arc<AgentPool>) {
|
||||
let _ = RPC_AGENTS.set(agents);
|
||||
}
|
||||
|
||||
/// Static registry mapping method names to handlers.
|
||||
///
|
||||
/// Add new handlers here. The registry is a plain slice — linear scan is
|
||||
@@ -145,6 +157,18 @@ static HANDLERS: &[(&str, Handler)] = &[
|
||||
("project.forget", |p| Box::pin(handle_project_forget(p))),
|
||||
("bot_config.save", |p| Box::pin(handle_bot_config_save(p))),
|
||||
("chat.cancel", |p| Box::pin(handle_chat_cancel(p))),
|
||||
// ── formerly REST-only endpoints, now RPC ────────────────────────────────
|
||||
("io.read_file", |p| Box::pin(handle_io_read_file(p))),
|
||||
("io.list_directory_absolute", |p| {
|
||||
Box::pin(handle_io_list_directory_absolute(p))
|
||||
}),
|
||||
("bot.command", |p| Box::pin(handle_bot_command(p))),
|
||||
("agents.start", |p| Box::pin(handle_agents_start(p))),
|
||||
("agents.stop", |p| Box::pin(handle_agents_stop(p))),
|
||||
("wizard.confirm_step", |p| {
|
||||
Box::pin(handle_wizard_confirm_step(p))
|
||||
}),
|
||||
("wizard.skip_step", |p| Box::pin(handle_wizard_skip_step(p))),
|
||||
];
|
||||
|
||||
// ── typed-write helper macros ───────────────────────────────────────────────
|
||||
@@ -778,6 +802,194 @@ async fn handle_chat_cancel(_params: Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
// ── formerly REST-only handlers ──────────────────────────────────────────────
|
||||
|
||||
/// Handler for `io.read_file`. Reads a project-scoped file and returns its content.
|
||||
///
|
||||
/// Parameters: `{ "path": string }`.
|
||||
async fn handle_io_read_file(params: Value) -> Value {
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
let Some(path) = params.get("path").and_then(|v| v.as_str()) else {
|
||||
return err_json("missing path");
|
||||
};
|
||||
match crate::service::file_io::read_file(path.to_string(), &ctx.state).await {
|
||||
Ok(content) => Value::String(content),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `io.list_directory_absolute`. Lists entries at an absolute path.
|
||||
///
|
||||
/// Parameters: `{ "path": string }`.
|
||||
async fn handle_io_list_directory_absolute(params: Value) -> Value {
|
||||
let Some(path) = params.get("path").and_then(|v| v.as_str()) else {
|
||||
return err_json("missing path");
|
||||
};
|
||||
match crate::service::file_io::list_directory_absolute(path.to_string()).await {
|
||||
Ok(entries) => serde_json::to_value(entries).unwrap_or(Value::Array(vec![])),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `bot.command`. Dispatches a slash command and returns markdown output.
|
||||
///
|
||||
/// Parameters: `{ "command": string, "args"?: string }`.
|
||||
async fn handle_bot_command(params: Value) -> Value {
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
let Some(command) = params.get("command").and_then(|v| v.as_str()) else {
|
||||
return err_json("missing command");
|
||||
};
|
||||
let args = params.get("args").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let Ok(root) = ctx.state.get_project_root() else {
|
||||
return err_json("No project open");
|
||||
};
|
||||
let Some(agents) = RPC_AGENTS.get() else {
|
||||
return err_json("Agent pool not initialised");
|
||||
};
|
||||
match crate::service::bot_command::execute(command, args, &root, agents).await {
|
||||
Ok(response) => serde_json::json!({"response": response}),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `agents.start`. Starts an agent for a story.
|
||||
///
|
||||
/// Parameters: `{ "story_id": string, "agent_name"?: string }`.
|
||||
async fn handle_agents_start(params: Value) -> Value {
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
let Some(story_id) = params.get("story_id").and_then(|v| v.as_str()) else {
|
||||
return err_json("missing story_id");
|
||||
};
|
||||
let agent_name = params.get("agent_name").and_then(|v| v.as_str());
|
||||
let Ok(root) = ctx.state.get_project_root() else {
|
||||
return err_json("No project open");
|
||||
};
|
||||
let Some(agents) = RPC_AGENTS.get() else {
|
||||
return err_json("Agent pool not initialised");
|
||||
};
|
||||
match crate::service::agents::start_agent(agents, &root, story_id, agent_name, None, None).await
|
||||
{
|
||||
Ok(info) => serde_json::json!({
|
||||
"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,
|
||||
"base_branch": info.base_branch,
|
||||
"log_session_id": info.log_session_id,
|
||||
}),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `agents.stop`. Stops a running agent.
|
||||
///
|
||||
/// Parameters: `{ "story_id": string, "agent_name": string }`.
|
||||
async fn handle_agents_stop(params: Value) -> Value {
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
let Some(story_id) = params.get("story_id").and_then(|v| v.as_str()) else {
|
||||
return err_json("missing story_id");
|
||||
};
|
||||
let Some(agent_name) = params.get("agent_name").and_then(|v| v.as_str()) else {
|
||||
return err_json("missing agent_name");
|
||||
};
|
||||
let Ok(root) = ctx.state.get_project_root() else {
|
||||
return err_json("No project open");
|
||||
};
|
||||
let Some(agents) = RPC_AGENTS.get() else {
|
||||
return err_json("Agent pool not initialised");
|
||||
};
|
||||
match crate::service::agents::stop_agent(agents, &root, story_id, agent_name).await {
|
||||
Ok(()) => Value::Bool(true),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialise a [`crate::io::wizard::WizardState`] into the frontend's expected JSON shape.
|
||||
fn wizard_state_to_value(state: &crate::io::wizard::WizardState) -> Value {
|
||||
let steps: Vec<Value> = state
|
||||
.steps
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let step_str = serde_json::to_value(s.step)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_default();
|
||||
let status_str = serde_json::to_value(&s.status)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"step": step_str,
|
||||
"label": s.step.label(),
|
||||
"status": status_str,
|
||||
"content": s.content,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({
|
||||
"steps": steps,
|
||||
"current_step_index": state.current_step_index(),
|
||||
"completed": state.completed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Handler for `wizard.confirm_step`. Confirms the current wizard step.
|
||||
///
|
||||
/// Parameters: `{ "step": string }`.
|
||||
async fn handle_wizard_confirm_step(params: Value) -> Value {
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
let Some(step_str) = params.get("step").and_then(|v| v.as_str()) else {
|
||||
return err_json("missing step");
|
||||
};
|
||||
let Ok(root) = ctx.state.get_project_root() else {
|
||||
return err_json("No project open");
|
||||
};
|
||||
let quoted = format!("\"{step_str}\"");
|
||||
let step = match serde_json::from_str::<crate::io::wizard::WizardStep>("ed) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return err_json(format!("Unknown wizard step: {step_str}")),
|
||||
};
|
||||
match crate::service::wizard::mark_step_confirmed(&root, step) {
|
||||
Ok(state) => wizard_state_to_value(&state),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler for `wizard.skip_step`. Skips the current wizard step.
|
||||
///
|
||||
/// Parameters: `{ "step": string }`.
|
||||
async fn handle_wizard_skip_step(params: Value) -> Value {
|
||||
let Some(ctx) = RPC_CTX.get() else {
|
||||
return err_json("RPC context not initialised");
|
||||
};
|
||||
let Some(step_str) = params.get("step").and_then(|v| v.as_str()) else {
|
||||
return err_json("missing step");
|
||||
};
|
||||
let Ok(root) = ctx.state.get_project_root() else {
|
||||
return err_json("No project open");
|
||||
};
|
||||
let quoted = format!("\"{step_str}\"");
|
||||
let step = match serde_json::from_str::<crate::io::wizard::WizardStep>("ed) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return err_json(format!("Unknown wizard step: {step_str}")),
|
||||
};
|
||||
match crate::service::wizard::mark_step_skipped(&root, step) {
|
||||
Ok(state) => wizard_state_to_value(&state),
|
||||
Err(e) => err_json(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// ── dispatch ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Dispatch an incoming RPC method call to the registered handler.
|
||||
|
||||
@@ -1,551 +0,0 @@
|
||||
//! HTTP agent endpoints — thin adapters over `service::agents`.
|
||||
//!
|
||||
//! Each handler: extracts payload → calls `service::agents::X` → shapes
|
||||
//! response DTO → returns HTTP result. No filesystem access, no inline
|
||||
//! validation, no process invocations.
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found};
|
||||
use crate::service::agents::{self as svc, AgentConfigEntry, WorkItemContent};
|
||||
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
|
||||
use poem::http::StatusCode;
|
||||
use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Tags)]
|
||||
enum AgentsTags {
|
||||
Agents,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct StartAgentPayload {
|
||||
story_id: String,
|
||||
agent_name: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
worktree_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Object, Serialize)]
|
||||
struct AgentConfigInfoResponse {
|
||||
name: String,
|
||||
role: String,
|
||||
stage: Option<String>,
|
||||
model: Option<String>,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
max_turns: Option<u32>,
|
||||
max_budget_usd: Option<f64>,
|
||||
}
|
||||
|
||||
impl From<AgentConfigEntry> for AgentConfigInfoResponse {
|
||||
fn from(e: AgentConfigEntry) -> Self {
|
||||
Self {
|
||||
name: e.name,
|
||||
role: e.role,
|
||||
stage: e.stage,
|
||||
model: e.model,
|
||||
allowed_tools: e.allowed_tools,
|
||||
max_turns: e.max_turns,
|
||||
max_budget_usd: e.max_budget_usd,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// Response for the work item content endpoint.
|
||||
#[derive(Object, Serialize)]
|
||||
struct WorkItemContentResponse {
|
||||
content: String,
|
||||
stage: String,
|
||||
name: Option<String>,
|
||||
agent: Option<String>,
|
||||
}
|
||||
|
||||
impl From<WorkItemContent> for WorkItemContentResponse {
|
||||
fn from(w: WorkItemContent) -> Self {
|
||||
use crate::pipeline_state::Stage;
|
||||
// Frozen items report "frozen" so the UI can render them distinctly;
|
||||
// otherwise we emit the canonical clean stage directory name.
|
||||
let stage = if w.frozen {
|
||||
"frozen".to_string()
|
||||
} else {
|
||||
match &w.stage {
|
||||
Stage::Coding => "current".to_string(),
|
||||
other => other.dir_name().to_string(),
|
||||
}
|
||||
};
|
||||
Self {
|
||||
content: w.content,
|
||||
stage,
|
||||
name: w.name,
|
||||
agent: w.agent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single test case result for the OpenAPI response.
|
||||
#[derive(Object, Serialize)]
|
||||
struct TestCaseResultResponse {
|
||||
name: String,
|
||||
status: String,
|
||||
details: Option<String>,
|
||||
}
|
||||
|
||||
/// Response for the work item test results endpoint.
|
||||
#[derive(Object, Serialize)]
|
||||
struct TestResultsResponse {
|
||||
unit: Vec<TestCaseResultResponse>,
|
||||
integration: Vec<TestCaseResultResponse>,
|
||||
}
|
||||
|
||||
impl TestResultsResponse {
|
||||
fn from_story_results(results: &StoryTestResults) -> Self {
|
||||
Self {
|
||||
unit: results.unit.iter().map(Self::map_case).collect(),
|
||||
integration: results.integration.iter().map(Self::map_case).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_case(tc: &TestCaseResult) -> TestCaseResultResponse {
|
||||
TestCaseResultResponse {
|
||||
name: tc.name.clone(),
|
||||
status: match tc.status {
|
||||
TestStatus::Pass => "pass".to_string(),
|
||||
TestStatus::Fail => "fail".to_string(),
|
||||
},
|
||||
details: tc.details.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response for the agent output endpoint.
|
||||
#[derive(Object, Serialize)]
|
||||
struct AgentOutputResponse {
|
||||
output: String,
|
||||
}
|
||||
|
||||
/// Per-agent cost breakdown entry for the token cost endpoint.
|
||||
#[derive(Object, Serialize)]
|
||||
struct AgentCostEntry {
|
||||
agent_name: String,
|
||||
model: Option<String>,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cache_creation_input_tokens: u64,
|
||||
cache_read_input_tokens: u64,
|
||||
total_cost_usd: f64,
|
||||
}
|
||||
|
||||
/// Response for the work item token cost endpoint.
|
||||
#[derive(Object, Serialize)]
|
||||
struct TokenCostResponse {
|
||||
total_cost_usd: f64,
|
||||
agents: Vec<AgentCostEntry>,
|
||||
}
|
||||
|
||||
/// A single token usage record in the all-usage response.
|
||||
#[derive(Object, Serialize)]
|
||||
struct TokenUsageRecordResponse {
|
||||
story_id: String,
|
||||
agent_name: String,
|
||||
model: Option<String>,
|
||||
timestamp: String,
|
||||
input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
cache_creation_input_tokens: u64,
|
||||
cache_read_input_tokens: u64,
|
||||
total_cost_usd: f64,
|
||||
}
|
||||
|
||||
/// Response for the all token usage endpoint.
|
||||
#[derive(Object, Serialize)]
|
||||
struct AllTokenUsageResponse {
|
||||
records: Vec<TokenUsageRecordResponse>,
|
||||
}
|
||||
|
||||
/// Map a `service::agents::Error` to a Poem HTTP error with the correct status.
|
||||
fn map_svc_error(err: svc::Error) -> poem::Error {
|
||||
match err {
|
||||
svc::Error::AgentNotFound(_) => {
|
||||
poem::Error::from_string(err.to_string(), StatusCode::NOT_FOUND)
|
||||
}
|
||||
svc::Error::WorkItemNotFound(_) => {
|
||||
poem::Error::from_string(err.to_string(), StatusCode::NOT_FOUND)
|
||||
}
|
||||
svc::Error::Worktree(_) => {
|
||||
poem::Error::from_string(err.to_string(), StatusCode::BAD_REQUEST)
|
||||
}
|
||||
svc::Error::Config(_) => poem::Error::from_string(err.to_string(), StatusCode::BAD_REQUEST),
|
||||
svc::Error::Io(_) => {
|
||||
poem::Error::from_string(err.to_string(), StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenAPI endpoint group for agent management (start, stop, list, inspect).
|
||||
pub struct AgentsApi {
|
||||
pub ctx: Arc<AppContext>,
|
||||
}
|
||||
|
||||
#[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<StartAgentPayload>,
|
||||
) -> OpenApiResult<Json<AgentInfoResponse>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let info = svc::start_agent(
|
||||
&self.ctx.services.agents,
|
||||
&project_root,
|
||||
&payload.0.story_id,
|
||||
payload.0.agent_name.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(map_svc_error)?;
|
||||
|
||||
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<StopAgentPayload>) -> OpenApiResult<Json<bool>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
svc::stop_agent(
|
||||
&self.ctx.services.agents,
|
||||
&project_root,
|
||||
&payload.0.story_id,
|
||||
&payload.0.agent_name,
|
||||
)
|
||||
.await
|
||||
.map_err(map_svc_error)?;
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
/// Get the configured agent roster from project.toml.
|
||||
#[oai(path = "/agents/config", method = "get")]
|
||||
async fn get_agent_config(&self) -> OpenApiResult<Json<Vec<AgentConfigInfoResponse>>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let entries = svc::get_agent_config(&project_root).map_err(map_svc_error)?;
|
||||
Ok(Json(
|
||||
entries
|
||||
.into_iter()
|
||||
.map(AgentConfigInfoResponse::from)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Reload project config and return the updated agent roster.
|
||||
#[oai(path = "/agents/config/reload", method = "post")]
|
||||
async fn reload_config(&self) -> OpenApiResult<Json<Vec<AgentConfigInfoResponse>>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let entries = svc::reload_config(&project_root).map_err(map_svc_error)?;
|
||||
Ok(Json(
|
||||
entries
|
||||
.into_iter()
|
||||
.map(AgentConfigInfoResponse::from)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Create a git worktree for a story under .huskies/worktrees/{story_id}.
|
||||
#[oai(path = "/agents/worktrees", method = "post")]
|
||||
async fn create_worktree(
|
||||
&self,
|
||||
payload: Json<CreateWorktreePayload>,
|
||||
) -> OpenApiResult<Json<WorktreeInfoResponse>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let info = svc::create_worktree(
|
||||
&self.ctx.services.agents,
|
||||
&project_root,
|
||||
&payload.0.story_id,
|
||||
)
|
||||
.await
|
||||
.map_err(map_svc_error)?;
|
||||
|
||||
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 .huskies/worktrees/.
|
||||
#[oai(path = "/agents/worktrees", method = "get")]
|
||||
async fn list_worktrees(&self) -> OpenApiResult<Json<Vec<WorktreeListEntry>>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let entries = svc::list_worktrees(&project_root).map_err(map_svc_error)?;
|
||||
|
||||
Ok(Json(
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|e| WorktreeListEntry {
|
||||
story_id: e.story_id,
|
||||
path: e.path.to_string_lossy().to_string(),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Get the markdown content of a work item by its story_id.
|
||||
///
|
||||
/// Searches all active pipeline stages for the file and returns its content
|
||||
/// along with the stage it was found in.
|
||||
#[oai(path = "/work-items/:story_id", method = "get")]
|
||||
async fn get_work_item_content(
|
||||
&self,
|
||||
story_id: Path<String>,
|
||||
) -> OpenApiResult<Json<WorkItemContentResponse>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let item = svc::get_work_item_content(&project_root, &story_id.0).map_err(|e| match e {
|
||||
svc::Error::WorkItemNotFound(_) => not_found(e.to_string()),
|
||||
other => map_svc_error(other),
|
||||
})?;
|
||||
|
||||
Ok(Json(WorkItemContentResponse::from(item)))
|
||||
}
|
||||
|
||||
/// Get test results for a work item by its story_id.
|
||||
///
|
||||
/// Returns unit and integration test results. Checks in-memory workflow
|
||||
/// state first, then falls back to results persisted in the story file.
|
||||
#[oai(path = "/work-items/:story_id/test-results", method = "get")]
|
||||
async fn get_test_results(
|
||||
&self,
|
||||
story_id: Path<String>,
|
||||
) -> OpenApiResult<Json<Option<TestResultsResponse>>> {
|
||||
// Fast path: return from in-memory state without requiring project_root.
|
||||
let in_memory = {
|
||||
let workflow = self
|
||||
.ctx
|
||||
.workflow
|
||||
.lock()
|
||||
.map_err(|e| bad_request(format!("Lock error: {e}")))?;
|
||||
workflow.results.get(&story_id.0).cloned()
|
||||
};
|
||||
if let Some(results) = in_memory {
|
||||
return Ok(Json(Some(TestResultsResponse::from_story_results(
|
||||
&results,
|
||||
))));
|
||||
}
|
||||
|
||||
// Slow path: fall back to results persisted in the story file.
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let workflow = self
|
||||
.ctx
|
||||
.workflow
|
||||
.lock()
|
||||
.map_err(|e| bad_request(format!("Lock error: {e}")))?;
|
||||
|
||||
let results = svc::get_test_results(&project_root, &story_id.0, &workflow);
|
||||
Ok(Json(
|
||||
results.map(|r| TestResultsResponse::from_story_results(&r)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Get the historical output text for an agent session.
|
||||
///
|
||||
/// Reads the most recent persistent log file for the given story+agent and
|
||||
/// returns all `output` events concatenated as a single string. Returns an
|
||||
/// empty string if no log file exists yet.
|
||||
#[oai(path = "/agents/:story_id/:agent_name/output", method = "get")]
|
||||
async fn get_agent_output(
|
||||
&self,
|
||||
story_id: Path<String>,
|
||||
agent_name: Path<String>,
|
||||
) -> OpenApiResult<Json<AgentOutputResponse>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let output = svc::get_agent_output(&project_root, &story_id.0, &agent_name.0)
|
||||
.map_err(map_svc_error)?;
|
||||
|
||||
Ok(Json(AgentOutputResponse { output }))
|
||||
}
|
||||
|
||||
/// 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<String>) -> OpenApiResult<Json<bool>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
svc::remove_worktree(&project_root, &story_id.0)
|
||||
.await
|
||||
.map_err(map_svc_error)?;
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
/// Get the total token cost and per-agent breakdown for a work item.
|
||||
///
|
||||
/// Returns the sum of all recorded token usage for the given story_id.
|
||||
/// If no usage has been recorded, returns zero cost with an empty agents list.
|
||||
#[oai(path = "/work-items/:story_id/token-cost", method = "get")]
|
||||
async fn get_work_item_token_cost(
|
||||
&self,
|
||||
story_id: Path<String>,
|
||||
) -> OpenApiResult<Json<TokenCostResponse>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let summary =
|
||||
svc::get_work_item_token_cost(&project_root, &story_id.0).map_err(map_svc_error)?;
|
||||
|
||||
let agents = summary
|
||||
.agents
|
||||
.into_iter()
|
||||
.map(|a| AgentCostEntry {
|
||||
agent_name: a.agent_name,
|
||||
model: a.model,
|
||||
input_tokens: a.input_tokens,
|
||||
output_tokens: a.output_tokens,
|
||||
cache_creation_input_tokens: a.cache_creation_input_tokens,
|
||||
cache_read_input_tokens: a.cache_read_input_tokens,
|
||||
total_cost_usd: a.total_cost_usd,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(TokenCostResponse {
|
||||
total_cost_usd: summary.total_cost_usd,
|
||||
agents,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get all token usage records across all stories.
|
||||
///
|
||||
/// Returns the full history from the persistent token_usage.jsonl log.
|
||||
#[oai(path = "/token-usage", method = "get")]
|
||||
async fn get_all_token_usage(&self) -> OpenApiResult<Json<AllTokenUsageResponse>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.services
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let records = svc::get_all_token_usage(&project_root).map_err(map_svc_error)?;
|
||||
|
||||
let response_records: Vec<TokenUsageRecordResponse> = records
|
||||
.into_iter()
|
||||
.map(|r| TokenUsageRecordResponse {
|
||||
story_id: r.story_id,
|
||||
agent_name: r.agent_name,
|
||||
model: r.model,
|
||||
timestamp: r.timestamp,
|
||||
input_tokens: r.usage.input_tokens,
|
||||
output_tokens: r.usage.output_tokens,
|
||||
cache_creation_input_tokens: r.usage.cache_creation_input_tokens,
|
||||
cache_read_input_tokens: r.usage.cache_read_input_tokens,
|
||||
total_cost_usd: r.usage.total_cost_usd,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(AllTokenUsageResponse {
|
||||
records: response_records,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,651 +0,0 @@
|
||||
//! Tests for the HTTP agent endpoints.
|
||||
use super::*;
|
||||
use crate::agents::AgentStatus;
|
||||
use std::path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_work_dirs(tmp: &TempDir) -> path::PathBuf {
|
||||
let root = tmp.path().to_path_buf();
|
||||
for stage in &["5_done", "6_archived"] {
|
||||
std::fs::create_dir_all(root.join(".huskies").join("work").join(stage)).unwrap();
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_is_archived_false_when_file_absent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = make_work_dirs(&tmp);
|
||||
assert!(!svc::is_archived(&root, "79_story_foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_is_archived_true_when_file_in_5_done() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = make_work_dirs(&tmp);
|
||||
std::fs::write(
|
||||
root.join(".huskies/work/5_done/79_story_foo.md"),
|
||||
"---\nname: test\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(svc::is_archived(&root, "79_story_foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_is_archived_true_when_file_in_6_archived() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = make_work_dirs(&tmp);
|
||||
std::fs::write(
|
||||
root.join(".huskies/work/6_archived/79_story_foo.md"),
|
||||
"---\nname: test\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(svc::is_archived(&root, "79_story_foo"));
|
||||
}
|
||||
|
||||
fn make_project_toml(root: &path::Path, content: &str) {
|
||||
let sk_dir = root.join(".huskies");
|
||||
std::fs::create_dir_all(&sk_dir).unwrap();
|
||||
std::fs::write(sk_dir.join("project.toml"), content).unwrap();
|
||||
}
|
||||
|
||||
// --- get_agent_config tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_agent_config_returns_default_when_no_toml() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api.get_agent_config().await.unwrap().0;
|
||||
// Default config has one agent named "default"
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].name, "default");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_agent_config_returns_configured_agents() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
make_project_toml(
|
||||
tmp.path(),
|
||||
r#"
|
||||
[[agent]]
|
||||
name = "coder-1"
|
||||
role = "Full-stack engineer"
|
||||
model = "sonnet"
|
||||
max_turns = 30
|
||||
max_budget_usd = 5.0
|
||||
|
||||
[[agent]]
|
||||
name = "qa"
|
||||
role = "QA reviewer"
|
||||
model = "haiku"
|
||||
"#,
|
||||
);
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api.get_agent_config().await.unwrap().0;
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].name, "coder-1");
|
||||
assert_eq!(result[0].role, "Full-stack engineer");
|
||||
assert_eq!(result[0].model, Some("sonnet".to_string()));
|
||||
assert_eq!(result[0].max_turns, Some(30));
|
||||
assert_eq!(result[0].max_budget_usd, Some(5.0));
|
||||
assert_eq!(result[1].name, "qa");
|
||||
assert_eq!(result[1].model, Some("haiku".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_agent_config_returns_error_when_no_project_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
*ctx.state.project_root.lock().unwrap() = None;
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api.get_agent_config().await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- reload_config tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_config_returns_default_when_no_toml() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api.reload_config().await.unwrap().0;
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].name, "default");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_config_returns_configured_agents() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
make_project_toml(
|
||||
tmp.path(),
|
||||
r#"
|
||||
[[agent]]
|
||||
name = "supervisor"
|
||||
role = "Coordinator"
|
||||
model = "opus"
|
||||
allowed_tools = ["Read", "Bash"]
|
||||
"#,
|
||||
);
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api.reload_config().await.unwrap().0;
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].name, "supervisor");
|
||||
assert_eq!(result[0].role, "Coordinator");
|
||||
assert_eq!(result[0].model, Some("opus".to_string()));
|
||||
assert_eq!(
|
||||
result[0].allowed_tools,
|
||||
Some(vec!["Read".to_string(), "Bash".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_config_returns_error_when_no_project_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
*ctx.state.project_root.lock().unwrap() = None;
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api.reload_config().await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- list_worktrees tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_worktrees_returns_empty_when_no_worktree_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api.list_worktrees().await.unwrap().0;
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_worktrees_returns_entries_from_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let worktrees_dir = tmp.path().join(".huskies").join("worktrees");
|
||||
std::fs::create_dir_all(worktrees_dir.join("42_story_foo")).unwrap();
|
||||
std::fs::create_dir_all(worktrees_dir.join("43_story_bar")).unwrap();
|
||||
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let mut result = api.list_worktrees().await.unwrap().0;
|
||||
result.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
||||
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].story_id, "42_story_foo");
|
||||
assert_eq!(result[1].story_id, "43_story_bar");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_worktrees_returns_error_when_no_project_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
*ctx.state.project_root.lock().unwrap() = None;
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api.list_worktrees().await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- stop_agent tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_agent_returns_error_when_no_project_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
*ctx.state.project_root.lock().unwrap() = None;
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.stop_agent(Json(StopAgentPayload {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
}))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_agent_returns_error_when_agent_not_found() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.stop_agent(Json(StopAgentPayload {
|
||||
story_id: "nonexistent_story".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
}))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_agent_succeeds_with_running_agent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
ctx.services
|
||||
.agents
|
||||
.inject_test_agent("42_story_foo", "coder-1", AgentStatus::Running);
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.stop_agent(Json(StopAgentPayload {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
assert!(result);
|
||||
}
|
||||
|
||||
// --- start_agent error path ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_agent_returns_error_when_no_project_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
*ctx.state.project_root.lock().unwrap() = None;
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.start_agent(Json(StartAgentPayload {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: None,
|
||||
}))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- get_work_item_content tests ---
|
||||
|
||||
fn make_stage_dir(root: &path::Path, stage: &str) {
|
||||
std::fs::create_dir_all(root.join(".huskies").join("work").join(stage)).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_work_item_content_returns_content_from_backlog() {
|
||||
crate::crdt_state::init_for_test();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
make_stage_dir(root, "1_backlog");
|
||||
std::fs::write(
|
||||
root.join(".huskies/work/1_backlog/42_story_foo.md"),
|
||||
"---\nname: \"Foo Story\"\n---\n\n# Story 42: Foo Story\n\nSome content.",
|
||||
)
|
||||
.unwrap();
|
||||
// Story 929: name lives in the typed CRDT register, not in YAML on disk.
|
||||
crate::crdt_state::write_item_str(
|
||||
"42_story_foo",
|
||||
"1_backlog",
|
||||
Some("Foo Story"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let ctx = AppContext::new_test(root.to_path_buf());
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.get_work_item_content(Path("42_story_foo".to_string()))
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
assert!(result.content.contains("Some content."));
|
||||
assert_eq!(result.stage, "backlog");
|
||||
assert_eq!(result.name, Some("Foo Story".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_work_item_content_returns_content_from_current() {
|
||||
crate::crdt_state::init_for_test();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
make_stage_dir(root, "2_current");
|
||||
std::fs::write(
|
||||
root.join(".huskies/work/2_current/43_story_bar.md"),
|
||||
"---\nname: \"Bar Story\"\n---\n\nBar content.",
|
||||
)
|
||||
.unwrap();
|
||||
crate::crdt_state::write_item_str(
|
||||
"43_story_bar",
|
||||
"2_current",
|
||||
Some("Bar Story"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let ctx = AppContext::new_test(root.to_path_buf());
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.get_work_item_content(Path("43_story_bar".to_string()))
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(result.stage, "current");
|
||||
assert_eq!(result.name, Some("Bar Story".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_work_item_content_returns_not_found_when_absent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.get_work_item_content(Path("99_story_nonexistent".to_string()))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_work_item_content_falls_back_to_crdt_when_no_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
// Seed content + CRDT with no .md file on disk.
|
||||
crate::db::write_item_with_content(
|
||||
"44_story_crdt_only",
|
||||
"1_backlog",
|
||||
"---\nname: \"CRDT Only\"\n---\n\nCRDT content.",
|
||||
crate::db::ItemMeta::named("CRDT Only"),
|
||||
);
|
||||
let ctx = AppContext::new_test(root);
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.get_work_item_content(Path("44_story_crdt_only".to_string()))
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
assert!(result.content.contains("CRDT content."));
|
||||
assert_eq!(result.stage, "backlog");
|
||||
assert_eq!(result.name, Some("CRDT Only".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_work_item_content_crdt_fallback_with_current_stage() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
// Seed a CRDT-only story in the coding/current stage.
|
||||
crate::db::write_item_with_content(
|
||||
"45_story_crdt_current",
|
||||
"2_current",
|
||||
"---\nname: \"Current CRDT\"\n---\n\nIn progress.",
|
||||
crate::db::ItemMeta::named("Current CRDT"),
|
||||
);
|
||||
let ctx = AppContext::new_test(root);
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.get_work_item_content(Path("45_story_crdt_current".to_string()))
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
assert!(result.content.contains("In progress."));
|
||||
assert_eq!(result.stage, "current");
|
||||
assert_eq!(result.name, Some("Current CRDT".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_work_item_content_returns_error_when_no_project_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
*ctx.state.project_root.lock().unwrap() = None;
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.get_work_item_content(Path("42_story_foo".to_string()))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- get_agent_output tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_agent_output_returns_empty_when_no_log_exists() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.get_agent_output(
|
||||
Path("42_story_foo".to_string()),
|
||||
Path("coder-1".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(result.output, "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_agent_output_returns_concatenated_output_events() {
|
||||
use crate::agent_log::AgentLogWriter;
|
||||
use crate::agents::AgentEvent;
|
||||
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let mut writer = AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-test").unwrap();
|
||||
|
||||
writer
|
||||
.write_event(&AgentEvent::Status {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
status: "running".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
writer
|
||||
.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "Hello ".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
writer
|
||||
.write_event(&AgentEvent::Output {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "world\n".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
writer
|
||||
.write_event(&AgentEvent::Done {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
session_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let ctx = AppContext::new_test(root.to_path_buf());
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.get_agent_output(
|
||||
Path("42_story_foo".to_string()),
|
||||
Path("coder-1".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
|
||||
// Only output event texts should be concatenated; status and done are excluded.
|
||||
assert_eq!(result.output, "Hello world\n");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_agent_output_returns_error_when_no_project_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
*ctx.state.project_root.lock().unwrap() = None;
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.get_agent_output(
|
||||
Path("42_story_foo".to_string()),
|
||||
Path("coder-1".to_string()),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- create_worktree error path ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_worktree_returns_error_when_no_project_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
*ctx.state.project_root.lock().unwrap() = None;
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.create_worktree(Json(CreateWorktreePayload {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
}))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_worktree_returns_error_when_not_a_git_repo() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// project_root is set but has no git repo — git worktree add will fail
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.create_worktree(Json(CreateWorktreePayload {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
}))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- remove_worktree error paths ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_worktree_returns_error_when_no_project_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
*ctx.state.project_root.lock().unwrap() = None;
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api.remove_worktree(Path("42_story_foo".to_string())).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_worktree_returns_error_when_worktree_not_found() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// project_root is set but no worktree exists for this story_id
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.remove_worktree(Path("nonexistent_story".to_string()))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- get_test_results tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_test_results_returns_none_when_no_results() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = make_work_dirs(&tmp);
|
||||
let ctx = AppContext::new_test(root);
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.get_test_results(Path("42_story_foo".to_string()))
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_test_results_returns_in_memory_results() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = make_work_dirs(&tmp);
|
||||
let ctx = AppContext::new_test(root);
|
||||
|
||||
// Record test results in-memory.
|
||||
{
|
||||
let mut workflow = ctx.workflow.lock().unwrap();
|
||||
workflow
|
||||
.record_test_results_validated(
|
||||
"42_story_foo".to_string(),
|
||||
vec![crate::workflow::TestCaseResult {
|
||||
name: "unit_test_1".to_string(),
|
||||
status: crate::workflow::TestStatus::Pass,
|
||||
details: None,
|
||||
}],
|
||||
vec![crate::workflow::TestCaseResult {
|
||||
name: "int_test_1".to_string(),
|
||||
status: crate::workflow::TestStatus::Fail,
|
||||
details: Some("assertion failed".to_string()),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.get_test_results(Path("42_story_foo".to_string()))
|
||||
.await
|
||||
.unwrap()
|
||||
.0
|
||||
.expect("should have test results");
|
||||
|
||||
assert_eq!(result.unit.len(), 1);
|
||||
assert_eq!(result.unit[0].name, "unit_test_1");
|
||||
assert_eq!(result.unit[0].status, "pass");
|
||||
assert!(result.unit[0].details.is_none());
|
||||
|
||||
assert_eq!(result.integration.len(), 1);
|
||||
assert_eq!(result.integration[0].name, "int_test_1");
|
||||
assert_eq!(result.integration[0].status, "fail");
|
||||
assert_eq!(
|
||||
result.integration[0].details.as_deref(),
|
||||
Some("assertion failed")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_test_results_falls_back_to_file_persisted_results() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
// Create work dirs including 2_current for the story file.
|
||||
for stage in &["1_backlog", "2_current", "5_done", "6_archived"] {
|
||||
std::fs::create_dir_all(root.join(".huskies").join("work").join(stage)).unwrap();
|
||||
}
|
||||
|
||||
// Use a unique high-numbered story ID to avoid collisions with the
|
||||
// "42_story_foo" entry used by get_test_results_returns_none_when_no_results.
|
||||
let story_content = r#"---
|
||||
name: "Test story"
|
||||
---
|
||||
# Test story
|
||||
|
||||
## Test Results
|
||||
|
||||
<!-- huskies-test-results: {"unit":[{"name":"from_file","status":"pass","details":null}],"integration":[]} -->
|
||||
"#;
|
||||
std::fs::write(
|
||||
root.join(".huskies/work/2_current/9906_story_persisted_results.md"),
|
||||
story_content,
|
||||
)
|
||||
.unwrap();
|
||||
// Also write to the content store so read_story_content returns this
|
||||
// test's content even when another test left a stale entry in the
|
||||
// global content store.
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_content("9906_story_persisted_results", story_content);
|
||||
|
||||
let ctx = AppContext::new_test(root);
|
||||
let api = AgentsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.get_test_results(Path("9906_story_persisted_results".to_string()))
|
||||
.await
|
||||
.unwrap()
|
||||
.0
|
||||
.expect("should fall back to file results");
|
||||
|
||||
assert_eq!(result.unit.len(), 1);
|
||||
assert_eq!(result.unit[0].name, "from_file");
|
||||
assert_eq!(result.unit[0].status, "pass");
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
//! Anthropic API proxy — thin adapter over `service::anthropic`.
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use crate::service::anthropic::{self as svc, ModelSummary};
|
||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Deserialize, Object)]
|
||||
struct ApiKeyPayload {
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Tags)]
|
||||
enum AnthropicTags {
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
/// OpenAPI endpoint group for Anthropic API key and model operations.
|
||||
pub struct AnthropicApi {
|
||||
ctx: Arc<AppContext>,
|
||||
}
|
||||
|
||||
impl AnthropicApi {
|
||||
/// Create a new `AnthropicApi` bound to the given application context.
|
||||
pub fn new(ctx: Arc<AppContext>) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl From<Arc<AppContext>> for AnthropicApi {
|
||||
fn from(ctx: Arc<AppContext>) -> Self {
|
||||
Self::new(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
#[OpenApi(tag = "AnthropicTags::Anthropic")]
|
||||
impl AnthropicApi {
|
||||
/// Check whether an Anthropic API key is stored.
|
||||
///
|
||||
/// Returns `true` if a non-empty key is present, otherwise `false`.
|
||||
#[oai(path = "/anthropic/key/exists", method = "get")]
|
||||
async fn get_anthropic_api_key_exists(&self) -> OpenApiResult<Json<bool>> {
|
||||
let exists = svc::get_api_key_exists(self.ctx.store.as_ref())
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(exists))
|
||||
}
|
||||
|
||||
/// Store or update the Anthropic API key used for requests.
|
||||
///
|
||||
/// Returns `true` when the key is saved successfully.
|
||||
#[oai(path = "/anthropic/key", method = "post")]
|
||||
async fn set_anthropic_api_key(
|
||||
&self,
|
||||
payload: Json<ApiKeyPayload>,
|
||||
) -> OpenApiResult<Json<bool>> {
|
||||
svc::set_api_key(self.ctx.store.as_ref(), payload.0.api_key)
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
/// List available Anthropic models.
|
||||
#[oai(path = "/anthropic/models", method = "get")]
|
||||
async fn list_anthropic_models(&self) -> OpenApiResult<Json<Vec<ModelSummary>>> {
|
||||
let models = svc::list_models(self.ctx.store.as_ref())
|
||||
.await
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(models))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl AnthropicApi {
|
||||
/// List models from an injectable URL (used in tests to avoid real network calls).
|
||||
async fn list_anthropic_models_from(
|
||||
&self,
|
||||
url: &str,
|
||||
) -> OpenApiResult<Json<Vec<ModelSummary>>> {
|
||||
let models = svc::list_models_from(self.ctx.store.as_ref(), url)
|
||||
.await
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(models))
|
||||
}
|
||||
}
|
||||
|
||||
// Private helper retained for backward compatibility with tests that call it directly.
|
||||
#[cfg(test)]
|
||||
fn get_anthropic_api_key(ctx: &AppContext) -> Result<String, String> {
|
||||
svc::get_api_key(ctx.store.as_ref()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// Private types retained so existing tests that deserialise them directly continue to compile.
|
||||
#[cfg(test)]
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AnthropicModelsResponse {
|
||||
data: Vec<AnthropicModelInfo>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AnthropicModelInfo {
|
||||
id: String,
|
||||
context_window: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::AppContext;
|
||||
use crate::http::test_helpers::{make_api, test_ctx};
|
||||
use crate::store::StoreOps;
|
||||
const KEY_ANTHROPIC_API_KEY: &str = "anthropic_api_key";
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// -- get_anthropic_api_key (private helper) --
|
||||
|
||||
#[test]
|
||||
fn get_api_key_returns_err_when_not_set() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
let result = get_anthropic_api_key(&ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_api_key_returns_err_when_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
ctx.store.set(KEY_ANTHROPIC_API_KEY, json!(""));
|
||||
let result = get_anthropic_api_key(&ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_api_key_returns_err_when_not_string() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
ctx.store.set(KEY_ANTHROPIC_API_KEY, json!(12345));
|
||||
let result = get_anthropic_api_key(&ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not a string"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_api_key_returns_key_when_set() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
ctx.store
|
||||
.set(KEY_ANTHROPIC_API_KEY, json!("sk-ant-test123"));
|
||||
let result = get_anthropic_api_key(&ctx);
|
||||
assert_eq!(result.unwrap(), "sk-ant-test123");
|
||||
}
|
||||
|
||||
// -- get_anthropic_api_key_exists endpoint --
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_exists_returns_false_when_not_set() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<AnthropicApi>(&dir);
|
||||
let result = api.get_anthropic_api_key_exists().await.unwrap();
|
||||
assert!(!result.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_exists_returns_true_when_set() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||
ctx.store
|
||||
.set(KEY_ANTHROPIC_API_KEY, json!("sk-ant-test123"));
|
||||
let api = AnthropicApi::new(Arc::new(ctx));
|
||||
let result = api.get_anthropic_api_key_exists().await.unwrap();
|
||||
assert!(result.0);
|
||||
}
|
||||
|
||||
// -- set_anthropic_api_key endpoint --
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_api_key_returns_true() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<AnthropicApi>(&dir);
|
||||
let payload = Json(ApiKeyPayload {
|
||||
api_key: "sk-ant-test123".to_string(),
|
||||
});
|
||||
let result = api.set_anthropic_api_key(payload).await.unwrap();
|
||||
assert!(result.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_then_exists_returns_true() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = Arc::new(AppContext::new_test(dir.path().to_path_buf()));
|
||||
let api = AnthropicApi::new(ctx);
|
||||
api.set_anthropic_api_key(Json(ApiKeyPayload {
|
||||
api_key: "sk-ant-test123".to_string(),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = api.get_anthropic_api_key_exists().await.unwrap();
|
||||
assert!(result.0);
|
||||
}
|
||||
|
||||
// -- list_anthropic_models endpoint --
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_models_fails_when_no_key() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<AnthropicApi>(&dir);
|
||||
let result = api.list_anthropic_models().await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_models_fails_with_invalid_header_value() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||
// A header value containing a newline is invalid
|
||||
ctx.store.set(KEY_ANTHROPIC_API_KEY, json!("bad\nvalue"));
|
||||
let api = AnthropicApi::new(Arc::new(ctx));
|
||||
let result = api.list_anthropic_models_from("http://127.0.0.1:1").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_models_fails_when_server_unreachable() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||
ctx.store
|
||||
.set(KEY_ANTHROPIC_API_KEY, json!("sk-ant-test123"));
|
||||
let api = AnthropicApi::new(Arc::new(ctx));
|
||||
// Port 1 is reserved and should immediately refuse the connection
|
||||
let result = api.list_anthropic_models_from("http://127.0.0.1:1").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_creates_api_instance() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let _api = make_api::<AnthropicApi>(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_model_info_deserializes_context_window() {
|
||||
let json = json!({
|
||||
"id": "claude-opus-4-5",
|
||||
"context_window": 200000
|
||||
});
|
||||
let info: AnthropicModelInfo = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(info.id, "claude-opus-4-5");
|
||||
assert_eq!(info.context_window, 200000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_models_response_deserializes_multiple_models() {
|
||||
let json = json!({
|
||||
"data": [
|
||||
{ "id": "claude-opus-4-5", "context_window": 200000 },
|
||||
{ "id": "claude-haiku-4-5-20251001", "context_window": 100000 }
|
||||
]
|
||||
});
|
||||
let response: AnthropicModelsResponse = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(response.data.len(), 2);
|
||||
assert_eq!(response.data[0].context_window, 200000);
|
||||
assert_eq!(response.data[1].context_window, 100000);
|
||||
}
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
//! Bot command HTTP endpoint.
|
||||
//!
|
||||
//! `POST /api/bot/command` lets the web UI invoke the same deterministic bot
|
||||
//! commands available in Matrix without going through the LLM.
|
||||
//!
|
||||
//! Dispatches to [`crate::service::bot_command::execute`], which owns all
|
||||
//! parsing and routing logic. This handler is a thin OpenAPI adapter: it
|
||||
//! receives JSON, calls the service, and maps typed errors to HTTP status codes.
|
||||
|
||||
use crate::http::context::{AppContext, OpenApiResult};
|
||||
use crate::service::bot_command as svc;
|
||||
use poem::http::StatusCode;
|
||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Tags)]
|
||||
enum BotCommandTags {
|
||||
BotCommand,
|
||||
}
|
||||
|
||||
/// Body for `POST /api/bot/command`.
|
||||
#[derive(Object, Deserialize)]
|
||||
struct BotCommandRequest {
|
||||
/// The command keyword without the leading slash (e.g. `"status"`, `"start"`).
|
||||
command: String,
|
||||
/// Any text after the command keyword, trimmed (may be empty).
|
||||
#[oai(default)]
|
||||
args: String,
|
||||
}
|
||||
|
||||
/// Response body for `POST /api/bot/command`.
|
||||
#[derive(Object, Serialize)]
|
||||
struct BotCommandResponse {
|
||||
/// Markdown-formatted response text.
|
||||
response: String,
|
||||
}
|
||||
|
||||
/// OpenAPI endpoint group for bot slash-command execution.
|
||||
pub struct BotCommandApi {
|
||||
pub ctx: Arc<AppContext>,
|
||||
}
|
||||
|
||||
#[OpenApi(tag = "BotCommandTags::BotCommand")]
|
||||
impl BotCommandApi {
|
||||
/// Execute a slash command without LLM invocation.
|
||||
///
|
||||
/// Dispatches to the same handlers used by the Matrix and Slack bots.
|
||||
/// Returns a markdown-formatted response that the frontend can display
|
||||
/// directly in the chat panel.
|
||||
///
|
||||
/// # Errors
|
||||
/// - `400 Bad Request` — project root not set, or invalid command arguments.
|
||||
/// - `404 Not Found` — unrecognised command keyword.
|
||||
/// - `500 Internal Server Error` — command execution failed.
|
||||
#[oai(path = "/bot/command", method = "post")]
|
||||
async fn run_command(
|
||||
&self,
|
||||
body: Json<BotCommandRequest>,
|
||||
) -> OpenApiResult<Json<BotCommandResponse>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.state
|
||||
.get_project_root()
|
||||
.map_err(|e| poem::Error::from_string(e, StatusCode::BAD_REQUEST))?;
|
||||
|
||||
let cmd = body.command.trim().to_ascii_lowercase();
|
||||
let args = body.args.trim();
|
||||
|
||||
let response = svc::execute(&cmd, args, &project_root, &self.ctx.services.agents)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
svc::Error::UnknownCommand(msg) => {
|
||||
poem::Error::from_string(msg, StatusCode::NOT_FOUND)
|
||||
}
|
||||
svc::Error::BadArgs(msg) => poem::Error::from_string(msg, StatusCode::BAD_REQUEST),
|
||||
svc::Error::CommandFailed(msg) => {
|
||||
poem::Error::from_string(msg, StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(Json(BotCommandResponse { response }))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_api(dir: &TempDir) -> BotCommandApi {
|
||||
BotCommandApi {
|
||||
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn help_command_returns_response() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = test_api(&dir);
|
||||
let body = BotCommandRequest {
|
||||
command: "help".to_string(),
|
||||
args: String::new(),
|
||||
};
|
||||
let result = api.run_command(Json(body)).await;
|
||||
assert!(result.is_ok());
|
||||
let resp = result.unwrap().0;
|
||||
assert!(!resp.response.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_command_returns_error_message() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = test_api(&dir);
|
||||
let body = BotCommandRequest {
|
||||
command: "nonexistent_xyz".to_string(),
|
||||
args: String::new(),
|
||||
};
|
||||
let result = api.run_command(Json(body)).await;
|
||||
assert!(result.is_err(), "unknown command should return HTTP 404");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_without_number_returns_usage() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = test_api(&dir);
|
||||
let body = BotCommandRequest {
|
||||
command: "start".to_string(),
|
||||
args: String::new(),
|
||||
};
|
||||
let result = api.run_command(Json(body)).await;
|
||||
assert!(result.is_err(), "start with no args should return HTTP 400");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_without_number_returns_usage() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = test_api(&dir);
|
||||
let body = BotCommandRequest {
|
||||
command: "delete".to_string(),
|
||||
args: String::new(),
|
||||
};
|
||||
let result = api.run_command(Json(body)).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"delete with no args should return HTTP 400"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_command_returns_response() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Initialise a bare git repo so the git command has something to query.
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.ok();
|
||||
let api = test_api(&dir);
|
||||
let body = BotCommandRequest {
|
||||
command: "git".to_string(),
|
||||
args: String::new(),
|
||||
};
|
||||
let result = api.run_command(Json(body)).await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timer_list_returns_response_not_unknown_command() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = test_api(&dir);
|
||||
let body = BotCommandRequest {
|
||||
command: "timer".to_string(),
|
||||
args: "list".to_string(),
|
||||
};
|
||||
let result = api.run_command(Json(body)).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"timer list should succeed, got err: {:?}",
|
||||
result.err().map(|e| e.to_string())
|
||||
);
|
||||
let resp = result.unwrap().0;
|
||||
assert!(
|
||||
!resp.response.contains("Unknown command"),
|
||||
"timer list should not return 'Unknown command': {}",
|
||||
resp.response
|
||||
);
|
||||
}
|
||||
|
||||
// -- htop (web-UI slash-command path) ------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn htop_returns_dashboard_not_unknown_command() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = test_api(&dir);
|
||||
let body = BotCommandRequest {
|
||||
command: "htop".to_string(),
|
||||
args: String::new(),
|
||||
};
|
||||
let result = api.run_command(Json(body)).await;
|
||||
assert!(result.is_ok());
|
||||
let resp = result.unwrap().0;
|
||||
assert!(
|
||||
!resp.response.contains("Unknown command"),
|
||||
"htop should not return 'Unknown command': {}",
|
||||
resp.response
|
||||
);
|
||||
assert!(
|
||||
resp.response.contains("htop"),
|
||||
"htop response should contain 'htop': {}",
|
||||
resp.response
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn htop_with_duration_returns_dashboard() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = test_api(&dir);
|
||||
let body = BotCommandRequest {
|
||||
command: "htop".to_string(),
|
||||
args: "10m".to_string(),
|
||||
};
|
||||
let result = api.run_command(Json(body)).await;
|
||||
assert!(result.is_ok());
|
||||
let resp = result.unwrap().0;
|
||||
assert!(
|
||||
!resp.response.contains("Unknown command"),
|
||||
"htop 10m should not return 'Unknown command': {}",
|
||||
resp.response
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn htop_stop_returns_response_not_unknown_command() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = test_api(&dir);
|
||||
let body = BotCommandRequest {
|
||||
command: "htop".to_string(),
|
||||
args: "stop".to_string(),
|
||||
};
|
||||
let result = api.run_command(Json(body)).await;
|
||||
assert!(result.is_ok());
|
||||
let resp = result.unwrap().0;
|
||||
assert!(
|
||||
!resp.response.contains("Unknown command"),
|
||||
"htop stop should not return 'Unknown command': {}",
|
||||
resp.response
|
||||
);
|
||||
}
|
||||
|
||||
// -- rmtree ----------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn rmtree_without_number_returns_usage() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = test_api(&dir);
|
||||
let body = BotCommandRequest {
|
||||
command: "rmtree".to_string(),
|
||||
args: String::new(),
|
||||
};
|
||||
let result = api.run_command(Json(body)).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"rmtree with no args should return HTTP 400"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rmtree_with_non_numeric_arg_returns_usage() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = test_api(&dir);
|
||||
let body = BotCommandRequest {
|
||||
command: "rmtree".to_string(),
|
||||
args: "foo".to_string(),
|
||||
};
|
||||
let result = api.run_command(Json(body)).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"rmtree with non-numeric arg should return HTTP 400"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rmtree_does_not_return_unknown_command() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = test_api(&dir);
|
||||
let body = BotCommandRequest {
|
||||
command: "rmtree".to_string(),
|
||||
args: "999".to_string(),
|
||||
};
|
||||
let result = api.run_command(Json(body)).await;
|
||||
assert!(result.is_ok());
|
||||
let resp = result.unwrap().0;
|
||||
assert!(
|
||||
!resp.response.contains("Unknown command"),
|
||||
"/rmtree should not return 'Unknown command': {}",
|
||||
resp.response
|
||||
);
|
||||
}
|
||||
|
||||
// -- htop bot-command path (regression: htop must remain in command registry) --
|
||||
|
||||
#[test]
|
||||
fn htop_is_registered_in_bot_command_registry() {
|
||||
let commands = crate::chat::commands::commands();
|
||||
assert!(
|
||||
commands.iter().any(|c| c.name == "htop"),
|
||||
"htop must be registered in the bot command registry so /help lists it"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_command_requires_project_root() {
|
||||
// Create a context with no project root set.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||
// Clear the project root.
|
||||
*ctx.state.project_root.lock().unwrap() = None;
|
||||
let api = BotCommandApi { ctx: Arc::new(ctx) };
|
||||
let body = BotCommandRequest {
|
||||
command: "status".to_string(),
|
||||
args: String::new(),
|
||||
};
|
||||
let result = api.run_command(Json(body)).await;
|
||||
assert!(result.is_err(), "should fail when no project root is set");
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
//! Bot configuration endpoints — GET/PUT for .huskies/bot.toml credentials.
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Tags)]
|
||||
enum BotConfigTags {
|
||||
BotConfig,
|
||||
}
|
||||
|
||||
#[derive(Object, Serialize, Deserialize, Default)]
|
||||
struct BotConfigPayload {
|
||||
pub transport: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub homeserver: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub room_ids: Option<Vec<String>>,
|
||||
pub slack_bot_token: Option<String>,
|
||||
pub slack_signing_secret: Option<String>,
|
||||
pub slack_channel_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// OpenAPI endpoint group for reading and writing bot configuration.
|
||||
pub struct BotConfigApi {
|
||||
pub ctx: Arc<AppContext>,
|
||||
}
|
||||
|
||||
#[OpenApi(tag = "BotConfigTags::BotConfig")]
|
||||
impl BotConfigApi {
|
||||
/// Read current bot credentials from .huskies/bot.toml.
|
||||
#[oai(path = "/bot/config", method = "get")]
|
||||
async fn get_config(&self) -> OpenApiResult<Json<BotConfigPayload>> {
|
||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||
let path = root.join(".huskies").join("bot.toml");
|
||||
let config: BotConfigPayload = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| toml::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Ok(Json(config))
|
||||
}
|
||||
|
||||
/// Persist bot credentials to .huskies/bot.toml.
|
||||
#[oai(path = "/bot/config", method = "put")]
|
||||
async fn put_config(
|
||||
&self,
|
||||
payload: Json<BotConfigPayload>,
|
||||
) -> OpenApiResult<Json<BotConfigPayload>> {
|
||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||
let path = root.join(".huskies").join("bot.toml");
|
||||
let content = toml::to_string(&payload.0).map_err(|e| bad_request(e.to_string()))?;
|
||||
std::fs::write(&path, content).map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(payload)
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
//! HTTP chat endpoints — REST API for the LLM-powered chat interface.
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use crate::llm::chat;
|
||||
use poem_openapi::{OpenApi, Tags, payload::Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Tags)]
|
||||
enum ChatTags {
|
||||
Chat,
|
||||
}
|
||||
|
||||
/// OpenAPI endpoint group for the LLM-powered chat interface.
|
||||
pub struct ChatApi {
|
||||
pub ctx: Arc<AppContext>,
|
||||
}
|
||||
|
||||
#[OpenApi(tag = "ChatTags::Chat")]
|
||||
impl ChatApi {
|
||||
/// Cancel the currently running chat stream, if any.
|
||||
///
|
||||
/// Returns `true` once the cancellation signal is issued.
|
||||
#[oai(path = "/chat/cancel", method = "post")]
|
||||
async fn cancel_chat(&self) -> OpenApiResult<Json<bool>> {
|
||||
chat::cancel_chat(&self.ctx.state).map_err(bad_request)?;
|
||||
Ok(Json(true))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_api(dir: &TempDir) -> ChatApi {
|
||||
ChatApi {
|
||||
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_chat_returns_true() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = test_api(&dir);
|
||||
let result = api.cancel_chat().await;
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_chat_sends_cancel_signal() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = test_api(&dir);
|
||||
let mut cancel_rx = api.ctx.state.cancel_rx.clone();
|
||||
cancel_rx.borrow_and_update();
|
||||
|
||||
api.cancel_chat().await.unwrap();
|
||||
|
||||
assert!(*cancel_rx.borrow());
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ use crate::services::Services;
|
||||
use crate::state::SessionState;
|
||||
use crate::store::JsonFileStore;
|
||||
use crate::workflow::WorkflowState;
|
||||
use poem::http::StatusCode;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
|
||||
@@ -121,35 +120,10 @@ impl AppContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Alias for `poem::Result<T>` used by OpenAPI handler return types.
|
||||
pub type OpenApiResult<T> = poem::Result<T>;
|
||||
|
||||
/// Return a 400 Bad Request error with the given message.
|
||||
pub fn bad_request(message: String) -> poem::Error {
|
||||
poem::Error::from_string(message, StatusCode::BAD_REQUEST)
|
||||
}
|
||||
|
||||
/// Return a 404 Not Found error with the given message.
|
||||
pub fn not_found(message: String) -> poem::Error {
|
||||
poem::Error::from_string(message, StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bad_request_returns_400_status() {
|
||||
let err = bad_request("something went wrong".to_string());
|
||||
assert_eq!(err.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_request_accepts_empty_message() {
|
||||
let err = bad_request(String::new());
|
||||
assert_eq!(err.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_decision_equality() {
|
||||
assert_eq!(PermissionDecision::Deny, PermissionDecision::Deny);
|
||||
@@ -161,10 +135,4 @@ mod tests {
|
||||
assert_ne!(PermissionDecision::Deny, PermissionDecision::Approve);
|
||||
assert_ne!(PermissionDecision::Approve, PermissionDecision::AlwaysAllow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_returns_404_status() {
|
||||
let err = not_found("item not found".to_string());
|
||||
assert_eq!(err.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
//! HTTP I/O endpoints — thin adapters over `service::file_io`.
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use crate::service::file_io::{self as svc, FileEntry};
|
||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Tags)]
|
||||
enum IoTags {
|
||||
Io,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Object)]
|
||||
struct FilePathPayload {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Object)]
|
||||
struct WriteFilePayload {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Object)]
|
||||
struct SearchPayload {
|
||||
query: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Object)]
|
||||
struct CreateDirectoryPayload {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Object)]
|
||||
struct ExecShellPayload {
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
}
|
||||
|
||||
/// OpenAPI endpoint group for filesystem I/O operations (read, write, list, search).
|
||||
pub struct IoApi {
|
||||
pub ctx: Arc<AppContext>,
|
||||
}
|
||||
|
||||
#[OpenApi(tag = "IoTags::Io")]
|
||||
impl IoApi {
|
||||
/// Read a file from the currently open project and return its contents.
|
||||
#[oai(path = "/io/fs/read", method = "post")]
|
||||
async fn read_file(&self, payload: Json<FilePathPayload>) -> OpenApiResult<Json<String>> {
|
||||
let content = svc::read_file(payload.0.path, &self.ctx.state)
|
||||
.await
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(content))
|
||||
}
|
||||
|
||||
/// Write a file to the currently open project, creating parent directories if needed.
|
||||
#[oai(path = "/io/fs/write", method = "post")]
|
||||
async fn write_file(&self, payload: Json<WriteFilePayload>) -> OpenApiResult<Json<bool>> {
|
||||
svc::write_file(payload.0.path, payload.0.content, &self.ctx.state)
|
||||
.await
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
/// List files and folders in a directory within the currently open project.
|
||||
#[oai(path = "/io/fs/list", method = "post")]
|
||||
async fn list_directory(
|
||||
&self,
|
||||
payload: Json<FilePathPayload>,
|
||||
) -> OpenApiResult<Json<Vec<FileEntry>>> {
|
||||
let entries = svc::list_directory(payload.0.path, &self.ctx.state)
|
||||
.await
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(entries))
|
||||
}
|
||||
|
||||
/// List files and folders at an absolute path (not scoped to the project root).
|
||||
#[oai(path = "/io/fs/list/absolute", method = "post")]
|
||||
async fn list_directory_absolute(
|
||||
&self,
|
||||
payload: Json<FilePathPayload>,
|
||||
) -> OpenApiResult<Json<Vec<FileEntry>>> {
|
||||
let entries = svc::list_directory_absolute(payload.0.path)
|
||||
.await
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(entries))
|
||||
}
|
||||
|
||||
/// Create a directory at an absolute path.
|
||||
#[oai(path = "/io/fs/create/absolute", method = "post")]
|
||||
async fn create_directory_absolute(
|
||||
&self,
|
||||
payload: Json<CreateDirectoryPayload>,
|
||||
) -> OpenApiResult<Json<bool>> {
|
||||
svc::create_directory_absolute(payload.0.path)
|
||||
.await
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
/// Get the user's home directory.
|
||||
#[oai(path = "/io/fs/home", method = "get")]
|
||||
async fn get_home_directory(&self) -> OpenApiResult<Json<String>> {
|
||||
let home = svc::get_home_directory().map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(home))
|
||||
}
|
||||
|
||||
/// List all files in the project recursively, respecting .gitignore.
|
||||
#[oai(path = "/io/fs/files", method = "get")]
|
||||
async fn list_project_files(&self) -> OpenApiResult<Json<Vec<String>>> {
|
||||
let files = svc::list_project_files(&self.ctx.state)
|
||||
.await
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(files))
|
||||
}
|
||||
|
||||
/// Search the currently open project for files containing the provided query string.
|
||||
#[oai(path = "/io/search", method = "post")]
|
||||
async fn search_files(
|
||||
&self,
|
||||
payload: Json<SearchPayload>,
|
||||
) -> OpenApiResult<Json<Vec<crate::service::file_io::SearchResult>>> {
|
||||
let results = svc::search_files(payload.0.query, &self.ctx.state)
|
||||
.await
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(results))
|
||||
}
|
||||
|
||||
/// Execute an allowlisted shell command in the currently open project.
|
||||
#[oai(path = "/io/shell/exec", method = "post")]
|
||||
async fn exec_shell(
|
||||
&self,
|
||||
payload: Json<ExecShellPayload>,
|
||||
) -> OpenApiResult<Json<crate::service::file_io::CommandOutput>> {
|
||||
let output = svc::exec_shell(payload.0.command, payload.0.args, &self.ctx.state)
|
||||
.await
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(output))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl From<std::sync::Arc<AppContext>> for IoApi {
|
||||
fn from(ctx: std::sync::Arc<AppContext>) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::test_helpers::make_api;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// --- list_directory_absolute ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_directory_absolute_returns_entries_for_valid_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir(dir.path().join("subdir")).unwrap();
|
||||
std::fs::write(dir.path().join("file.txt"), "content").unwrap();
|
||||
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let payload = Json(FilePathPayload {
|
||||
path: dir.path().to_string_lossy().to_string(),
|
||||
});
|
||||
let result = api.list_directory_absolute(payload).await.unwrap();
|
||||
let entries = &result.0;
|
||||
|
||||
assert!(entries.len() >= 2);
|
||||
assert!(
|
||||
entries
|
||||
.iter()
|
||||
.any(|e| e.name == "subdir" && e.kind == "dir")
|
||||
);
|
||||
assert!(
|
||||
entries
|
||||
.iter()
|
||||
.any(|e| e.name == "file.txt" && e.kind == "file")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_directory_absolute_returns_empty_for_empty_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let empty = dir.path().join("empty");
|
||||
std::fs::create_dir(&empty).unwrap();
|
||||
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let payload = Json(FilePathPayload {
|
||||
path: empty.to_string_lossy().to_string(),
|
||||
});
|
||||
let result = api.list_directory_absolute(payload).await.unwrap();
|
||||
assert!(result.0.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_directory_absolute_errors_on_nonexistent_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let payload = Json(FilePathPayload {
|
||||
path: dir.path().join("nonexistent").to_string_lossy().to_string(),
|
||||
});
|
||||
let result = api.list_directory_absolute(payload).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_directory_absolute_errors_on_file_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let file = dir.path().join("not_a_dir.txt");
|
||||
std::fs::write(&file, "content").unwrap();
|
||||
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let payload = Json(FilePathPayload {
|
||||
path: file.to_string_lossy().to_string(),
|
||||
});
|
||||
let result = api.list_directory_absolute(payload).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- create_directory_absolute ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_directory_absolute_creates_new_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let new_dir = dir.path().join("new_dir");
|
||||
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let payload = Json(CreateDirectoryPayload {
|
||||
path: new_dir.to_string_lossy().to_string(),
|
||||
});
|
||||
let result = api.create_directory_absolute(payload).await.unwrap();
|
||||
assert!(result.0);
|
||||
assert!(new_dir.is_dir());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_directory_absolute_succeeds_for_existing_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let existing = dir.path().join("existing");
|
||||
std::fs::create_dir(&existing).unwrap();
|
||||
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let payload = Json(CreateDirectoryPayload {
|
||||
path: existing.to_string_lossy().to_string(),
|
||||
});
|
||||
let result = api.create_directory_absolute(payload).await.unwrap();
|
||||
assert!(result.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_directory_absolute_creates_nested_dirs() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let nested = dir.path().join("a").join("b").join("c");
|
||||
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let payload = Json(CreateDirectoryPayload {
|
||||
path: nested.to_string_lossy().to_string(),
|
||||
});
|
||||
let result = api.create_directory_absolute(payload).await.unwrap();
|
||||
assert!(result.0);
|
||||
assert!(nested.is_dir());
|
||||
}
|
||||
|
||||
// --- get_home_directory ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_home_directory_returns_a_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let result = api.get_home_directory().await.unwrap();
|
||||
let home = &result.0;
|
||||
assert!(!home.is_empty());
|
||||
assert!(std::path::Path::new(home).is_absolute());
|
||||
}
|
||||
|
||||
// --- read_file (project-scoped) ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_returns_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("hello.txt"), "hello world").unwrap();
|
||||
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let payload = Json(FilePathPayload {
|
||||
path: "hello.txt".to_string(),
|
||||
});
|
||||
let result = api.read_file(payload).await.unwrap();
|
||||
assert_eq!(result.0, "hello world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_errors_on_missing_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let payload = Json(FilePathPayload {
|
||||
path: "nonexistent.txt".to_string(),
|
||||
});
|
||||
let result = api.read_file(payload).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- write_file (project-scoped) ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_file_creates_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let payload = Json(WriteFilePayload {
|
||||
path: "output.txt".to_string(),
|
||||
content: "written content".to_string(),
|
||||
});
|
||||
let result = api.write_file(payload).await.unwrap();
|
||||
assert!(result.0);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("output.txt")).unwrap(),
|
||||
"written content"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_file_creates_parent_dirs() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let payload = Json(WriteFilePayload {
|
||||
path: "sub/dir/file.txt".to_string(),
|
||||
content: "nested".to_string(),
|
||||
});
|
||||
let result = api.write_file(payload).await.unwrap();
|
||||
assert!(result.0);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("sub/dir/file.txt")).unwrap(),
|
||||
"nested"
|
||||
);
|
||||
}
|
||||
|
||||
// --- list_project_files ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_project_files_returns_file_paths() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir(dir.path().join("src")).unwrap();
|
||||
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
|
||||
std::fs::write(dir.path().join("README.md"), "# readme").unwrap();
|
||||
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let result = api.list_project_files().await.unwrap();
|
||||
let files = &result.0;
|
||||
|
||||
assert!(files.contains(&"README.md".to_string()));
|
||||
assert!(files.contains(&"src/main.rs".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_project_files_excludes_directories() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir(dir.path().join("subdir")).unwrap();
|
||||
std::fs::write(dir.path().join("file.txt"), "").unwrap();
|
||||
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let result = api.list_project_files().await.unwrap();
|
||||
let files = &result.0;
|
||||
|
||||
assert!(files.contains(&"file.txt".to_string()));
|
||||
// Directories should not appear
|
||||
assert!(!files.iter().any(|f| f == "subdir"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_project_files_returns_sorted_paths() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("z_last.txt"), "").unwrap();
|
||||
std::fs::write(dir.path().join("a_first.txt"), "").unwrap();
|
||||
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let result = api.list_project_files().await.unwrap();
|
||||
let files = &result.0;
|
||||
|
||||
let a_idx = files.iter().position(|f| f == "a_first.txt").unwrap();
|
||||
let z_idx = files.iter().position(|f| f == "z_last.txt").unwrap();
|
||||
assert!(a_idx < z_idx);
|
||||
}
|
||||
|
||||
// --- list_directory (project-scoped) ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_directory_returns_entries() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir(dir.path().join("adir")).unwrap();
|
||||
std::fs::write(dir.path().join("bfile.txt"), "").unwrap();
|
||||
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let payload = Json(FilePathPayload {
|
||||
path: ".".to_string(),
|
||||
});
|
||||
let result = api.list_directory(payload).await.unwrap();
|
||||
let entries = &result.0;
|
||||
|
||||
assert!(entries.iter().any(|e| e.name == "adir" && e.kind == "dir"));
|
||||
assert!(
|
||||
entries
|
||||
.iter()
|
||||
.any(|e| e.name == "bfile.txt" && e.kind == "file")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_directory_errors_on_nonexistent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<IoApi>(&dir);
|
||||
let payload = Json(FilePathPayload {
|
||||
path: "nonexistent_dir".to_string(),
|
||||
});
|
||||
let result = api.list_directory(payload).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
+9
-94
@@ -1,34 +1,18 @@
|
||||
//! HTTP server — module declarations for all REST, MCP, WebSocket, and SSE endpoints.
|
||||
/// Agent management HTTP endpoints.
|
||||
pub mod agents;
|
||||
/// Server-sent event stream for real-time agent output.
|
||||
pub mod agents_sse;
|
||||
/// Anthropic API key management endpoints.
|
||||
pub mod anthropic;
|
||||
/// Static asset serving (embedded frontend files).
|
||||
pub mod assets;
|
||||
/// Bot slash-command HTTP endpoint.
|
||||
pub mod bot_command;
|
||||
/// Bot configuration read/write endpoints.
|
||||
pub mod bot_config;
|
||||
/// Chat session HTTP endpoints.
|
||||
pub mod chat;
|
||||
/// Shared application context threaded through handlers.
|
||||
pub mod context;
|
||||
/// Server-sent event stream for pipeline/watcher events.
|
||||
pub mod events;
|
||||
/// Node identity endpoint (public key, node ID).
|
||||
pub mod identity;
|
||||
/// Filesystem I/O HTTP endpoints (read, write, list, search).
|
||||
pub mod io;
|
||||
/// Model Context Protocol (MCP) HTTP endpoint and tool modules.
|
||||
pub mod mcp;
|
||||
/// LLM model selection and listing endpoints.
|
||||
pub mod model;
|
||||
/// OAuth 2.0 PKCE flow endpoints for Anthropic authentication.
|
||||
pub mod oauth;
|
||||
/// Project settings HTTP endpoints.
|
||||
pub mod settings;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_helpers;
|
||||
/// Workflow helpers for story/bug file operations.
|
||||
@@ -36,26 +20,13 @@ pub mod workflow;
|
||||
|
||||
/// Gateway-mode HTTP endpoints for multi-project proxy.
|
||||
pub mod gateway;
|
||||
/// Project open/close/list HTTP endpoints.
|
||||
pub mod project;
|
||||
/// Setup wizard HTTP endpoints.
|
||||
pub mod wizard;
|
||||
/// WebSocket handler for real-time frontend communication.
|
||||
pub mod ws;
|
||||
|
||||
use agents::AgentsApi;
|
||||
use anthropic::AnthropicApi;
|
||||
use bot_command::BotCommandApi;
|
||||
use bot_config::BotConfigApi;
|
||||
use chat::ChatApi;
|
||||
use context::AppContext;
|
||||
use io::IoApi;
|
||||
use model::ModelApi;
|
||||
use poem::EndpointExt;
|
||||
use poem::http::StatusCode;
|
||||
use poem::{Route, get, post};
|
||||
use poem_openapi::OpenApiService;
|
||||
use project::ProjectApi;
|
||||
use settings::SettingsApi;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -88,7 +59,13 @@ pub fn remove_port_file(path: &Path) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
/// Assemble the full Poem route tree (API, WebSocket, MCP, OAuth, assets).
|
||||
/// Liveness probe — always returns 200 OK.
|
||||
#[poem::handler]
|
||||
pub fn health_handler() -> poem::Response {
|
||||
poem::Response::builder().status(StatusCode::OK).body("ok")
|
||||
}
|
||||
|
||||
/// Assemble the full Poem route tree (WebSocket, MCP, OAuth, assets, webhooks).
|
||||
pub fn build_routes(
|
||||
ctx: AppContext,
|
||||
whatsapp_ctx: Option<Arc<WhatsAppWebhookContext>>,
|
||||
@@ -98,13 +75,10 @@ pub fn build_routes(
|
||||
) -> impl poem::Endpoint {
|
||||
let ctx_arc = std::sync::Arc::new(ctx);
|
||||
|
||||
let (api_service, docs_service) = build_openapi_service(ctx_arc.clone());
|
||||
|
||||
let oauth_state = Arc::new(oauth::OAuthState::new(port));
|
||||
|
||||
let mut route = Route::new()
|
||||
.nest("/api", api_service)
|
||||
.nest("/docs", docs_service.swagger_ui())
|
||||
.at("/health", get(health_handler))
|
||||
.at("/ws", get(ws::ws_handler))
|
||||
.at("/crdt-sync", get(crate::crdt_sync::crdt_sync_handler))
|
||||
.at("/rpc", post(rpc_http_handler))
|
||||
@@ -240,58 +214,6 @@ pub fn debug_crdt_handler(req: &poem::Request) -> poem::Response {
|
||||
.body(serde_json::to_string_pretty(&body).unwrap_or_default())
|
||||
}
|
||||
|
||||
type ApiTuple = (
|
||||
ProjectApi,
|
||||
ModelApi,
|
||||
AnthropicApi,
|
||||
IoApi,
|
||||
ChatApi,
|
||||
AgentsApi,
|
||||
SettingsApi,
|
||||
BotCommandApi,
|
||||
wizard::WizardApi,
|
||||
BotConfigApi,
|
||||
);
|
||||
|
||||
type ApiService = OpenApiService<ApiTuple, ()>;
|
||||
|
||||
/// All HTTP methods are documented by OpenAPI at /docs
|
||||
pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
||||
let api = (
|
||||
ProjectApi { ctx: ctx.clone() },
|
||||
ModelApi { ctx: ctx.clone() },
|
||||
AnthropicApi::new(ctx.clone()),
|
||||
IoApi { ctx: ctx.clone() },
|
||||
ChatApi { ctx: ctx.clone() },
|
||||
AgentsApi { ctx: ctx.clone() },
|
||||
SettingsApi { ctx: ctx.clone() },
|
||||
BotCommandApi { ctx: ctx.clone() },
|
||||
wizard::WizardApi { ctx: ctx.clone() },
|
||||
BotConfigApi { ctx: ctx.clone() },
|
||||
);
|
||||
|
||||
let api_service =
|
||||
OpenApiService::new(api, "Huskies API", "1.0").server("http://127.0.0.1:3001/api");
|
||||
|
||||
let docs_api = (
|
||||
ProjectApi { ctx: ctx.clone() },
|
||||
ModelApi { ctx: ctx.clone() },
|
||||
AnthropicApi::new(ctx.clone()),
|
||||
IoApi { ctx: ctx.clone() },
|
||||
ChatApi { ctx: ctx.clone() },
|
||||
AgentsApi { ctx: ctx.clone() },
|
||||
SettingsApi { ctx: ctx.clone() },
|
||||
BotCommandApi { ctx: ctx.clone() },
|
||||
wizard::WizardApi { ctx: ctx.clone() },
|
||||
BotConfigApi { ctx },
|
||||
);
|
||||
|
||||
let docs_service =
|
||||
OpenApiService::new(docs_api, "Huskies API", "1.0").server("http://127.0.0.1:3001/api");
|
||||
|
||||
(api_service, docs_service)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -341,13 +263,6 @@ mod tests {
|
||||
assert!(port > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_openapi_service_constructs_without_panic() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = Arc::new(context::AppContext::new_test(tmp.path().to_path_buf()));
|
||||
let (_api_service, _docs_service) = build_openapi_service(ctx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_routes_constructs_without_panic() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
//! HTTP model endpoints — REST API for model selection and LLM provider management.
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use crate::io::fs;
|
||||
use crate::llm::chat;
|
||||
use poem_openapi::{Object, OpenApi, Tags, param::Query, payload::Json};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Tags)]
|
||||
enum ModelTags {
|
||||
Model,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Object)]
|
||||
struct ModelPayload {
|
||||
model: String,
|
||||
}
|
||||
|
||||
/// OpenAPI endpoint group for LLM model selection and listing.
|
||||
pub struct ModelApi {
|
||||
pub ctx: Arc<AppContext>,
|
||||
}
|
||||
|
||||
#[OpenApi(tag = "ModelTags::Model")]
|
||||
impl ModelApi {
|
||||
/// Get the currently selected model preference, if any.
|
||||
#[oai(path = "/model", method = "get")]
|
||||
async fn get_model_preference(&self) -> OpenApiResult<Json<Option<String>>> {
|
||||
let result = fs::get_model_preference(self.ctx.store.as_ref()).map_err(bad_request)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// Persist the selected model preference.
|
||||
#[oai(path = "/model", method = "post")]
|
||||
async fn set_model_preference(&self, payload: Json<ModelPayload>) -> OpenApiResult<Json<bool>> {
|
||||
fs::set_model_preference(payload.0.model, self.ctx.store.as_ref()).map_err(bad_request)?;
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
/// Fetch available model names from an Ollama server.
|
||||
/// Optionally override the base URL via query string.
|
||||
/// Returns an empty list when Ollama is unreachable so the UI stays functional.
|
||||
#[oai(path = "/ollama/models", method = "get")]
|
||||
async fn get_ollama_models(
|
||||
&self,
|
||||
base_url: Query<Option<String>>,
|
||||
) -> OpenApiResult<Json<Vec<String>>> {
|
||||
let models = chat::get_ollama_models(base_url.0)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
Ok(Json(models))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl From<std::sync::Arc<AppContext>> for ModelApi {
|
||||
fn from(ctx: std::sync::Arc<AppContext>) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::test_helpers::make_api;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_model_preference_returns_none_when_unset() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<ModelApi>(&dir);
|
||||
let result = api.get_model_preference().await.unwrap();
|
||||
assert!(result.0.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_model_preference_returns_true() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<ModelApi>(&dir);
|
||||
let payload = Json(ModelPayload {
|
||||
model: "claude-3-sonnet".to_string(),
|
||||
});
|
||||
let result = api.set_model_preference(payload).await.unwrap();
|
||||
assert!(result.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_model_preference_returns_value_after_set() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<ModelApi>(&dir);
|
||||
|
||||
let payload = Json(ModelPayload {
|
||||
model: "claude-3-sonnet".to_string(),
|
||||
});
|
||||
api.set_model_preference(payload).await.unwrap();
|
||||
|
||||
let result = api.get_model_preference().await.unwrap();
|
||||
assert_eq!(result.0, Some("claude-3-sonnet".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_model_preference_overwrites_previous_value() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<ModelApi>(&dir);
|
||||
|
||||
api.set_model_preference(Json(ModelPayload {
|
||||
model: "model-a".to_string(),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
api.set_model_preference(Json(ModelPayload {
|
||||
model: "model-b".to_string(),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = api.get_model_preference().await.unwrap();
|
||||
assert_eq!(result.0, Some("model-b".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_ollama_models_returns_empty_list_for_unreachable_url() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<ModelApi>(&dir);
|
||||
// Port 1 is reserved and should immediately refuse the connection.
|
||||
let base_url = Query(Some("http://127.0.0.1:1".to_string()));
|
||||
let result = api.get_ollama_models(base_url).await;
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().0, Vec::<String>::new());
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
//! HTTP project endpoints — thin adapters over `service::project`.
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found};
|
||||
use crate::service::project::{self as svc, Error as ProjectError};
|
||||
use poem::http::StatusCode;
|
||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Tags)]
|
||||
enum ProjectTags {
|
||||
Project,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Object)]
|
||||
struct PathPayload {
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// Map a typed [`ProjectError`] to a `poem::Error` with the appropriate HTTP status.
|
||||
fn map_project_error(e: ProjectError) -> poem::Error {
|
||||
match e {
|
||||
ProjectError::PathNotFound(msg) => not_found(msg),
|
||||
ProjectError::NotADirectory(msg) => bad_request(msg),
|
||||
ProjectError::Internal(msg) => {
|
||||
poem::Error::from_string(msg, StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenAPI endpoint group for project open, close, and listing operations.
|
||||
pub struct ProjectApi {
|
||||
pub ctx: Arc<AppContext>,
|
||||
}
|
||||
|
||||
#[OpenApi(tag = "ProjectTags::Project")]
|
||||
impl ProjectApi {
|
||||
/// Get the currently open project path (if any).
|
||||
///
|
||||
/// Returns null when no project is open.
|
||||
#[oai(path = "/project", method = "get")]
|
||||
async fn get_current_project(&self) -> OpenApiResult<Json<Option<String>>> {
|
||||
let result = svc::get_current_project(&self.ctx.state, self.ctx.store.as_ref())
|
||||
.map_err(map_project_error)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// Open a project and set it as the current project.
|
||||
///
|
||||
/// Persists the selected path for later sessions.
|
||||
#[oai(path = "/project", method = "post")]
|
||||
async fn open_project(&self, payload: Json<PathPayload>) -> OpenApiResult<Json<String>> {
|
||||
let confirmed = svc::open_project(
|
||||
payload.0.path,
|
||||
&self.ctx.state,
|
||||
self.ctx.store.as_ref(),
|
||||
self.ctx.services.agents.port(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_project_error)?;
|
||||
Ok(Json(confirmed))
|
||||
}
|
||||
|
||||
/// Close the current project and clear the stored selection.
|
||||
#[oai(path = "/project", method = "delete")]
|
||||
async fn close_project(&self) -> OpenApiResult<Json<bool>> {
|
||||
// TRACE:MERGE-DEBUG — remove once root cause is found
|
||||
crate::slog_error!(
|
||||
"[MERGE-DEBUG] DELETE /project called! \
|
||||
Backtrace: this is the only code path that clears project_root."
|
||||
);
|
||||
svc::close_project(&self.ctx.state, self.ctx.store.as_ref()).map_err(map_project_error)?;
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
/// List known projects from the store.
|
||||
#[oai(path = "/projects", method = "get")]
|
||||
async fn list_known_projects(&self) -> OpenApiResult<Json<Vec<String>>> {
|
||||
let projects =
|
||||
svc::get_known_projects(self.ctx.store.as_ref()).map_err(map_project_error)?;
|
||||
Ok(Json(projects))
|
||||
}
|
||||
|
||||
/// Forget a known project path.
|
||||
#[oai(path = "/projects/forget", method = "post")]
|
||||
async fn forget_known_project(&self, payload: Json<PathPayload>) -> OpenApiResult<Json<bool>> {
|
||||
svc::forget_known_project(payload.0.path, self.ctx.store.as_ref())
|
||||
.map_err(map_project_error)?;
|
||||
Ok(Json(true))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl From<std::sync::Arc<AppContext>> for ProjectApi {
|
||||
fn from(ctx: std::sync::Arc<AppContext>) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::test_helpers::make_api;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_current_project_returns_none_when_unset() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<ProjectApi>(&dir);
|
||||
// Clear the project root that new_test sets
|
||||
api.close_project().await.unwrap();
|
||||
let result = api.get_current_project().await.unwrap();
|
||||
assert!(result.0.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_current_project_returns_path_from_state() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<ProjectApi>(&dir);
|
||||
let result = api.get_current_project().await.unwrap();
|
||||
assert_eq!(result.0, Some(dir.path().to_string_lossy().to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_project_succeeds_with_valid_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<ProjectApi>(&dir);
|
||||
let path = dir.path().to_string_lossy().to_string();
|
||||
let payload = Json(PathPayload { path: path.clone() });
|
||||
let result = api.open_project(payload).await.unwrap();
|
||||
assert_eq!(result.0, path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_project_fails_with_nonexistent_file_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<ProjectApi>(&dir);
|
||||
// Create a file (not a directory) to trigger validation error
|
||||
let file_path = dir.path().join("not_a_dir.txt");
|
||||
std::fs::write(&file_path, "content").unwrap();
|
||||
let payload = Json(PathPayload {
|
||||
path: file_path.to_string_lossy().to_string(),
|
||||
});
|
||||
let result = api.open_project(payload).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_project_returns_true() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<ProjectApi>(&dir);
|
||||
let result = api.close_project().await.unwrap();
|
||||
assert!(result.0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_project_clears_current_project() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<ProjectApi>(&dir);
|
||||
|
||||
// Verify project is set initially
|
||||
let before = api.get_current_project().await.unwrap();
|
||||
assert!(before.0.is_some());
|
||||
|
||||
// Close the project
|
||||
api.close_project().await.unwrap();
|
||||
|
||||
// Verify project is now None
|
||||
let after = api.get_current_project().await.unwrap();
|
||||
assert!(after.0.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_known_projects_returns_empty_initially() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<ProjectApi>(&dir);
|
||||
// Close the project so the store has no known projects
|
||||
api.close_project().await.unwrap();
|
||||
let result = api.list_known_projects().await.unwrap();
|
||||
assert!(result.0.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_known_projects_returns_project_after_open() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<ProjectApi>(&dir);
|
||||
let path = dir.path().to_string_lossy().to_string();
|
||||
|
||||
api.open_project(Json(PathPayload { path: path.clone() }))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = api.list_known_projects().await.unwrap();
|
||||
assert!(result.0.contains(&path));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forget_known_project_removes_project() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<ProjectApi>(&dir);
|
||||
let path = dir.path().to_string_lossy().to_string();
|
||||
|
||||
api.open_project(Json(PathPayload { path: path.clone() }))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let before = api.list_known_projects().await.unwrap();
|
||||
assert!(before.0.contains(&path));
|
||||
|
||||
let result = api
|
||||
.forget_known_project(Json(PathPayload { path: path.clone() }))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.0);
|
||||
|
||||
let after = api.list_known_projects().await.unwrap();
|
||||
assert!(!after.0.contains(&path));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forget_known_project_returns_true_for_nonexistent_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<ProjectApi>(&dir);
|
||||
let result = api
|
||||
.forget_known_project(Json(PathPayload {
|
||||
path: "/some/unknown/path".to_string(),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.0);
|
||||
}
|
||||
}
|
||||
@@ -1,615 +0,0 @@
|
||||
//! HTTP settings endpoints — REST API for user preferences and editor configuration.
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use crate::service::settings as svc;
|
||||
use crate::store::StoreOps;
|
||||
use poem_openapi::{Object, OpenApi, Tags, param::Query, payload::Json};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
// Re-export service types so the test module (which does `use super::*`) can
|
||||
// access them without modification.
|
||||
pub use svc::EDITOR_COMMAND_KEY;
|
||||
pub use svc::ProjectSettings;
|
||||
#[cfg(test)]
|
||||
pub use svc::settings_from_config;
|
||||
|
||||
/// Thin wrapper — delegates to [`svc::validate_project_settings`] and maps
|
||||
/// the typed error to `String` so existing tests calling `.unwrap_err()` can
|
||||
/// call `.contains()` directly.
|
||||
fn validate_project_settings(s: &ProjectSettings) -> Result<(), String> {
|
||||
svc::validate_project_settings(s).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Thin wrapper — delegates to [`svc::write_project_settings`] and maps the
|
||||
/// typed error to `String` so existing tests can call `.unwrap()` unchanged.
|
||||
#[cfg(test)]
|
||||
fn write_project_settings(project_root: &Path, s: &ProjectSettings) -> Result<(), String> {
|
||||
svc::write_project_settings(project_root, s).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Return the configured editor command from the store, or `None` if not set.
|
||||
pub fn get_editor_command_from_store(ctx: &AppContext) -> Option<String> {
|
||||
svc::get_editor_command(&*ctx.store)
|
||||
}
|
||||
|
||||
#[derive(Tags)]
|
||||
enum SettingsTags {
|
||||
Settings,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct EditorCommandPayload {
|
||||
editor_command: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Object, Serialize)]
|
||||
struct EditorCommandResponse {
|
||||
editor_command: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Object, Serialize)]
|
||||
struct OpenFileResponse {
|
||||
success: bool,
|
||||
}
|
||||
|
||||
/// OpenAPI endpoint group for user preferences and editor configuration.
|
||||
pub struct SettingsApi {
|
||||
pub ctx: Arc<AppContext>,
|
||||
}
|
||||
|
||||
#[OpenApi(tag = "SettingsTags::Settings")]
|
||||
impl SettingsApi {
|
||||
/// Get the configured editor command (e.g. "zed", "code", "cursor"), or null if not set.
|
||||
#[oai(path = "/settings/editor", method = "get")]
|
||||
async fn get_editor(&self) -> OpenApiResult<Json<EditorCommandResponse>> {
|
||||
let editor_command = get_editor_command_from_store(&self.ctx);
|
||||
Ok(Json(EditorCommandResponse { editor_command }))
|
||||
}
|
||||
|
||||
/// Open a file in the configured editor at the given line number.
|
||||
///
|
||||
/// Invokes the stored editor CLI (e.g. "zed", "code") with `path:line` as the argument.
|
||||
/// Returns an error if no editor is configured or if the process fails to spawn.
|
||||
#[oai(path = "/settings/open-file", method = "post")]
|
||||
async fn open_file(
|
||||
&self,
|
||||
path: Query<String>,
|
||||
line: Query<Option<u32>>,
|
||||
) -> OpenApiResult<Json<OpenFileResponse>> {
|
||||
svc::open_file_in_editor(&*self.ctx.store, &path.0, line.0)
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(OpenFileResponse { success: true }))
|
||||
}
|
||||
|
||||
/// Get current project.toml scalar settings as JSON.
|
||||
#[oai(path = "/settings", method = "get")]
|
||||
async fn get_settings(&self) -> OpenApiResult<Json<ProjectSettings>> {
|
||||
let project_root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||
let s =
|
||||
svc::load_project_settings(&project_root).map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(s))
|
||||
}
|
||||
|
||||
/// Update project.toml scalar settings. Array sections (component, agent) are preserved.
|
||||
///
|
||||
/// Returns 400 if the input fails validation (e.g. unknown qa mode, negative max_retries).
|
||||
#[oai(path = "/settings", method = "put")]
|
||||
async fn put_settings(
|
||||
&self,
|
||||
payload: Json<ProjectSettings>,
|
||||
) -> OpenApiResult<Json<ProjectSettings>> {
|
||||
validate_project_settings(&payload.0).map_err(bad_request)?;
|
||||
let project_root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||
svc::write_project_settings(&project_root, &payload.0)
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
// Re-read to confirm what was written
|
||||
let s =
|
||||
svc::load_project_settings(&project_root).map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(s))
|
||||
}
|
||||
|
||||
/// Set the preferred editor command (e.g. "zed", "code", "cursor").
|
||||
/// Pass null or empty string to clear the preference.
|
||||
#[oai(path = "/settings/editor", method = "put")]
|
||||
async fn set_editor(
|
||||
&self,
|
||||
payload: Json<EditorCommandPayload>,
|
||||
) -> OpenApiResult<Json<EditorCommandResponse>> {
|
||||
let editor_command = payload.0.editor_command;
|
||||
let trimmed = editor_command
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty());
|
||||
match trimmed {
|
||||
Some(cmd) => {
|
||||
self.ctx.store.set(EDITOR_COMMAND_KEY, json!(cmd));
|
||||
self.ctx.store.save().map_err(bad_request)?;
|
||||
Ok(Json(EditorCommandResponse {
|
||||
editor_command: Some(cmd.to_string()),
|
||||
}))
|
||||
}
|
||||
None => {
|
||||
self.ctx.store.delete(EDITOR_COMMAND_KEY);
|
||||
self.ctx.store.save().map_err(bad_request)?;
|
||||
Ok(Json(EditorCommandResponse {
|
||||
editor_command: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl From<std::sync::Arc<AppContext>> for SettingsApi {
|
||||
fn from(ctx: std::sync::Arc<AppContext>) -> Self {
|
||||
Self { ctx }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::test_helpers::{make_api, test_ctx};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_editor_returns_none_when_unset() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<SettingsApi>(&dir);
|
||||
let result = api.get_editor().await.unwrap();
|
||||
assert!(result.0.editor_command.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_editor_stores_command() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<SettingsApi>(&dir);
|
||||
let payload = Json(EditorCommandPayload {
|
||||
editor_command: Some("zed".to_string()),
|
||||
});
|
||||
let result = api.set_editor(payload).await.unwrap();
|
||||
assert_eq!(result.0.editor_command, Some("zed".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_editor_clears_command_on_null() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<SettingsApi>(&dir);
|
||||
api.set_editor(Json(EditorCommandPayload {
|
||||
editor_command: Some("zed".to_string()),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = api
|
||||
.set_editor(Json(EditorCommandPayload {
|
||||
editor_command: None,
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.0.editor_command.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_editor_clears_command_on_empty_string() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<SettingsApi>(&dir);
|
||||
let result = api
|
||||
.set_editor(Json(EditorCommandPayload {
|
||||
editor_command: Some(String::new()),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.0.editor_command.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_editor_trims_whitespace_only() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<SettingsApi>(&dir);
|
||||
let result = api
|
||||
.set_editor(Json(EditorCommandPayload {
|
||||
editor_command: Some(" ".to_string()),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.0.editor_command.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_editor_returns_value_after_set() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<SettingsApi>(&dir);
|
||||
api.set_editor(Json(EditorCommandPayload {
|
||||
editor_command: Some("cursor".to_string()),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = api.get_editor().await.unwrap();
|
||||
assert_eq!(result.0.editor_command, Some("cursor".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_command_defaults_to_null() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
let result = get_editor_command_from_store(&ctx);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_editor_command_persists_in_store() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
|
||||
ctx.store.set(EDITOR_COMMAND_KEY, json!("zed"));
|
||||
ctx.store.save().unwrap();
|
||||
|
||||
let result = get_editor_command_from_store(&ctx);
|
||||
assert_eq!(result, Some("zed".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_editor_command_from_store_returns_value() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
|
||||
ctx.store.set(EDITOR_COMMAND_KEY, json!("code"));
|
||||
let result = get_editor_command_from_store(&ctx);
|
||||
assert_eq!(result, Some("code".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_editor_command_returns_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
|
||||
ctx.store.set(EDITOR_COMMAND_KEY, json!("cursor"));
|
||||
ctx.store.delete(EDITOR_COMMAND_KEY);
|
||||
let result = get_editor_command_from_store(&ctx);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_command_survives_reload() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let store_path = dir.path().join(".huskies_store.json");
|
||||
|
||||
{
|
||||
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||
ctx.store.set(EDITOR_COMMAND_KEY, json!("zed"));
|
||||
ctx.store.save().unwrap();
|
||||
}
|
||||
|
||||
// Reload from disk
|
||||
let store2 = crate::store::JsonFileStore::new(store_path).unwrap();
|
||||
let val = store2.get(EDITOR_COMMAND_KEY);
|
||||
assert_eq!(val, Some(json!("zed")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_editor_http_handler_returns_null_when_not_set() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
let api = SettingsApi { ctx: Arc::new(ctx) };
|
||||
let result = api.get_editor().await.unwrap().0;
|
||||
assert!(result.editor_command.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_editor_http_handler_stores_value() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
let api = SettingsApi { ctx: Arc::new(ctx) };
|
||||
let result = api
|
||||
.set_editor(Json(EditorCommandPayload {
|
||||
editor_command: Some("zed".to_string()),
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(result.editor_command, Some("zed".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_editor_http_handler_clears_value_when_null() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let ctx = test_ctx(dir.path());
|
||||
let api = SettingsApi { ctx: Arc::new(ctx) };
|
||||
// First set a value
|
||||
api.set_editor(Json(EditorCommandPayload {
|
||||
editor_command: Some("code".to_string()),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
// Now clear it
|
||||
let result = api
|
||||
.set_editor(Json(EditorCommandPayload {
|
||||
editor_command: None,
|
||||
}))
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
assert!(result.editor_command.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_file_returns_error_when_no_editor_configured() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<SettingsApi>(&dir);
|
||||
let result = api
|
||||
.open_file(Query("src/main.rs".to_string()), Query(Some(42)))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(err.status(), poem::http::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_file_spawns_editor_with_path_and_line() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<SettingsApi>(&dir);
|
||||
// Configure the editor to "echo" which is a safe no-op command
|
||||
api.set_editor(Json(EditorCommandPayload {
|
||||
editor_command: Some("echo".to_string()),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = api
|
||||
.open_file(Query("src/main.rs".to_string()), Query(Some(42)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.0.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_file_spawns_editor_with_path_only_when_no_line() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<SettingsApi>(&dir);
|
||||
api.set_editor(Json(EditorCommandPayload {
|
||||
editor_command: Some("echo".to_string()),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = api
|
||||
.open_file(Query("src/lib.rs".to_string()), Query(None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.0.success);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_file_returns_error_for_nonexistent_editor() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api::<SettingsApi>(&dir);
|
||||
api.set_editor(Json(EditorCommandPayload {
|
||||
editor_command: Some("this_editor_does_not_exist_xyz_abc".to_string()),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = api
|
||||
.open_file(Query("src/main.rs".to_string()), Query(Some(1)))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ── /api/settings GET/PUT ──────────────────────────────────────────────
|
||||
|
||||
fn default_project_settings() -> ProjectSettings {
|
||||
let cfg = crate::config::ProjectConfig::default();
|
||||
settings_from_config(&cfg)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_settings_returns_defaults_when_no_project_toml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Create .huskies dir so project root detection works but no project.toml
|
||||
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
||||
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||
let api = SettingsApi { ctx: Arc::new(ctx) };
|
||||
let result = api.get_settings().await.unwrap().0;
|
||||
assert_eq!(result.default_qa, "server");
|
||||
assert_eq!(result.max_retries, 2);
|
||||
assert!(result.rate_limit_notifications);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_settings_writes_and_returns_settings() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
||||
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||
let api = SettingsApi { ctx: Arc::new(ctx) };
|
||||
|
||||
let mut s = default_project_settings();
|
||||
s.default_qa = "agent".to_string();
|
||||
s.max_retries = 5;
|
||||
s.rate_limit_notifications = false;
|
||||
|
||||
let result = api.put_settings(Json(s)).await.unwrap().0;
|
||||
assert_eq!(result.default_qa, "agent");
|
||||
assert_eq!(result.max_retries, 5);
|
||||
assert!(!result.rate_limit_notifications);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_settings_preserves_agent_sections() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let huskies_dir = dir.path().join(".huskies");
|
||||
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||||
|
||||
// Write a project.toml with agent sections
|
||||
std::fs::write(
|
||||
huskies_dir.join("project.toml"),
|
||||
r#"
|
||||
[[agent]]
|
||||
name = "coder-1"
|
||||
model = "sonnet"
|
||||
stage = "coder"
|
||||
|
||||
[[component]]
|
||||
name = "server"
|
||||
path = "."
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||
let api = SettingsApi { ctx: Arc::new(ctx) };
|
||||
|
||||
let mut s = default_project_settings();
|
||||
s.default_qa = "human".to_string();
|
||||
api.put_settings(Json(s)).await.unwrap();
|
||||
|
||||
// Re-read the file and verify agent/component sections are still there
|
||||
let written = std::fs::read_to_string(huskies_dir.join("project.toml")).unwrap();
|
||||
assert!(
|
||||
written.contains("coder-1"),
|
||||
"agent section should be preserved"
|
||||
);
|
||||
assert!(
|
||||
written.contains("server"),
|
||||
"component section should be preserved"
|
||||
);
|
||||
assert!(written.contains("human"), "new setting should be written");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn put_settings_rejects_invalid_qa_mode() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
||||
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||
let api = SettingsApi { ctx: Arc::new(ctx) };
|
||||
|
||||
let mut s = default_project_settings();
|
||||
s.default_qa = "invalid_mode".to_string();
|
||||
|
||||
let result = api.put_settings(Json(s)).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert_eq!(err.status(), poem::http::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_project_settings_accepts_valid_qa_modes() {
|
||||
for mode in &["server", "agent", "human"] {
|
||||
let s = ProjectSettings {
|
||||
default_qa: mode.to_string(),
|
||||
default_coder_model: None,
|
||||
max_coders: None,
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
rendezvous: None,
|
||||
watcher_sweep_interval_secs: 60,
|
||||
watcher_done_retention_secs: 14400,
|
||||
};
|
||||
assert!(
|
||||
validate_project_settings(&s).is_ok(),
|
||||
"qa mode '{mode}' should be valid"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_project_settings_rejects_unknown_qa_mode() {
|
||||
let s = ProjectSettings {
|
||||
default_qa: "robot".to_string(),
|
||||
default_coder_model: None,
|
||||
max_coders: None,
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
rendezvous: None,
|
||||
watcher_sweep_interval_secs: 60,
|
||||
watcher_done_retention_secs: 14400,
|
||||
};
|
||||
let err = validate_project_settings(&s).unwrap_err();
|
||||
assert!(err.contains("robot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_read_project_settings_roundtrip() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join(".huskies")).unwrap();
|
||||
|
||||
let s = ProjectSettings {
|
||||
default_qa: "agent".to_string(),
|
||||
default_coder_model: Some("opus".to_string()),
|
||||
max_coders: Some(2),
|
||||
max_retries: 3,
|
||||
base_branch: Some("main".to_string()),
|
||||
rate_limit_notifications: false,
|
||||
timezone: Some("America/New_York".to_string()),
|
||||
rendezvous: Some("ws://host:3001/crdt-sync".to_string()),
|
||||
watcher_sweep_interval_secs: 30,
|
||||
watcher_done_retention_secs: 7200,
|
||||
};
|
||||
|
||||
write_project_settings(dir.path(), &s).unwrap();
|
||||
|
||||
let config = crate::config::ProjectConfig::load(dir.path()).unwrap();
|
||||
let loaded = settings_from_config(&config);
|
||||
|
||||
assert_eq!(loaded.default_qa, "agent");
|
||||
assert_eq!(loaded.default_coder_model, Some("opus".to_string()));
|
||||
assert_eq!(loaded.max_coders, Some(2));
|
||||
assert_eq!(loaded.max_retries, 3);
|
||||
assert_eq!(loaded.base_branch, Some("main".to_string()));
|
||||
assert!(!loaded.rate_limit_notifications);
|
||||
assert_eq!(loaded.timezone, Some("America/New_York".to_string()));
|
||||
assert_eq!(
|
||||
loaded.rendezvous,
|
||||
Some("ws://host:3001/crdt-sync".to_string())
|
||||
);
|
||||
assert_eq!(loaded.watcher_sweep_interval_secs, 30);
|
||||
assert_eq!(loaded.watcher_done_retention_secs, 7200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_project_settings_clears_optional_fields_when_none() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let huskies_dir = dir.path().join(".huskies");
|
||||
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||||
|
||||
// First write with optional fields set
|
||||
let s_with = ProjectSettings {
|
||||
default_qa: "server".to_string(),
|
||||
default_coder_model: Some("sonnet".to_string()),
|
||||
max_coders: Some(3),
|
||||
max_retries: 2,
|
||||
base_branch: Some("master".to_string()),
|
||||
rate_limit_notifications: true,
|
||||
timezone: Some("UTC".to_string()),
|
||||
rendezvous: None,
|
||||
watcher_sweep_interval_secs: 60,
|
||||
watcher_done_retention_secs: 14400,
|
||||
};
|
||||
write_project_settings(dir.path(), &s_with).unwrap();
|
||||
|
||||
// Then write with optional fields cleared
|
||||
let s_clear = ProjectSettings {
|
||||
default_qa: "server".to_string(),
|
||||
default_coder_model: None,
|
||||
max_coders: None,
|
||||
max_retries: 2,
|
||||
base_branch: None,
|
||||
rate_limit_notifications: true,
|
||||
timezone: None,
|
||||
rendezvous: None,
|
||||
watcher_sweep_interval_secs: 60,
|
||||
watcher_done_retention_secs: 14400,
|
||||
};
|
||||
write_project_settings(dir.path(), &s_clear).unwrap();
|
||||
|
||||
let config = crate::config::ProjectConfig::load(dir.path()).unwrap();
|
||||
let loaded = settings_from_config(&config);
|
||||
assert!(loaded.default_coder_model.is_none());
|
||||
assert!(loaded.max_coders.is_none());
|
||||
assert!(loaded.base_branch.is_none());
|
||||
assert!(loaded.timezone.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,9 @@
|
||||
//! Shared test utilities for HTTP handler tests.
|
||||
//!
|
||||
//! Import with `use crate::http::test_helpers::{make_api, test_ctx};`
|
||||
|
||||
use crate::http::context::AppContext;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Build an [`AppContext`] rooted at `dir` for use in tests.
|
||||
pub(crate) fn test_ctx(dir: &Path) -> AppContext {
|
||||
AppContext::new_test(dir.to_path_buf())
|
||||
}
|
||||
|
||||
/// Build an API struct rooted in `dir` for use in tests.
|
||||
///
|
||||
/// Requires the API type to implement `From<Arc<AppContext>>`. Add a
|
||||
/// `#[cfg(test)]` impl block to each API struct to opt in.
|
||||
pub(crate) fn make_api<T: From<Arc<AppContext>>>(dir: &TempDir) -> T {
|
||||
Arc::new(test_ctx(dir.path())).into()
|
||||
}
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
//! HTTP wizard endpoints — REST API for the project setup wizard.
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found};
|
||||
use crate::io::wizard::{WizardState, WizardStep};
|
||||
use crate::service::wizard as svc;
|
||||
use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Tags)]
|
||||
enum WizardTags {
|
||||
Wizard,
|
||||
}
|
||||
|
||||
/// Response for a single wizard step.
|
||||
#[derive(Serialize, Object)]
|
||||
struct StepResponse {
|
||||
step: String,
|
||||
label: String,
|
||||
status: String,
|
||||
#[oai(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
/// Full wizard state response.
|
||||
#[derive(Serialize, Object)]
|
||||
struct WizardResponse {
|
||||
steps: Vec<StepResponse>,
|
||||
current_step_index: usize,
|
||||
completed: bool,
|
||||
}
|
||||
|
||||
/// Request body for confirming/skipping a step or submitting content.
|
||||
#[derive(Deserialize, Object)]
|
||||
struct StepActionPayload {
|
||||
/// Optional content to store for the step (e.g., generated spec).
|
||||
#[oai(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&WizardState> for WizardResponse {
|
||||
fn from(state: &WizardState) -> Self {
|
||||
WizardResponse {
|
||||
steps: state
|
||||
.steps
|
||||
.iter()
|
||||
.map(|s| StepResponse {
|
||||
step: serde_json::to_value(s.step)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_default(),
|
||||
label: s.step.label().to_string(),
|
||||
status: serde_json::to_value(&s.status)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(String::from))
|
||||
.unwrap_or_default(),
|
||||
content: s.content.clone(),
|
||||
})
|
||||
.collect(),
|
||||
current_step_index: state.current_step_index(),
|
||||
completed: state.completed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_step(step_str: &str) -> Result<WizardStep, poem::Error> {
|
||||
let quoted = format!("\"{step_str}\"");
|
||||
serde_json::from_str::<WizardStep>("ed)
|
||||
.map_err(|_| not_found(format!("Unknown wizard step: {step_str}")))
|
||||
}
|
||||
|
||||
/// OpenAPI endpoint group for the multi-step project setup wizard.
|
||||
pub struct WizardApi {
|
||||
pub ctx: Arc<AppContext>,
|
||||
}
|
||||
|
||||
#[OpenApi(tag = "WizardTags::Wizard")]
|
||||
impl WizardApi {
|
||||
/// Get the current wizard state.
|
||||
///
|
||||
/// Returns the full setup wizard progress including all steps and their
|
||||
/// statuses. Returns 404 if no wizard is active.
|
||||
#[oai(path = "/wizard", method = "get")]
|
||||
async fn get_wizard_state(&self) -> OpenApiResult<Json<WizardResponse>> {
|
||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||
let state = svc::get_state(&root).map_err(|_| not_found("No wizard active".to_string()))?;
|
||||
Ok(Json(WizardResponse::from(&state)))
|
||||
}
|
||||
|
||||
/// Set a step's content and mark it as awaiting confirmation.
|
||||
///
|
||||
/// Used after the agent generates content for a step. The content is
|
||||
/// stored for preview and the step is marked as awaiting user confirmation.
|
||||
#[oai(path = "/wizard/step/:step/content", method = "put")]
|
||||
async fn set_step_content(
|
||||
&self,
|
||||
step: Path<String>,
|
||||
payload: Json<StepActionPayload>,
|
||||
) -> OpenApiResult<Json<WizardResponse>> {
|
||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||
let wizard_step = parse_step(&step.0)?;
|
||||
let state = svc::set_step_content(&root, wizard_step, payload.0.content)
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(WizardResponse::from(&state)))
|
||||
}
|
||||
|
||||
/// Confirm a step and advance to the next.
|
||||
///
|
||||
/// The step must be the current active step. Returns the updated wizard state.
|
||||
#[oai(path = "/wizard/step/:step/confirm", method = "post")]
|
||||
async fn confirm_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
|
||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||
let wizard_step = parse_step(&step.0)?;
|
||||
let state =
|
||||
svc::mark_step_confirmed(&root, wizard_step).map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(WizardResponse::from(&state)))
|
||||
}
|
||||
|
||||
/// Skip a step and advance to the next.
|
||||
///
|
||||
/// The step must be the current active step.
|
||||
#[oai(path = "/wizard/step/:step/skip", method = "post")]
|
||||
async fn skip_step(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
|
||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||
let wizard_step = parse_step(&step.0)?;
|
||||
let state =
|
||||
svc::mark_step_skipped(&root, wizard_step).map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(WizardResponse::from(&state)))
|
||||
}
|
||||
|
||||
/// Mark a step as generating (agent is working on it).
|
||||
#[oai(path = "/wizard/step/:step/generating", method = "post")]
|
||||
async fn mark_generating(&self, step: Path<String>) -> OpenApiResult<Json<WizardResponse>> {
|
||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
||||
let wizard_step = parse_step(&step.0)?;
|
||||
let state = svc::mark_step_generating(&root, wizard_step)
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
Ok(Json(WizardResponse::from(&state)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::AppContext;
|
||||
use poem::http::StatusCode;
|
||||
use poem::test::TestClient;
|
||||
use poem_openapi::OpenApiService;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, TestClient<impl poem::Endpoint>) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = dir.path().to_path_buf();
|
||||
std::fs::create_dir_all(root.join(".huskies")).unwrap();
|
||||
|
||||
let ctx = Arc::new(AppContext::new_test(root.clone()));
|
||||
let api = WizardApi { ctx };
|
||||
let service = OpenApiService::new(api, "test", "0.1.0");
|
||||
let client = TestClient::new(service);
|
||||
(dir, client)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_wizard_returns_404_when_no_wizard() {
|
||||
let (_dir, client) = setup();
|
||||
let resp = client.get("/wizard").send().await;
|
||||
resp.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_wizard_returns_state_when_active() {
|
||||
let (dir, client) = setup();
|
||||
WizardState::init_if_missing(dir.path());
|
||||
|
||||
let resp = client.get("/wizard").send().await;
|
||||
resp.assert_status_is_ok();
|
||||
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||
assert_eq!(body["current_step_index"], 1);
|
||||
assert!(!body["completed"].as_bool().unwrap());
|
||||
assert_eq!(body["steps"].as_array().unwrap().len(), 8);
|
||||
assert_eq!(body["steps"][0]["status"], "confirmed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirm_step_advances_wizard() {
|
||||
let (dir, client) = setup();
|
||||
WizardState::init_if_missing(dir.path());
|
||||
|
||||
let resp = client.post("/wizard/step/context/confirm").send().await;
|
||||
resp.assert_status_is_ok();
|
||||
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||
assert_eq!(body["current_step_index"], 2);
|
||||
assert_eq!(body["steps"][1]["status"], "confirmed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirm_wrong_step_returns_error() {
|
||||
let (dir, client) = setup();
|
||||
WizardState::init_if_missing(dir.path());
|
||||
|
||||
// Try to confirm step 3 (stack) when current is step 2 (context)
|
||||
let resp = client.post("/wizard/step/stack/confirm").send().await;
|
||||
resp.assert_status(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skip_step_advances_wizard() {
|
||||
let (dir, client) = setup();
|
||||
WizardState::init_if_missing(dir.path());
|
||||
|
||||
let resp = client.post("/wizard/step/context/skip").send().await;
|
||||
resp.assert_status_is_ok();
|
||||
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||
assert_eq!(body["steps"][1]["status"], "skipped");
|
||||
assert_eq!(body["current_step_index"], 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_step_content_marks_awaiting_confirmation() {
|
||||
let (dir, client) = setup();
|
||||
WizardState::init_if_missing(dir.path());
|
||||
|
||||
let resp = client
|
||||
.put("/wizard/step/context/content")
|
||||
.body_json(&serde_json::json!({
|
||||
"content": "# My Project\n\nA great project."
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||
assert_eq!(body["steps"][1]["status"], "awaiting_confirmation");
|
||||
assert_eq!(
|
||||
body["steps"][1]["content"],
|
||||
"# My Project\n\nA great project."
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mark_generating_updates_step() {
|
||||
let (dir, client) = setup();
|
||||
WizardState::init_if_missing(dir.path());
|
||||
|
||||
let resp = client.post("/wizard/step/context/generating").send().await;
|
||||
resp.assert_status_is_ok();
|
||||
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||
assert_eq!(body["steps"][1]["status"], "generating");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_step_returns_404() {
|
||||
let (dir, client) = setup();
|
||||
WizardState::init_if_missing(dir.path());
|
||||
|
||||
let resp = client.post("/wizard/step/nonexistent/confirm").send().await;
|
||||
resp.assert_status(StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_wizard_flow_completes() {
|
||||
let (dir, client) = setup();
|
||||
WizardState::init_if_missing(dir.path());
|
||||
|
||||
// Steps 2-8 (scaffold is already confirmed)
|
||||
let steps = [
|
||||
"context",
|
||||
"stack",
|
||||
"test_script",
|
||||
"build_script",
|
||||
"lint_script",
|
||||
"release_script",
|
||||
"test_coverage",
|
||||
];
|
||||
for step in steps {
|
||||
let resp = client
|
||||
.post(format!("/wizard/step/{step}/confirm"))
|
||||
.send()
|
||||
.await;
|
||||
resp.assert_status_is_ok();
|
||||
}
|
||||
|
||||
// Check final state
|
||||
let resp = client.get("/wizard").send().await;
|
||||
resp.assert_status_is_ok();
|
||||
let body: serde_json::Value = resp.0.into_body().into_json().await.unwrap();
|
||||
assert!(body["completed"].as_bool().unwrap());
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ pub async fn write_file(path: String, content: String, state: &SessionState) ->
|
||||
write_file_impl(full_path, content).await
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, poem_openapi::Object)]
|
||||
#[derive(Serialize, Debug)]
|
||||
/// A directory listing entry with its name and kind (file or directory).
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Serialize, Debug, poem_openapi::Object)]
|
||||
#[derive(Serialize, Debug)]
|
||||
/// A single file that matched a text search, with its match count.
|
||||
pub struct SearchResult {
|
||||
pub path: String,
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
/// Output captured from a shell command: stdout, stderr, and exit code.
|
||||
#[derive(Serialize, Debug, poem_openapi::Object)]
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct CommandOutput {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
|
||||
@@ -181,6 +181,7 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
// Event bus: broadcast channel for pipeline lifecycle events.
|
||||
let (watcher_tx, _) = broadcast::channel::<io::watcher::WatcherEvent>(1024);
|
||||
let agents = Arc::new(AgentPool::new(port, watcher_tx.clone()));
|
||||
crate::crdt_sync::init_rpc_agents(Arc::clone(&agents));
|
||||
|
||||
// Filesystem watcher: watches config files for hot-reload.
|
||||
if let Some(ref root) = *app_state.project_root.lock().unwrap() {
|
||||
|
||||
@@ -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