Restore codebase deleted by bad auto-commit e4227cf
Commit e4227cf (a story creation auto-commit) erroneously deleted 175
files from master's tree, likely due to a race condition between
concurrent git operations. This commit re-adds all files from the
working directory.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1209
server/src/http/agents.rs
Normal file
1209
server/src/http/agents.rs
Normal file
File diff suppressed because it is too large
Load Diff
208
server/src/http/agents_sse.rs
Normal file
208
server/src/http/agents_sse.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use crate::http::context::AppContext;
|
||||
use poem::handler;
|
||||
use poem::http::StatusCode;
|
||||
use poem::web::{Data, Path};
|
||||
use poem::{Body, IntoResponse, Response};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// SSE endpoint: `GET /agents/:story_id/:agent_name/stream`
|
||||
///
|
||||
/// Streams `AgentEvent`s as Server-Sent Events. Each event is JSON-encoded
|
||||
/// with `data:` prefix and double newline terminator per the SSE spec.
|
||||
///
|
||||
/// `AgentEvent::Thinking` events are intentionally excluded — thinking traces
|
||||
/// are internal model state and must never be displayed in the UI.
|
||||
#[handler]
|
||||
pub async fn agent_stream(
|
||||
Path((story_id, agent_name)): Path<(String, String)>,
|
||||
ctx: Data<&Arc<AppContext>>,
|
||||
) -> impl IntoResponse {
|
||||
let mut rx = match ctx.agents.subscribe(&story_id, &agent_name) {
|
||||
Ok(rx) => rx,
|
||||
Err(e) => {
|
||||
return Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(Body::from_string(e));
|
||||
}
|
||||
};
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
// Never forward thinking traces to the UI — they are
|
||||
// internal model state and must not be displayed.
|
||||
if matches!(event, crate::agents::AgentEvent::Thinking { .. }) {
|
||||
continue;
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string(&event) {
|
||||
yield Ok::<_, std::io::Error>(format!("data: {json}\n\n"));
|
||||
}
|
||||
// Check for terminal events
|
||||
match &event {
|
||||
crate::agents::AgentEvent::Done { .. }
|
||||
| crate::agents::AgentEvent::Error { .. } => break,
|
||||
crate::agents::AgentEvent::Status { status, .. }
|
||||
if status == "stopped" => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
let msg = format!("{{\"type\":\"warning\",\"message\":\"Skipped {n} events\"}}");
|
||||
yield Ok::<_, std::io::Error>(format!("data: {msg}\n\n"));
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Response::builder()
|
||||
.header("Content-Type", "text/event-stream")
|
||||
.header("Cache-Control", "no-cache")
|
||||
.header("Connection", "keep-alive")
|
||||
.body(Body::from_bytes_stream(
|
||||
futures::StreamExt::map(stream, |r| r.map(bytes::Bytes::from)),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agents::{AgentEvent, AgentStatus};
|
||||
use crate::http::context::AppContext;
|
||||
use poem::{EndpointExt, Route, get};
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn test_app(ctx: Arc<AppContext>) -> impl poem::Endpoint {
|
||||
Route::new()
|
||||
.at(
|
||||
"/agents/:story_id/:agent_name/stream",
|
||||
get(agent_stream),
|
||||
)
|
||||
.data(ctx)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thinking_events_are_not_forwarded_via_sse() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let ctx = Arc::new(AppContext::new_test(tmp.path().to_path_buf()));
|
||||
|
||||
// Inject a running agent and get its broadcast sender.
|
||||
let tx = ctx
|
||||
.agents
|
||||
.inject_test_agent("1_story", "coder-1", AgentStatus::Running);
|
||||
|
||||
// Spawn a task that sends events after the SSE connection is established.
|
||||
let tx_clone = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
// Brief pause so the SSE handler has subscribed before we emit.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
|
||||
// Thinking event — must be filtered out.
|
||||
let _ = tx_clone.send(AgentEvent::Thinking {
|
||||
story_id: "1_story".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "secret thinking text".to_string(),
|
||||
});
|
||||
|
||||
// Output event — must be forwarded.
|
||||
let _ = tx_clone.send(AgentEvent::Output {
|
||||
story_id: "1_story".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "visible output".to_string(),
|
||||
});
|
||||
|
||||
// Done event — closes the stream.
|
||||
let _ = tx_clone.send(AgentEvent::Done {
|
||||
story_id: "1_story".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
session_id: None,
|
||||
});
|
||||
});
|
||||
|
||||
let cli = poem::test::TestClient::new(test_app(ctx));
|
||||
let resp = cli
|
||||
.get("/agents/1_story/coder-1/stream")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let body = resp.0.into_body().into_string().await.unwrap();
|
||||
|
||||
// Thinking content must not appear anywhere in the SSE output.
|
||||
assert!(
|
||||
!body.contains("secret thinking text"),
|
||||
"Thinking text must not be forwarded via SSE: {body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("\"type\":\"thinking\""),
|
||||
"Thinking event type must not appear in SSE output: {body}"
|
||||
);
|
||||
|
||||
// Output event must be present.
|
||||
assert!(
|
||||
body.contains("visible output"),
|
||||
"Output event must be forwarded via SSE: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains("\"type\":\"output\""),
|
||||
"Output event type must appear in SSE output: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn output_and_done_events_are_forwarded_via_sse() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let ctx = Arc::new(AppContext::new_test(tmp.path().to_path_buf()));
|
||||
|
||||
let tx = ctx
|
||||
.agents
|
||||
.inject_test_agent("2_story", "coder-1", AgentStatus::Running);
|
||||
|
||||
let tx_clone = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
|
||||
let _ = tx_clone.send(AgentEvent::Output {
|
||||
story_id: "2_story".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
text: "step 1 output".to_string(),
|
||||
});
|
||||
|
||||
let _ = tx_clone.send(AgentEvent::Done {
|
||||
story_id: "2_story".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
session_id: Some("sess-abc".to_string()),
|
||||
});
|
||||
});
|
||||
|
||||
let cli = poem::test::TestClient::new(test_app(ctx));
|
||||
let resp = cli
|
||||
.get("/agents/2_story/coder-1/stream")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let body = resp.0.into_body().into_string().await.unwrap();
|
||||
|
||||
assert!(body.contains("step 1 output"), "Output must be forwarded: {body}");
|
||||
assert!(body.contains("\"type\":\"done\""), "Done event must be forwarded: {body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_agent_returns_404() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let ctx = Arc::new(AppContext::new_test(tmp.path().to_path_buf()));
|
||||
|
||||
let cli = poem::test::TestClient::new(test_app(ctx));
|
||||
let resp = cli
|
||||
.get("/agents/nonexistent/coder-1/stream")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
resp.0.status(),
|
||||
poem::http::StatusCode::NOT_FOUND,
|
||||
"Unknown agent must return 404"
|
||||
);
|
||||
}
|
||||
}
|
||||
318
server/src/http/anthropic.rs
Normal file
318
server/src/http/anthropic.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use crate::llm::chat;
|
||||
use crate::store::StoreOps;
|
||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models";
|
||||
const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
const KEY_ANTHROPIC_API_KEY: &str = "anthropic_api_key";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicModelsResponse {
|
||||
data: Vec<AnthropicModelInfo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicModelInfo {
|
||||
id: String,
|
||||
context_window: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Object)]
|
||||
struct AnthropicModelSummary {
|
||||
id: String,
|
||||
context_window: u64,
|
||||
}
|
||||
|
||||
fn get_anthropic_api_key(ctx: &AppContext) -> Result<String, String> {
|
||||
match ctx.store.get(KEY_ANTHROPIC_API_KEY) {
|
||||
Some(value) => {
|
||||
if let Some(key) = value.as_str() {
|
||||
if key.is_empty() {
|
||||
Err("Anthropic API key is empty. Please set your API key.".to_string())
|
||||
} else {
|
||||
Ok(key.to_string())
|
||||
}
|
||||
} else {
|
||||
Err("Stored API key is not a string".to_string())
|
||||
}
|
||||
}
|
||||
None => Err("Anthropic API key not found. Please set your API key.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Object)]
|
||||
struct ApiKeyPayload {
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Tags)]
|
||||
enum AnthropicTags {
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
pub struct AnthropicApi {
|
||||
ctx: Arc<AppContext>,
|
||||
}
|
||||
|
||||
impl AnthropicApi {
|
||||
pub fn new(ctx: Arc<AppContext>) -> Self {
|
||||
Self { 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 =
|
||||
chat::get_anthropic_api_key_exists(self.ctx.store.as_ref()).map_err(bad_request)?;
|
||||
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>> {
|
||||
chat::set_anthropic_api_key(self.ctx.store.as_ref(), payload.0.api_key)
|
||||
.map_err(bad_request)?;
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
/// List available Anthropic models.
|
||||
#[oai(path = "/anthropic/models", method = "get")]
|
||||
async fn list_anthropic_models(&self) -> OpenApiResult<Json<Vec<AnthropicModelSummary>>> {
|
||||
self.list_anthropic_models_from(ANTHROPIC_MODELS_URL).await
|
||||
}
|
||||
}
|
||||
|
||||
impl AnthropicApi {
|
||||
async fn list_anthropic_models_from(
|
||||
&self,
|
||||
url: &str,
|
||||
) -> OpenApiResult<Json<Vec<AnthropicModelSummary>>> {
|
||||
let api_key = get_anthropic_api_key(self.ctx.as_ref()).map_err(bad_request)?;
|
||||
let client = reqwest::Client::new();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-api-key",
|
||||
HeaderValue::from_str(&api_key).map_err(|e| bad_request(e.to_string()))?,
|
||||
);
|
||||
headers.insert(
|
||||
"anthropic-version",
|
||||
HeaderValue::from_static(ANTHROPIC_VERSION),
|
||||
);
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.headers(headers)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
return Err(bad_request(format!(
|
||||
"Anthropic API error {status}: {error_text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.json::<AnthropicModelsResponse>()
|
||||
.await
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
let models = body
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|m| AnthropicModelSummary {
|
||||
id: m.id,
|
||||
context_window: m.context_window,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(models))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::AppContext;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_ctx(dir: &TempDir) -> AppContext {
|
||||
AppContext::new_test(dir.path().to_path_buf())
|
||||
}
|
||||
|
||||
fn make_api(dir: &TempDir) -> AnthropicApi {
|
||||
AnthropicApi::new(Arc::new(test_ctx(dir)))
|
||||
}
|
||||
|
||||
// -- 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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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(&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(&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(&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(&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);
|
||||
}
|
||||
}
|
||||
148
server/src/http/assets.rs
Normal file
148
server/src/http/assets.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use poem::{
|
||||
Response, handler,
|
||||
http::{StatusCode, header},
|
||||
web::Path,
|
||||
};
|
||||
use rust_embed::RustEmbed;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../frontend/dist"]
|
||||
struct EmbeddedAssets;
|
||||
|
||||
fn serve_embedded(path: &str) -> Response {
|
||||
let normalized = if path.is_empty() {
|
||||
"index.html"
|
||||
} else {
|
||||
path.trim_start_matches('/')
|
||||
};
|
||||
|
||||
let is_asset_request = normalized.starts_with("assets/");
|
||||
let asset = if is_asset_request {
|
||||
EmbeddedAssets::get(normalized)
|
||||
} else {
|
||||
EmbeddedAssets::get(normalized).or_else(|| {
|
||||
if normalized == "index.html" {
|
||||
None
|
||||
} else {
|
||||
EmbeddedAssets::get("index.html")
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
match asset {
|
||||
Some(content) => {
|
||||
let body = content.data.into_owned();
|
||||
let mime = mime_guess::from_path(normalized)
|
||||
.first_or_octet_stream()
|
||||
.to_string();
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime)
|
||||
.body(body)
|
||||
}
|
||||
None => Response::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body("Not Found"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve a single embedded asset from the `assets/` folder.
|
||||
#[handler]
|
||||
pub fn embedded_asset(Path(path): Path<String>) -> Response {
|
||||
let asset_path = format!("assets/{path}");
|
||||
serve_embedded(&asset_path)
|
||||
}
|
||||
|
||||
/// Serve an embedded file by path (falls back to `index.html` for SPA routing).
|
||||
#[handler]
|
||||
pub fn embedded_file(Path(path): Path<String>) -> Response {
|
||||
serve_embedded(&path)
|
||||
}
|
||||
|
||||
/// Serve the embedded SPA entrypoint.
|
||||
#[handler]
|
||||
pub fn embedded_index() -> Response {
|
||||
serve_embedded("index.html")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use poem::http::StatusCode;
|
||||
|
||||
#[test]
|
||||
fn non_asset_path_spa_fallback_or_not_found() {
|
||||
// Non-asset paths fall back to index.html for SPA client-side routing.
|
||||
// In release builds (with embedded dist/) this returns 200.
|
||||
// In debug builds without a built frontend dist/ it returns 404.
|
||||
let response = serve_embedded("__nonexistent_spa_route__.html");
|
||||
let status = response.status();
|
||||
assert!(
|
||||
status == StatusCode::OK || status == StatusCode::NOT_FOUND,
|
||||
"unexpected status: {status}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_asset_path_prefix_returns_not_found() {
|
||||
// assets/ prefix: no SPA fallback – returns 404 if the file does not exist
|
||||
let response = serve_embedded("assets/__nonexistent__.js");
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_embedded_does_not_panic_on_empty_path() {
|
||||
// Empty path normalises to index.html; OK in release, 404 in debug without dist/
|
||||
let response = serve_embedded("");
|
||||
let status = response.status();
|
||||
assert!(
|
||||
status == StatusCode::OK || status == StatusCode::NOT_FOUND,
|
||||
"unexpected status: {status}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_assets_struct_is_iterable() {
|
||||
// Verifies that rust-embed compiled the EmbeddedAssets struct correctly.
|
||||
// In debug builds without a built frontend dist/ directory the iterator is empty; that is
|
||||
// expected. In release builds it will contain all bundled frontend files.
|
||||
let _files: Vec<_> = EmbeddedAssets::iter().collect();
|
||||
// No assertion needed – the test passes as long as it compiles and does not panic.
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedded_index_handler_returns_ok_or_not_found() {
|
||||
// Route the handler through TestClient; index.html is the SPA entry point.
|
||||
let app = poem::Route::new().at("/", poem::get(embedded_index));
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
let resp = cli.get("/").send().await;
|
||||
let status = resp.0.status();
|
||||
assert!(
|
||||
status == StatusCode::OK || status == StatusCode::NOT_FOUND,
|
||||
"unexpected status: {status}",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedded_file_handler_with_path_returns_ok_or_not_found() {
|
||||
// Non-asset paths fall back to index.html (SPA routing) or 404.
|
||||
let app = poem::Route::new().at("/*path", poem::get(embedded_file));
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
let resp = cli.get("/__spa_route__").send().await;
|
||||
let status = resp.0.status();
|
||||
assert!(
|
||||
status == StatusCode::OK || status == StatusCode::NOT_FOUND,
|
||||
"unexpected status: {status}",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedded_asset_handler_missing_file_returns_not_found() {
|
||||
// The assets/ prefix disables SPA fallback; missing files must return 404.
|
||||
let app = poem::Route::new().at("/assets/*path", poem::get(embedded_asset));
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
let resp = cli.get("/assets/__nonexistent__.js").send().await;
|
||||
assert_eq!(resp.0.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
58
server/src/http/chat.rs
Normal file
58
server/src/http/chat.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
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,
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
120
server/src/http/context.rs
Normal file
120
server/src/http/context.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use crate::agents::{AgentPool, ReconciliationEvent};
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
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};
|
||||
|
||||
/// The user's decision when responding to a permission dialog.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PermissionDecision {
|
||||
/// One-time denial.
|
||||
Deny,
|
||||
/// One-time approval.
|
||||
Approve,
|
||||
/// Approve and persist the rule to `.claude/settings.json` so Claude Code's
|
||||
/// built-in permission system handles future checks without prompting.
|
||||
AlwaysAllow,
|
||||
}
|
||||
|
||||
/// A permission request forwarded from the MCP `prompt_permission` tool to the
|
||||
/// active WebSocket session. The MCP handler blocks on `response_tx` until the
|
||||
/// user approves or denies via the frontend dialog.
|
||||
pub struct PermissionForward {
|
||||
pub request_id: String,
|
||||
pub tool_name: String,
|
||||
pub tool_input: serde_json::Value,
|
||||
pub response_tx: oneshot::Sender<PermissionDecision>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppContext {
|
||||
pub state: Arc<SessionState>,
|
||||
pub store: Arc<JsonFileStore>,
|
||||
pub workflow: Arc<std::sync::Mutex<WorkflowState>>,
|
||||
pub agents: Arc<AgentPool>,
|
||||
/// Broadcast channel for filesystem watcher events. WebSocket handlers
|
||||
/// subscribe to this to push lifecycle notifications to connected clients.
|
||||
pub watcher_tx: broadcast::Sender<WatcherEvent>,
|
||||
/// Broadcast channel for startup reconciliation progress events.
|
||||
/// WebSocket handlers subscribe to this to push real-time reconciliation
|
||||
/// updates to connected clients.
|
||||
pub reconciliation_tx: broadcast::Sender<ReconciliationEvent>,
|
||||
/// Sender for permission requests originating from the MCP
|
||||
/// `prompt_permission` tool. The MCP handler sends a [`PermissionForward`]
|
||||
/// and awaits the oneshot response.
|
||||
pub perm_tx: mpsc::UnboundedSender<PermissionForward>,
|
||||
/// Receiver for permission requests. The active WebSocket handler locks
|
||||
/// this and polls for incoming permission forwards.
|
||||
pub perm_rx: Arc<tokio::sync::Mutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||
/// Child process of the QA app launched for manual testing.
|
||||
/// Only one instance runs at a time.
|
||||
pub qa_app_process: Arc<std::sync::Mutex<Option<std::process::Child>>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl AppContext {
|
||||
pub fn new_test(project_root: std::path::PathBuf) -> Self {
|
||||
let state = SessionState::default();
|
||||
*state.project_root.lock().unwrap() = Some(project_root.clone());
|
||||
let store_path = project_root.join(".storkit_store.json");
|
||||
let (watcher_tx, _) = broadcast::channel(64);
|
||||
let (reconciliation_tx, _) = broadcast::channel(64);
|
||||
let (perm_tx, perm_rx) = mpsc::unbounded_channel();
|
||||
Self {
|
||||
state: Arc::new(state),
|
||||
store: Arc::new(JsonFileStore::new(store_path).unwrap()),
|
||||
workflow: Arc::new(std::sync::Mutex::new(WorkflowState::default())),
|
||||
agents: Arc::new(AgentPool::new(3001, watcher_tx.clone())),
|
||||
watcher_tx,
|
||||
reconciliation_tx,
|
||||
perm_tx,
|
||||
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
||||
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type OpenApiResult<T> = poem::Result<T>;
|
||||
|
||||
pub fn bad_request(message: String) -> poem::Error {
|
||||
poem::Error::from_string(message, StatusCode::BAD_REQUEST)
|
||||
}
|
||||
|
||||
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);
|
||||
assert_eq!(PermissionDecision::Approve, PermissionDecision::Approve);
|
||||
assert_eq!(PermissionDecision::AlwaysAllow, PermissionDecision::AlwaysAllow);
|
||||
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);
|
||||
}
|
||||
}
|
||||
66
server/src/http/health.rs
Normal file
66
server/src/http/health.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use poem::handler;
|
||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||
use serde::Serialize;
|
||||
|
||||
/// Health check endpoint.
|
||||
///
|
||||
/// Returns a static "ok" response to indicate the server is running.
|
||||
#[handler]
|
||||
pub fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
#[derive(Tags)]
|
||||
enum HealthTags {
|
||||
Health,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Object)]
|
||||
pub struct HealthStatus {
|
||||
status: String,
|
||||
}
|
||||
|
||||
pub struct HealthApi;
|
||||
|
||||
#[OpenApi(tag = "HealthTags::Health")]
|
||||
impl HealthApi {
|
||||
/// Health check endpoint.
|
||||
///
|
||||
/// Returns a JSON status object to confirm the server is running.
|
||||
#[oai(path = "/health", method = "get")]
|
||||
async fn health(&self) -> Json<HealthStatus> {
|
||||
Json(HealthStatus {
|
||||
status: "ok".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn handler_health_returns_ok() {
|
||||
let app = poem::Route::new().at("/health", poem::get(health));
|
||||
let cli = poem::test::TestClient::new(app);
|
||||
let resp = cli.get("/health").send().await;
|
||||
resp.assert_status_is_ok();
|
||||
resp.assert_text("ok").await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_status_serializes_to_json() {
|
||||
let status = HealthStatus {
|
||||
status: "ok".to_string(),
|
||||
};
|
||||
let json = serde_json::to_value(&status).unwrap();
|
||||
assert_eq!(json["status"], "ok");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn api_health_returns_ok_status() {
|
||||
let api = HealthApi;
|
||||
let response = api.health().await;
|
||||
assert_eq!(response.0.status, "ok");
|
||||
}
|
||||
}
|
||||
405
server/src/http/io.rs
Normal file
405
server/src/http/io.rs
Normal file
@@ -0,0 +1,405 @@
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use crate::io::fs as io_fs;
|
||||
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>,
|
||||
}
|
||||
|
||||
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 = io_fs::read_file(payload.0.path, &self.ctx.state)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
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>> {
|
||||
io_fs::write_file(payload.0.path, payload.0.content, &self.ctx.state)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
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<io_fs::FileEntry>>> {
|
||||
let entries = io_fs::list_directory(payload.0.path, &self.ctx.state)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
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<io_fs::FileEntry>>> {
|
||||
let entries = io_fs::list_directory_absolute(payload.0.path)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
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>> {
|
||||
io_fs::create_directory_absolute(payload.0.path)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
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 = io_fs::get_home_directory().map_err(bad_request)?;
|
||||
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 = io_fs::list_project_files(&self.ctx.state)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
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::io::search::SearchResult>>> {
|
||||
let results = crate::io::search::search_files(payload.0.query, &self.ctx.state)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
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::io::shell::CommandOutput>> {
|
||||
let output =
|
||||
crate::io::shell::exec_shell(payload.0.command, payload.0.args, &self.ctx.state)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
Ok(Json(output))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::AppContext;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_api(dir: &TempDir) -> IoApi {
|
||||
IoApi {
|
||||
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&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(&dir);
|
||||
let payload = Json(FilePathPayload {
|
||||
path: "nonexistent_dir".to_string(),
|
||||
});
|
||||
let result = api.list_directory(payload).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
}
|
||||
731
server/src/http/mcp/agent_tools.rs
Normal file
731
server/src/http/mcp/agent_tools.rs
Normal file
@@ -0,0 +1,731 @@
|
||||
use crate::agents::PipelineStage;
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::http::context::AppContext;
|
||||
use crate::http::settings::get_editor_command_from_store;
|
||||
use crate::slog_warn;
|
||||
use crate::worktree;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub(super) async fn tool_start_agent(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
let agent_name = args.get("agent_name").and_then(|v| v.as_str());
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
let info = ctx
|
||||
.agents
|
||||
.start_agent(&project_root, story_id, agent_name, None)
|
||||
.await?;
|
||||
|
||||
// Snapshot coverage baseline from the most recent coverage report (best-effort).
|
||||
if let Some(pct) = read_coverage_percent_from_json(&project_root)
|
||||
&& let Err(e) = crate::http::workflow::write_coverage_baseline_to_story_file(
|
||||
&project_root,
|
||||
story_id,
|
||||
pct,
|
||||
)
|
||||
{
|
||||
slog_warn!("[start_agent] Could not write coverage baseline to story file: {e}");
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&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,
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
/// Try to read the overall line coverage percentage from the llvm-cov JSON report.
|
||||
///
|
||||
/// Expects the file at `{project_root}/.storkit/coverage/server.json`.
|
||||
/// Returns `None` if the file is absent, unreadable, or cannot be parsed.
|
||||
pub(super) fn read_coverage_percent_from_json(project_root: &std::path::Path) -> Option<f64> {
|
||||
let path = project_root
|
||||
.join(".storkit")
|
||||
.join("coverage")
|
||||
.join("server.json");
|
||||
let contents = std::fs::read_to_string(&path).ok()?;
|
||||
let json: Value = serde_json::from_str(&contents).ok()?;
|
||||
// cargo llvm-cov --json format: data[0].totals.lines.percent
|
||||
json.pointer("/data/0/totals/lines/percent")
|
||||
.and_then(|v| v.as_f64())
|
||||
}
|
||||
|
||||
pub(super) async fn tool_stop_agent(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
let agent_name = args
|
||||
.get("agent_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: agent_name")?;
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
ctx.agents
|
||||
.stop_agent(&project_root, story_id, agent_name)
|
||||
.await?;
|
||||
|
||||
Ok(format!("Agent '{agent_name}' for story '{story_id}' stopped."))
|
||||
}
|
||||
|
||||
pub(super) fn tool_list_agents(ctx: &AppContext) -> Result<String, String> {
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state).ok();
|
||||
let agents = ctx.agents.list_agents()?;
|
||||
serde_json::to_string_pretty(&json!(agents
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
project_root
|
||||
.as_deref()
|
||||
.map(|root| !crate::http::agents::story_is_archived(root, &a.story_id))
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.map(|a| json!({
|
||||
"story_id": a.story_id,
|
||||
"agent_name": a.agent_name,
|
||||
"status": a.status.to_string(),
|
||||
"session_id": a.session_id,
|
||||
"worktree_path": a.worktree_path,
|
||||
}))
|
||||
.collect::<Vec<_>>()))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
pub(super) async fn tool_get_agent_output_poll(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
let agent_name = args
|
||||
.get("agent_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: agent_name")?;
|
||||
|
||||
// Try draining in-memory events first.
|
||||
match ctx.agents.drain_events(story_id, agent_name) {
|
||||
Ok(drained) => {
|
||||
let done = drained.iter().any(|e| {
|
||||
matches!(
|
||||
e,
|
||||
crate::agents::AgentEvent::Done { .. }
|
||||
| crate::agents::AgentEvent::Error { .. }
|
||||
)
|
||||
});
|
||||
|
||||
let events: Vec<serde_json::Value> = drained
|
||||
.into_iter()
|
||||
.filter_map(|e| serde_json::to_value(&e).ok())
|
||||
.collect();
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"events": events,
|
||||
"done": done,
|
||||
"event_count": events.len(),
|
||||
"message": if done { "Agent stream ended." } else if events.is_empty() { "No new events. Call again to continue." } else { "Events returned. Call again to continue." }
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
Err(_) => {
|
||||
// Agent not in memory — fall back to persistent log file.
|
||||
get_agent_output_from_log(story_id, agent_name, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fall back to reading agent output from the persistent log file on disk.
|
||||
///
|
||||
/// Tries to find the log file via the agent's stored log_session_id first,
|
||||
/// then falls back to `find_latest_log` scanning the log directory.
|
||||
pub(super) fn get_agent_output_from_log(
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
ctx: &AppContext,
|
||||
) -> Result<String, String> {
|
||||
use crate::agent_log;
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
|
||||
// Try to find the log file: first from in-memory agent info, then by scanning.
|
||||
let log_path = ctx
|
||||
.agents
|
||||
.get_log_info(story_id, agent_name)
|
||||
.map(|(session_id, root)| agent_log::log_file_path(&root, story_id, agent_name, &session_id))
|
||||
.filter(|p| p.exists())
|
||||
.or_else(|| agent_log::find_latest_log(&project_root, story_id, agent_name));
|
||||
|
||||
let log_path = match log_path {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return serde_json::to_string_pretty(&json!({
|
||||
"events": [],
|
||||
"done": true,
|
||||
"event_count": 0,
|
||||
"message": format!("No agent '{agent_name}' for story '{story_id}' and no log file found."),
|
||||
"source": "none",
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"));
|
||||
}
|
||||
};
|
||||
|
||||
match agent_log::read_log(&log_path) {
|
||||
Ok(entries) => {
|
||||
let events: Vec<serde_json::Value> = entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
let mut val = e.event;
|
||||
if let serde_json::Value::Object(ref mut map) = val {
|
||||
map.insert(
|
||||
"timestamp".to_string(),
|
||||
serde_json::Value::String(e.timestamp),
|
||||
);
|
||||
}
|
||||
val
|
||||
})
|
||||
.collect();
|
||||
|
||||
let count = events.len();
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"events": events,
|
||||
"done": true,
|
||||
"event_count": count,
|
||||
"message": "Events loaded from persistent log file.",
|
||||
"source": "log_file",
|
||||
"log_file": log_path.to_string_lossy(),
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
Err(e) => Err(format!("Failed to read log file: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tool_get_agent_config(ctx: &AppContext) -> Result<String, String> {
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
let config = ProjectConfig::load(&project_root)?;
|
||||
|
||||
// Collect available (idle) agent names across all stages so the caller can
|
||||
// see at a glance which agents are free to start (story 190).
|
||||
let mut available_names: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::new();
|
||||
for stage in &[
|
||||
PipelineStage::Coder,
|
||||
PipelineStage::Qa,
|
||||
PipelineStage::Mergemaster,
|
||||
PipelineStage::Other,
|
||||
] {
|
||||
if let Ok(names) = ctx.agents.available_agents_for_stage(&config, stage) {
|
||||
available_names.extend(names);
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&json!(config
|
||||
.agent
|
||||
.iter()
|
||||
.map(|a| json!({
|
||||
"name": a.name,
|
||||
"role": a.role,
|
||||
"model": a.model,
|
||||
"allowed_tools": a.allowed_tools,
|
||||
"max_turns": a.max_turns,
|
||||
"max_budget_usd": a.max_budget_usd,
|
||||
"available": available_names.contains(&a.name),
|
||||
}))
|
||||
.collect::<Vec<_>>()))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
pub(super) async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
let agent_name = args
|
||||
.get("agent_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: agent_name")?;
|
||||
let timeout_ms = args
|
||||
.get("timeout_ms")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(300_000); // default: 5 minutes
|
||||
|
||||
let info = ctx
|
||||
.agents
|
||||
.wait_for_agent(story_id, agent_name, timeout_ms)
|
||||
.await?;
|
||||
|
||||
let commits = match (&info.worktree_path, &info.base_branch) {
|
||||
(Some(wt_path), Some(base)) => get_worktree_commits(wt_path, base).await,
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let completion = info.completion.as_ref().map(|r| json!({
|
||||
"summary": r.summary,
|
||||
"gates_passed": r.gates_passed,
|
||||
"gate_output": r.gate_output,
|
||||
}));
|
||||
|
||||
serde_json::to_string_pretty(&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,
|
||||
"commits": commits,
|
||||
"completion": completion,
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
pub(super) async fn tool_create_worktree(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
let info = ctx.agents.create_worktree(&project_root, story_id).await?;
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"story_id": story_id,
|
||||
"worktree_path": info.path.to_string_lossy(),
|
||||
"branch": info.branch,
|
||||
"base_branch": info.base_branch,
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
pub(super) fn tool_list_worktrees(ctx: &AppContext) -> Result<String, String> {
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
let entries = worktree::list_worktrees(&project_root)?;
|
||||
|
||||
serde_json::to_string_pretty(&json!(entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"story_id": e.story_id,
|
||||
"path": e.path.to_string_lossy(),
|
||||
}))
|
||||
.collect::<Vec<_>>()))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
pub(super) async fn tool_remove_worktree(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
let config = ProjectConfig::load(&project_root)?;
|
||||
worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await?;
|
||||
|
||||
Ok(format!("Worktree for story '{story_id}' removed."))
|
||||
}
|
||||
|
||||
pub(super) fn tool_get_editor_command(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let worktree_path = args
|
||||
.get("worktree_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: worktree_path")?;
|
||||
|
||||
let editor = get_editor_command_from_store(ctx)
|
||||
.ok_or_else(|| "No editor configured. Set one via PUT /api/settings/editor.".to_string())?;
|
||||
|
||||
Ok(format!("{editor} {worktree_path}"))
|
||||
}
|
||||
|
||||
/// Run `git log <base>..HEAD --oneline` in the worktree and return the commit
|
||||
/// summaries, or `None` if git is unavailable or there are no new commits.
|
||||
pub(super) async fn get_worktree_commits(worktree_path: &str, base_branch: &str) -> Option<Vec<String>> {
|
||||
let wt = worktree_path.to_string();
|
||||
let base = base_branch.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["log", &format!("{base}..HEAD"), "--oneline"])
|
||||
.current_dir(&wt)
|
||||
.output()
|
||||
.ok()?;
|
||||
|
||||
if output.status.success() {
|
||||
let lines: Vec<String> = String::from_utf8(output.stdout)
|
||||
.ok()?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| l.to_string())
|
||||
.collect();
|
||||
Some(lines)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::AppContext;
|
||||
use crate::store::StoreOps;
|
||||
|
||||
fn test_ctx(dir: &std::path::Path) -> AppContext {
|
||||
AppContext::new_test(dir.to_path_buf())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_list_agents_empty() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_list_agents(&ctx).unwrap();
|
||||
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
|
||||
assert!(parsed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_agent_config_no_project_toml_returns_default_agent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
// No project.toml → default config with one fallback agent
|
||||
let result = tool_get_agent_config(&ctx).unwrap();
|
||||
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
|
||||
// Default config contains one agent entry with default values
|
||||
assert_eq!(parsed.len(), 1, "default config should have one fallback agent");
|
||||
assert!(parsed[0].get("name").is_some());
|
||||
assert!(parsed[0].get("role").is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_get_agent_output_poll_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_get_agent_output_poll(&json!({"agent_name": "bot"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_get_agent_output_poll_missing_agent_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result =
|
||||
tool_get_agent_output_poll(&json!({"story_id": "1_test"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("agent_name"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_get_agent_output_poll_no_agent_falls_back_to_empty_log() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
// No agent registered, no log file → returns empty response from log fallback
|
||||
let result = tool_get_agent_output_poll(
|
||||
&json!({"story_id": "99_nope", "agent_name": "bot"}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["done"], true);
|
||||
assert_eq!(parsed["event_count"], 0);
|
||||
assert!(
|
||||
parsed["message"].as_str().unwrap_or("").contains("No agent"),
|
||||
"expected 'No agent' message: {parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_get_agent_output_poll_with_running_agent_returns_empty_events() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
// Inject a running agent — no events broadcast yet
|
||||
ctx.agents
|
||||
.inject_test_agent("10_story", "worker", crate::agents::AgentStatus::Running);
|
||||
let result = tool_get_agent_output_poll(
|
||||
&json!({"story_id": "10_story", "agent_name": "worker"}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["done"], false);
|
||||
assert_eq!(parsed["event_count"], 0);
|
||||
assert!(parsed["events"].is_array());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_stop_agent_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_stop_agent(&json!({"agent_name": "bot"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_stop_agent_missing_agent_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_stop_agent(&json!({"story_id": "1_test"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("agent_name"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_start_agent_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_start_agent(&json!({}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_start_agent_no_agent_name_no_coder_returns_clear_error() {
|
||||
// Config has only a supervisor — start_agent without agent_name should
|
||||
// refuse rather than silently assigning supervisor.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
r#"
|
||||
[[agent]]
|
||||
name = "supervisor"
|
||||
stage = "other"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_start_agent(&json!({"story_id": "42_my_story"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(
|
||||
err.contains("coder"),
|
||||
"error should mention 'coder', got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_start_agent_no_agent_name_picks_coder_not_supervisor() {
|
||||
// Config has supervisor first, then coder-1. Without agent_name the
|
||||
// coder should be selected, not supervisor. The call will fail due to
|
||||
// missing git repo / worktree, but the error must NOT be about
|
||||
// "No coder agent configured".
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".storkit");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
r#"
|
||||
[[agent]]
|
||||
name = "supervisor"
|
||||
stage = "other"
|
||||
|
||||
[[agent]]
|
||||
name = "coder-1"
|
||||
stage = "coder"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_start_agent(&json!({"story_id": "42_my_story"}), &ctx).await;
|
||||
// May succeed or fail for infrastructure reasons (no git repo), but
|
||||
// must NOT fail with "No coder agent configured".
|
||||
if let Err(err) = result {
|
||||
assert!(
|
||||
!err.contains("No coder agent configured"),
|
||||
"should not fail on agent selection, got: {err}"
|
||||
);
|
||||
// Should also not complain about supervisor being absent.
|
||||
assert!(
|
||||
!err.contains("supervisor"),
|
||||
"should not select supervisor, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_create_worktree_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_create_worktree(&json!({}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_remove_worktree_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_remove_worktree(&json!({}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_list_worktrees_empty_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_list_worktrees(&ctx).unwrap();
|
||||
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
|
||||
assert!(parsed.is_empty());
|
||||
}
|
||||
|
||||
// ── Editor command tool tests ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tool_get_editor_command_missing_worktree_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_get_editor_command(&json!({}), &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("worktree_path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_editor_command_no_editor_configured() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_get_editor_command(
|
||||
&json!({"worktree_path": "/some/path"}),
|
||||
&ctx,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No editor configured"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_editor_command_formats_correctly() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
ctx.store.set("editor_command", json!("zed"));
|
||||
|
||||
let result = tool_get_editor_command(
|
||||
&json!({"worktree_path": "/home/user/worktrees/37_my_story"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result, "zed /home/user/worktrees/37_my_story");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_editor_command_works_with_vscode() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
ctx.store.set("editor_command", json!("code"));
|
||||
|
||||
let result = tool_get_editor_command(
|
||||
&json!({"worktree_path": "/path/to/worktree"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result, "code /path/to/worktree");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_editor_command_in_tools_list() {
|
||||
use super::super::{handle_tools_list};
|
||||
let resp = handle_tools_list(Some(json!(1)));
|
||||
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
||||
let tool = tools.iter().find(|t| t["name"] == "get_editor_command");
|
||||
assert!(tool.is_some(), "get_editor_command missing from tools list");
|
||||
let t = tool.unwrap();
|
||||
assert!(t["description"].is_string());
|
||||
let required = t["inputSchema"]["required"].as_array().unwrap();
|
||||
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
|
||||
assert!(req_names.contains(&"worktree_path"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_agent_tool_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_wait_for_agent(&json!({"agent_name": "bot"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_agent_tool_missing_agent_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_wait_for_agent(&json!({"story_id": "1_test"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("agent_name"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_agent_tool_nonexistent_agent_returns_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result =
|
||||
tool_wait_for_agent(&json!({"story_id": "99_nope", "agent_name": "bot", "timeout_ms": 50}), &ctx)
|
||||
.await;
|
||||
// No agent registered — should error
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_agent_tool_returns_completed_agent() {
|
||||
use crate::agents::AgentStatus;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
ctx.agents
|
||||
.inject_test_agent("41_story", "worker", AgentStatus::Completed);
|
||||
|
||||
let result = tool_wait_for_agent(
|
||||
&json!({"story_id": "41_story", "agent_name": "worker"}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["status"], "completed");
|
||||
assert_eq!(parsed["story_id"], "41_story");
|
||||
assert_eq!(parsed["agent_name"], "worker");
|
||||
// commits key present (may be null since no real worktree)
|
||||
assert!(parsed.get("commits").is_some());
|
||||
// completion key present (null for agents that didn't call report_completion)
|
||||
assert!(parsed.get("completion").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_agent_tool_in_list() {
|
||||
use super::super::{handle_tools_list};
|
||||
let resp = handle_tools_list(Some(json!(1)));
|
||||
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
||||
let wait_tool = tools.iter().find(|t| t["name"] == "wait_for_agent");
|
||||
assert!(wait_tool.is_some(), "wait_for_agent missing from tools list");
|
||||
let t = wait_tool.unwrap();
|
||||
assert!(t["description"].as_str().unwrap().contains("block") || t["description"].as_str().unwrap().contains("Block"));
|
||||
let required = t["inputSchema"]["required"].as_array().unwrap();
|
||||
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
|
||||
assert!(req_names.contains(&"story_id"));
|
||||
assert!(req_names.contains(&"agent_name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_coverage_percent_from_json_parses_llvm_cov_format() {
|
||||
use std::fs;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cov_dir = tmp.path().join(".storkit/coverage");
|
||||
fs::create_dir_all(&cov_dir).unwrap();
|
||||
let json_content = r#"{"data":[{"totals":{"lines":{"count":100,"covered":78,"percent":78.0}}}]}"#;
|
||||
fs::write(cov_dir.join("server.json"), json_content).unwrap();
|
||||
|
||||
let pct = read_coverage_percent_from_json(tmp.path());
|
||||
assert_eq!(pct, Some(78.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_coverage_percent_from_json_returns_none_when_absent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pct = read_coverage_percent_from_json(tmp.path());
|
||||
assert!(pct.is_none());
|
||||
}
|
||||
}
|
||||
735
server/src/http/mcp/diagnostics.rs
Normal file
735
server/src/http/mcp/diagnostics.rs
Normal file
@@ -0,0 +1,735 @@
|
||||
use crate::agents::move_story_to_stage;
|
||||
use crate::http::context::AppContext;
|
||||
use crate::log_buffer;
|
||||
use crate::slog;
|
||||
use crate::slog_warn;
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
|
||||
pub(super) fn tool_get_server_logs(args: &Value) -> Result<String, String> {
|
||||
let lines_count = args
|
||||
.get("lines")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n.min(1000) as usize)
|
||||
.unwrap_or(100);
|
||||
let filter = args.get("filter").and_then(|v| v.as_str());
|
||||
let severity = args
|
||||
.get("severity")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(log_buffer::LogLevel::from_str_ci);
|
||||
|
||||
let recent = log_buffer::global().get_recent(lines_count, filter, severity.as_ref());
|
||||
let joined = recent.join("\n");
|
||||
// Clamp to lines_count actual lines in case any entry contains embedded newlines.
|
||||
let all_lines: Vec<&str> = joined.lines().collect();
|
||||
let start = all_lines.len().saturating_sub(lines_count);
|
||||
Ok(all_lines[start..].join("\n"))
|
||||
}
|
||||
|
||||
/// Rebuild the server binary and re-exec (delegates to `crate::rebuild`).
|
||||
pub(super) async fn tool_rebuild_and_restart(ctx: &AppContext) -> Result<String, String> {
|
||||
slog!("[rebuild] Rebuild and restart requested via MCP tool");
|
||||
let project_root = ctx.state.get_project_root().unwrap_or_default();
|
||||
crate::rebuild::rebuild_and_restart(&ctx.agents, &project_root).await
|
||||
}
|
||||
|
||||
/// Generate a Claude Code permission rule string for the given tool name and input.
|
||||
///
|
||||
/// - `Edit` / `Write` / `Read` / `Grep` / `Glob` etc. → just the tool name
|
||||
/// - `Bash` → `Bash(first_word *)` derived from the `command` field in `tool_input`
|
||||
/// - `mcp__*` → the full tool name (e.g. `mcp__storkit__create_story`)
|
||||
fn generate_permission_rule(tool_name: &str, tool_input: &Value) -> String {
|
||||
if tool_name == "Bash" {
|
||||
// Extract command from tool_input.command and use first word as prefix
|
||||
let command_str = tool_input
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let first_word = command_str.split_whitespace().next().unwrap_or("unknown");
|
||||
format!("Bash({first_word} *)")
|
||||
} else {
|
||||
// For Edit, Write, Read, Glob, Grep, MCP tools, etc. — use the tool name directly
|
||||
tool_name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a permission rule to `.claude/settings.json` in the project root.
|
||||
/// Does nothing if the rule already exists. Creates the file if missing.
|
||||
pub(super) fn add_permission_rule(
|
||||
project_root: &std::path::Path,
|
||||
rule: &str,
|
||||
) -> Result<(), String> {
|
||||
let claude_dir = project_root.join(".claude");
|
||||
fs::create_dir_all(&claude_dir)
|
||||
.map_err(|e| format!("Failed to create .claude/ directory: {e}"))?;
|
||||
|
||||
let settings_path = claude_dir.join("settings.json");
|
||||
let mut settings: Value = if settings_path.exists() {
|
||||
let content = fs::read_to_string(&settings_path)
|
||||
.map_err(|e| format!("Failed to read settings.json: {e}"))?;
|
||||
serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings.json: {e}"))?
|
||||
} else {
|
||||
json!({ "permissions": { "allow": [] } })
|
||||
};
|
||||
|
||||
let allow_arr = settings
|
||||
.pointer_mut("/permissions/allow")
|
||||
.and_then(|v| v.as_array_mut());
|
||||
|
||||
let allow = match allow_arr {
|
||||
Some(arr) => arr,
|
||||
None => {
|
||||
// Ensure the structure exists
|
||||
settings
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.entry("permissions")
|
||||
.or_insert(json!({ "allow": [] }));
|
||||
settings
|
||||
.pointer_mut("/permissions/allow")
|
||||
.unwrap()
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
}
|
||||
};
|
||||
|
||||
// Check for duplicates — exact string match
|
||||
let rule_value = Value::String(rule.to_string());
|
||||
if allow.contains(&rule_value) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Also check for wildcard coverage: if "mcp__storkit__*" exists, don't add
|
||||
// a more specific "mcp__storkit__create_story".
|
||||
let dominated = allow.iter().any(|existing| {
|
||||
if let Some(pat) = existing.as_str()
|
||||
&& let Some(prefix) = pat.strip_suffix('*')
|
||||
{
|
||||
return rule.starts_with(prefix);
|
||||
}
|
||||
false
|
||||
});
|
||||
if dominated {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
allow.push(rule_value);
|
||||
|
||||
let pretty =
|
||||
serde_json::to_string_pretty(&settings).map_err(|e| format!("Failed to serialize: {e}"))?;
|
||||
fs::write(&settings_path, pretty).map_err(|e| format!("Failed to write settings.json: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// MCP tool called by Claude Code via `--permission-prompt-tool`.
|
||||
///
|
||||
/// Forwards the permission request through the shared channel to the active
|
||||
/// WebSocket session, which presents a dialog to the user. Blocks until the
|
||||
/// user approves or denies (with a 5-minute timeout).
|
||||
pub(super) async fn tool_prompt_permission(
|
||||
args: &Value,
|
||||
ctx: &AppContext,
|
||||
) -> Result<String, String> {
|
||||
let tool_name = args
|
||||
.get("tool_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
let tool_input = args.get("input").cloned().unwrap_or(json!({}));
|
||||
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
ctx.perm_tx
|
||||
.send(crate::http::context::PermissionForward {
|
||||
request_id: request_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
tool_input: tool_input.clone(),
|
||||
response_tx,
|
||||
})
|
||||
.map_err(|_| "No active WebSocket session to receive permission request".to_string())?;
|
||||
|
||||
use crate::http::context::PermissionDecision;
|
||||
|
||||
let decision = tokio::time::timeout(std::time::Duration::from_secs(300), response_rx)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
let msg = format!("Permission request for '{tool_name}' timed out after 5 minutes");
|
||||
slog_warn!("[permission] {msg}");
|
||||
msg
|
||||
})?
|
||||
.map_err(|_| "Permission response channel closed unexpectedly".to_string())?;
|
||||
|
||||
if decision == PermissionDecision::AlwaysAllow {
|
||||
// Persist the rule so Claude Code won't prompt again for this tool.
|
||||
if let Some(root) = ctx.state.project_root.lock().unwrap().clone() {
|
||||
let rule = generate_permission_rule(&tool_name, &tool_input);
|
||||
if let Err(e) = add_permission_rule(&root, &rule) {
|
||||
slog_warn!("[permission] Failed to write always-allow rule: {e}");
|
||||
} else {
|
||||
slog!("[permission] Added always-allow rule: {rule}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if decision == PermissionDecision::Approve || decision == PermissionDecision::AlwaysAllow {
|
||||
// Claude Code SDK expects:
|
||||
// Allow: { behavior: "allow", updatedInput: <record> }
|
||||
// Deny: { behavior: "deny", message: string }
|
||||
Ok(json!({"behavior": "allow", "updatedInput": tool_input}).to_string())
|
||||
} else {
|
||||
slog_warn!("[permission] User denied permission for '{tool_name}'");
|
||||
Ok(json!({
|
||||
"behavior": "deny",
|
||||
"message": format!("User denied permission for '{tool_name}'")
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn tool_get_token_usage(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let root = ctx.state.get_project_root()?;
|
||||
let filter_story = args.get("story_id").and_then(|v| v.as_str());
|
||||
|
||||
let all_records = crate::agents::token_usage::read_all(&root)?;
|
||||
let records: Vec<_> = all_records
|
||||
.into_iter()
|
||||
.filter(|r| filter_story.is_none_or(|s| r.story_id == s))
|
||||
.collect();
|
||||
|
||||
let total_cost: f64 = records.iter().map(|r| r.usage.total_cost_usd).sum();
|
||||
let total_input: u64 = records.iter().map(|r| r.usage.input_tokens).sum();
|
||||
let total_output: u64 = records.iter().map(|r| r.usage.output_tokens).sum();
|
||||
let total_cache_create: u64 = records
|
||||
.iter()
|
||||
.map(|r| r.usage.cache_creation_input_tokens)
|
||||
.sum();
|
||||
let total_cache_read: u64 = records
|
||||
.iter()
|
||||
.map(|r| r.usage.cache_read_input_tokens)
|
||||
.sum();
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"records": records.iter().map(|r| json!({
|
||||
"story_id": r.story_id,
|
||||
"agent_name": r.agent_name,
|
||||
"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::<Vec<_>>(),
|
||||
"totals": {
|
||||
"records": records.len(),
|
||||
"input_tokens": total_input,
|
||||
"output_tokens": total_output,
|
||||
"cache_creation_input_tokens": total_cache_create,
|
||||
"cache_read_input_tokens": total_cache_read,
|
||||
"total_cost_usd": total_cost,
|
||||
}
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
pub(super) fn tool_move_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
let target_stage = args
|
||||
.get("target_stage")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: target_stage")?;
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
|
||||
let (from_stage, to_stage) = move_story_to_stage(&project_root, story_id, target_stage)?;
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"story_id": story_id,
|
||||
"from_stage": from_stage,
|
||||
"to_stage": to_stage,
|
||||
"message": format!("Work item '{story_id}' moved from '{from_stage}' to '{to_stage}'.")
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::AppContext;
|
||||
|
||||
fn test_ctx(dir: &std::path::Path) -> AppContext {
|
||||
AppContext::new_test(dir.to_path_buf())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_server_logs_no_args_returns_string() {
|
||||
let result = tool_get_server_logs(&json!({})).unwrap();
|
||||
// Returns recent log lines (possibly empty in tests) — just verify no panic
|
||||
let _ = result;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_server_logs_with_filter_returns_matching_lines() {
|
||||
let result = tool_get_server_logs(&json!({"filter": "xyz_unlikely_match_999"})).unwrap();
|
||||
assert_eq!(
|
||||
result, "",
|
||||
"filter with no matches should return empty string"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_server_logs_with_line_limit() {
|
||||
let result = tool_get_server_logs(&json!({"lines": 5})).unwrap();
|
||||
assert!(result.lines().count() <= 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_server_logs_max_cap_is_1000() {
|
||||
// Lines > 1000 are capped — just verify it returns without error
|
||||
let result = tool_get_server_logs(&json!({"lines": 9999})).unwrap();
|
||||
let _ = result;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_token_usage_empty_returns_zero_totals() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_get_token_usage(&json!({}), &ctx).unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["records"].as_array().unwrap().len(), 0);
|
||||
assert_eq!(parsed["totals"]["records"], 0);
|
||||
assert_eq!(parsed["totals"]["total_cost_usd"], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_token_usage_returns_written_records() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let ctx = test_ctx(root);
|
||||
|
||||
let usage = crate::agents::TokenUsage {
|
||||
input_tokens: 100,
|
||||
output_tokens: 200,
|
||||
cache_creation_input_tokens: 5000,
|
||||
cache_read_input_tokens: 10000,
|
||||
total_cost_usd: 1.57,
|
||||
};
|
||||
let record =
|
||||
crate::agents::token_usage::build_record("42_story_foo", "coder-1", None, usage);
|
||||
crate::agents::token_usage::append_record(root, &record).unwrap();
|
||||
|
||||
let result = tool_get_token_usage(&json!({}), &ctx).unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["records"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(parsed["records"][0]["story_id"], "42_story_foo");
|
||||
assert_eq!(parsed["records"][0]["agent_name"], "coder-1");
|
||||
assert_eq!(parsed["records"][0]["input_tokens"], 100);
|
||||
assert_eq!(parsed["totals"]["records"], 1);
|
||||
assert!((parsed["totals"]["total_cost_usd"].as_f64().unwrap() - 1.57).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_token_usage_filters_by_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let ctx = test_ctx(root);
|
||||
|
||||
let usage = crate::agents::TokenUsage {
|
||||
input_tokens: 50,
|
||||
output_tokens: 60,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
total_cost_usd: 0.5,
|
||||
};
|
||||
let r1 =
|
||||
crate::agents::token_usage::build_record("10_story_a", "coder-1", None, usage.clone());
|
||||
let r2 = crate::agents::token_usage::build_record("20_story_b", "coder-2", None, usage);
|
||||
crate::agents::token_usage::append_record(root, &r1).unwrap();
|
||||
crate::agents::token_usage::append_record(root, &r2).unwrap();
|
||||
|
||||
let result = tool_get_token_usage(&json!({"story_id": "10_story_a"}), &ctx).unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["records"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(parsed["records"][0]["story_id"], "10_story_a");
|
||||
assert_eq!(parsed["totals"]["records"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_prompt_permission_approved_returns_updated_input() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
// Spawn a task that immediately sends approval through the channel.
|
||||
let perm_rx = ctx.perm_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut rx = perm_rx.lock().await;
|
||||
if let Some(forward) = rx.recv().await {
|
||||
let _ = forward
|
||||
.response_tx
|
||||
.send(crate::http::context::PermissionDecision::Approve);
|
||||
}
|
||||
});
|
||||
|
||||
let result = tool_prompt_permission(
|
||||
&json!({"tool_name": "Bash", "input": {"command": "echo hello"}}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("should succeed on approval");
|
||||
|
||||
let parsed: Value = serde_json::from_str(&result).expect("result should be valid JSON");
|
||||
assert_eq!(
|
||||
parsed["behavior"], "allow",
|
||||
"approved must return behavior:allow"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["updatedInput"]["command"], "echo hello",
|
||||
"approved must return updatedInput with original tool input for Claude Code SDK compatibility"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_prompt_permission_denied_returns_deny_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
// Spawn a task that immediately sends denial through the channel.
|
||||
let perm_rx = ctx.perm_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut rx = perm_rx.lock().await;
|
||||
if let Some(forward) = rx.recv().await {
|
||||
let _ = forward
|
||||
.response_tx
|
||||
.send(crate::http::context::PermissionDecision::Deny);
|
||||
}
|
||||
});
|
||||
|
||||
let result = tool_prompt_permission(&json!({"tool_name": "Write", "input": {}}), &ctx)
|
||||
.await
|
||||
.expect("denial must return Ok, not Err");
|
||||
|
||||
let parsed: Value = serde_json::from_str(&result).expect("result should be valid JSON");
|
||||
assert_eq!(
|
||||
parsed["behavior"], "deny",
|
||||
"denied must return behavior:deny"
|
||||
);
|
||||
assert!(parsed["message"].is_string(), "deny must include a message");
|
||||
}
|
||||
|
||||
// ── Permission rule generation tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn generate_rule_for_edit_tool() {
|
||||
let rule = generate_permission_rule("Edit", &json!({}));
|
||||
assert_eq!(rule, "Edit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_rule_for_write_tool() {
|
||||
let rule = generate_permission_rule("Write", &json!({}));
|
||||
assert_eq!(rule, "Write");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_rule_for_bash_git() {
|
||||
let rule = generate_permission_rule("Bash", &json!({"command": "git status"}));
|
||||
assert_eq!(rule, "Bash(git *)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_rule_for_bash_cargo() {
|
||||
let rule = generate_permission_rule("Bash", &json!({"command": "cargo test --all"}));
|
||||
assert_eq!(rule, "Bash(cargo *)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_rule_for_bash_empty_command() {
|
||||
let rule = generate_permission_rule("Bash", &json!({}));
|
||||
assert_eq!(rule, "Bash(unknown *)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_rule_for_mcp_tool() {
|
||||
let rule = generate_permission_rule("mcp__storkit__create_story", &json!({"name": "foo"}));
|
||||
assert_eq!(rule, "mcp__storkit__create_story");
|
||||
}
|
||||
|
||||
// ── Settings.json writing tests ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn add_rule_creates_settings_file_when_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
add_permission_rule(tmp.path(), "Edit").unwrap();
|
||||
|
||||
let content = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
||||
assert!(allow.contains(&json!("Edit")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rule_does_not_duplicate_existing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
add_permission_rule(tmp.path(), "Edit").unwrap();
|
||||
add_permission_rule(tmp.path(), "Edit").unwrap();
|
||||
|
||||
let content = fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
||||
let count = allow.iter().filter(|v| v == &&json!("Edit")).count();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rule_skips_when_wildcard_already_covers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let claude_dir = tmp.path().join(".claude");
|
||||
fs::create_dir_all(&claude_dir).unwrap();
|
||||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
r#"{"permissions":{"allow":["mcp__storkit__*"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
add_permission_rule(tmp.path(), "mcp__storkit__create_story").unwrap();
|
||||
|
||||
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
||||
assert_eq!(allow.len(), 1);
|
||||
assert_eq!(allow[0], "mcp__storkit__*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rule_appends_to_existing_rules() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let claude_dir = tmp.path().join(".claude");
|
||||
fs::create_dir_all(&claude_dir).unwrap();
|
||||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
r#"{"permissions":{"allow":["Edit"]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
add_permission_rule(tmp.path(), "Write").unwrap();
|
||||
|
||||
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let allow = settings["permissions"]["allow"].as_array().unwrap();
|
||||
assert_eq!(allow.len(), 2);
|
||||
assert!(allow.contains(&json!("Edit")));
|
||||
assert!(allow.contains(&json!("Write")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_rule_preserves_other_settings_fields() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let claude_dir = tmp.path().join(".claude");
|
||||
fs::create_dir_all(&claude_dir).unwrap();
|
||||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
r#"{"permissions":{"allow":["Edit"]},"enabledMcpjsonServers":["storkit"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
add_permission_rule(tmp.path(), "Write").unwrap();
|
||||
|
||||
let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap();
|
||||
let settings: Value = serde_json::from_str(&content).unwrap();
|
||||
let servers = settings["enabledMcpjsonServers"].as_array().unwrap();
|
||||
assert_eq!(servers.len(), 1);
|
||||
assert_eq!(servers[0], "storkit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_and_restart_in_tools_list() {
|
||||
use super::super::handle_tools_list;
|
||||
let resp = handle_tools_list(Some(json!(1)));
|
||||
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
||||
let tool = tools.iter().find(|t| t["name"] == "rebuild_and_restart");
|
||||
assert!(
|
||||
tool.is_some(),
|
||||
"rebuild_and_restart missing from tools list"
|
||||
);
|
||||
let t = tool.unwrap();
|
||||
assert!(t["description"].as_str().unwrap().contains("Rebuild"));
|
||||
assert!(t["inputSchema"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebuild_and_restart_kills_agents_before_build() {
|
||||
// Verify that calling rebuild_and_restart on an empty pool doesn't
|
||||
// panic and proceeds to the build step. We can't test exec() in a
|
||||
// unit test, but we can verify the build attempt happens.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
// The build will succeed (we're running in the real workspace) and
|
||||
// then exec() will be called — which would replace our test process.
|
||||
// So we only test that the function *runs* without panicking up to
|
||||
// the agent-kill step. We do this by checking the pool is empty.
|
||||
assert_eq!(ctx.agents.list_agents().unwrap().len(), 0);
|
||||
ctx.agents.kill_all_children(); // should not panic on empty pool
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_uses_matching_build_profile() {
|
||||
// The build must use the same profile (debug/release) as the running
|
||||
// binary, otherwise cargo build outputs to a different target dir and
|
||||
// current_exe() still points at the old binary.
|
||||
let build_args: Vec<&str> = if cfg!(debug_assertions) {
|
||||
vec!["build", "-p", "storkit"]
|
||||
} else {
|
||||
vec!["build", "--release", "-p", "storkit"]
|
||||
};
|
||||
|
||||
// Tests always run in debug mode, so --release must NOT be present.
|
||||
assert!(
|
||||
!build_args.contains(&"--release"),
|
||||
"In debug builds, rebuild must not pass --release (would put \
|
||||
the binary in target/release/ while current_exe() points to \
|
||||
target/debug/)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── move_story tool tests ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn move_story_in_tools_list() {
|
||||
use super::super::handle_tools_list;
|
||||
let resp = handle_tools_list(Some(json!(1)));
|
||||
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
||||
let tool = tools.iter().find(|t| t["name"] == "move_story");
|
||||
assert!(tool.is_some(), "move_story missing from tools list");
|
||||
let t = tool.unwrap();
|
||||
assert!(t["description"].is_string());
|
||||
let required = t["inputSchema"]["required"].as_array().unwrap();
|
||||
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
|
||||
assert!(req_names.contains(&"story_id"));
|
||||
assert!(req_names.contains(&"target_stage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_move_story_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_move_story(&json!({"target_stage": "current"}), &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_move_story_missing_target_stage() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_move_story(&json!({"story_id": "1_story_test"}), &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("target_stage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_move_story_invalid_target_stage() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
// Seed project root in state so get_project_root works
|
||||
let backlog = root.join(".storkit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(backlog.join("1_story_test.md"), "---\nname: Test\n---\n").unwrap();
|
||||
let ctx = test_ctx(root);
|
||||
let result = tool_move_story(
|
||||
&json!({"story_id": "1_story_test", "target_stage": "invalid"}),
|
||||
&ctx,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid target_stage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_move_story_moves_from_backlog_to_current() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let backlog = root.join(".storkit/work/1_backlog");
|
||||
let current = root.join(".storkit/work/2_current");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(backlog.join("5_story_test.md"), "---\nname: Test\n---\n").unwrap();
|
||||
|
||||
let ctx = test_ctx(root);
|
||||
let result = tool_move_story(
|
||||
&json!({"story_id": "5_story_test", "target_stage": "current"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!backlog.join("5_story_test.md").exists());
|
||||
assert!(current.join("5_story_test.md").exists());
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["story_id"], "5_story_test");
|
||||
assert_eq!(parsed["from_stage"], "backlog");
|
||||
assert_eq!(parsed["to_stage"], "current");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_move_story_moves_from_current_to_backlog() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let current = root.join(".storkit/work/2_current");
|
||||
let backlog = root.join(".storkit/work/1_backlog");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(current.join("6_story_back.md"), "---\nname: Back\n---\n").unwrap();
|
||||
|
||||
let ctx = test_ctx(root);
|
||||
let result = tool_move_story(
|
||||
&json!({"story_id": "6_story_back", "target_stage": "backlog"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(!current.join("6_story_back.md").exists());
|
||||
assert!(backlog.join("6_story_back.md").exists());
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["from_stage"], "current");
|
||||
assert_eq!(parsed["to_stage"], "backlog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_move_story_idempotent_when_already_in_target() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let current = root.join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("7_story_idem.md"), "---\nname: Idem\n---\n").unwrap();
|
||||
|
||||
let ctx = test_ctx(root);
|
||||
let result = tool_move_story(
|
||||
&json!({"story_id": "7_story_idem", "target_stage": "current"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(current.join("7_story_idem.md").exists());
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["from_stage"], "current");
|
||||
assert_eq!(parsed["to_stage"], "current");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_move_story_error_when_not_found() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_move_story(
|
||||
&json!({"story_id": "99_story_ghost", "target_stage": "current"}),
|
||||
&ctx,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.contains("not found in any pipeline stage")
|
||||
);
|
||||
}
|
||||
}
|
||||
766
server/src/http/mcp/git_tools.rs
Normal file
766
server/src/http/mcp/git_tools.rs
Normal file
@@ -0,0 +1,766 @@
|
||||
use crate::http::context::AppContext;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Validates that `worktree_path` exists and is inside the project's
|
||||
/// `.storkit/worktrees/` directory. Returns the canonicalized path.
|
||||
fn validate_worktree_path(worktree_path: &str, ctx: &AppContext) -> Result<PathBuf, String> {
|
||||
let wd = PathBuf::from(worktree_path);
|
||||
|
||||
if !wd.is_absolute() {
|
||||
return Err("worktree_path must be an absolute path".to_string());
|
||||
}
|
||||
if !wd.exists() {
|
||||
return Err(format!(
|
||||
"worktree_path does not exist: {worktree_path}"
|
||||
));
|
||||
}
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
let worktrees_root = project_root.join(".storkit").join("worktrees");
|
||||
|
||||
let canonical_wd = wd
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot canonicalize worktree_path: {e}"))?;
|
||||
|
||||
let canonical_wt = if worktrees_root.exists() {
|
||||
worktrees_root
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot canonicalize worktrees root: {e}"))?
|
||||
} else {
|
||||
return Err("No worktrees directory found in project".to_string());
|
||||
};
|
||||
|
||||
if !canonical_wd.starts_with(&canonical_wt) {
|
||||
return Err(format!(
|
||||
"worktree_path must be inside .storkit/worktrees/. Got: {worktree_path}"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(canonical_wd)
|
||||
}
|
||||
|
||||
/// Run a git command in the given directory and return its output.
|
||||
async fn run_git(args: Vec<&'static str>, dir: PathBuf) -> Result<std::process::Output, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
std::process::Command::new("git")
|
||||
.args(&args)
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {e}"))?
|
||||
.map_err(|e| format!("Failed to run git: {e}"))
|
||||
}
|
||||
|
||||
/// Run a git command with owned args in the given directory.
|
||||
async fn run_git_owned(args: Vec<String>, dir: PathBuf) -> Result<std::process::Output, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
std::process::Command::new("git")
|
||||
.args(&args)
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {e}"))?
|
||||
.map_err(|e| format!("Failed to run git: {e}"))
|
||||
}
|
||||
|
||||
/// git_status — returns working tree status (staged, unstaged, untracked files).
|
||||
pub(super) async fn tool_git_status(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let worktree_path = args
|
||||
.get("worktree_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: worktree_path")?;
|
||||
|
||||
let dir = validate_worktree_path(worktree_path, ctx)?;
|
||||
|
||||
let output = run_git(vec!["status", "--porcelain=v1", "-u"], dir).await?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"git status failed (exit {}): {stderr}",
|
||||
output.status.code().unwrap_or(-1)
|
||||
));
|
||||
}
|
||||
|
||||
let mut staged: Vec<String> = Vec::new();
|
||||
let mut unstaged: Vec<String> = Vec::new();
|
||||
let mut untracked: Vec<String> = Vec::new();
|
||||
|
||||
for line in stdout.lines() {
|
||||
if line.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let x = line.chars().next().unwrap_or(' ');
|
||||
let y = line.chars().nth(1).unwrap_or(' ');
|
||||
let path = line[3..].to_string();
|
||||
|
||||
match (x, y) {
|
||||
('?', '?') => untracked.push(path),
|
||||
(' ', _) => unstaged.push(path),
|
||||
(_, ' ') => staged.push(path),
|
||||
_ => {
|
||||
// Both staged and unstaged modifications
|
||||
staged.push(path.clone());
|
||||
unstaged.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"staged": staged,
|
||||
"unstaged": unstaged,
|
||||
"untracked": untracked,
|
||||
"clean": staged.is_empty() && unstaged.is_empty() && untracked.is_empty(),
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
/// git_diff — returns diff output. Supports staged/unstaged/commit range.
|
||||
pub(super) async fn tool_git_diff(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let worktree_path = args
|
||||
.get("worktree_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: worktree_path")?;
|
||||
|
||||
let dir = validate_worktree_path(worktree_path, ctx)?;
|
||||
|
||||
let staged = args
|
||||
.get("staged")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let commit_range = args
|
||||
.get("commit_range")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let mut git_args: Vec<String> = vec!["diff".to_string()];
|
||||
|
||||
if staged {
|
||||
git_args.push("--staged".to_string());
|
||||
}
|
||||
|
||||
if let Some(range) = commit_range {
|
||||
git_args.push(range);
|
||||
}
|
||||
|
||||
let output = run_git_owned(git_args, dir).await?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"git diff failed (exit {}): {stderr}",
|
||||
output.status.code().unwrap_or(-1)
|
||||
));
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"diff": stdout.as_ref(),
|
||||
"exit_code": output.status.code().unwrap_or(-1),
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
/// git_add — stages files by path.
|
||||
pub(super) async fn tool_git_add(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let worktree_path = args
|
||||
.get("worktree_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: worktree_path")?;
|
||||
|
||||
let paths: Vec<String> = args
|
||||
.get("paths")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or("Missing required argument: paths (must be an array of strings)")?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
if paths.is_empty() {
|
||||
return Err("paths must be a non-empty array of strings".to_string());
|
||||
}
|
||||
|
||||
let dir = validate_worktree_path(worktree_path, ctx)?;
|
||||
|
||||
let mut git_args: Vec<String> = vec!["add".to_string(), "--".to_string()];
|
||||
git_args.extend(paths.clone());
|
||||
|
||||
let output = run_git_owned(git_args, dir).await?;
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"git add failed (exit {}): {stderr}",
|
||||
output.status.code().unwrap_or(-1)
|
||||
));
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"staged": paths,
|
||||
"exit_code": output.status.code().unwrap_or(0),
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
/// git_commit — commits staged changes with a message.
|
||||
pub(super) async fn tool_git_commit(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let worktree_path = args
|
||||
.get("worktree_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: worktree_path")?;
|
||||
|
||||
let message = args
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: message")?
|
||||
.to_string();
|
||||
|
||||
if message.trim().is_empty() {
|
||||
return Err("message must not be empty".to_string());
|
||||
}
|
||||
|
||||
let dir = validate_worktree_path(worktree_path, ctx)?;
|
||||
|
||||
let git_args: Vec<String> = vec![
|
||||
"commit".to_string(),
|
||||
"--message".to_string(),
|
||||
message,
|
||||
];
|
||||
|
||||
let output = run_git_owned(git_args, dir).await?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"git commit failed (exit {}): {stderr}",
|
||||
output.status.code().unwrap_or(-1)
|
||||
));
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"output": stdout.as_ref(),
|
||||
"exit_code": output.status.code().unwrap_or(0),
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
/// git_log — returns commit history with configurable count and format.
|
||||
pub(super) async fn tool_git_log(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let worktree_path = args
|
||||
.get("worktree_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: worktree_path")?;
|
||||
|
||||
let dir = validate_worktree_path(worktree_path, ctx)?;
|
||||
|
||||
let count = args
|
||||
.get("count")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(10)
|
||||
.min(500);
|
||||
|
||||
let format = args
|
||||
.get("format")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("%H%x09%s%x09%an%x09%ai")
|
||||
.to_string();
|
||||
|
||||
let git_args: Vec<String> = vec![
|
||||
"log".to_string(),
|
||||
format!("--max-count={count}"),
|
||||
format!("--pretty=format:{format}"),
|
||||
];
|
||||
|
||||
let output = run_git_owned(git_args, dir).await?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"git log failed (exit {}): {stderr}",
|
||||
output.status.code().unwrap_or(-1)
|
||||
));
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"log": stdout.as_ref(),
|
||||
"exit_code": output.status.code().unwrap_or(0),
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::AppContext;
|
||||
use serde_json::json;
|
||||
|
||||
fn test_ctx(dir: &std::path::Path) -> AppContext {
|
||||
AppContext::new_test(dir.to_path_buf())
|
||||
}
|
||||
|
||||
/// Create a temp directory with a git worktree structure and init a repo.
|
||||
fn setup_worktree() -> (tempfile::TempDir, PathBuf, AppContext) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let story_wt = tmp
|
||||
.path()
|
||||
.join(".storkit")
|
||||
.join("worktrees")
|
||||
.join("42_test_story");
|
||||
std::fs::create_dir_all(&story_wt).unwrap();
|
||||
|
||||
// Init git repo in the worktree
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let ctx = test_ctx(tmp.path());
|
||||
(tmp, story_wt, ctx)
|
||||
}
|
||||
|
||||
// ── validate_worktree_path ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_relative_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = validate_worktree_path("relative/path", &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("absolute"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_nonexistent_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = validate_worktree_path("/nonexistent_path_xyz_git", &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_path_outside_worktrees() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let wt_dir = tmp.path().join(".storkit").join("worktrees");
|
||||
std::fs::create_dir_all(&wt_dir).unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = validate_worktree_path(tmp.path().to_str().unwrap(), &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("inside .storkit/worktrees"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_path_inside_worktrees() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let story_wt = tmp
|
||||
.path()
|
||||
.join(".storkit")
|
||||
.join("worktrees")
|
||||
.join("42_test_story");
|
||||
std::fs::create_dir_all(&story_wt).unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = validate_worktree_path(story_wt.to_str().unwrap(), &ctx);
|
||||
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
|
||||
}
|
||||
|
||||
// ── git_status ────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_status_missing_worktree_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_git_status(&json!({}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("worktree_path"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_status_clean_repo() {
|
||||
let (_tmp, story_wt, ctx) = setup_worktree();
|
||||
|
||||
// Make an initial commit so HEAD exists
|
||||
std::fs::write(story_wt.join("readme.txt"), "hello").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let result = tool_git_status(
|
||||
&json!({"worktree_path": story_wt.to_str().unwrap()}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["clean"], true);
|
||||
assert!(parsed["staged"].as_array().unwrap().is_empty());
|
||||
assert!(parsed["unstaged"].as_array().unwrap().is_empty());
|
||||
assert!(parsed["untracked"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_status_shows_untracked_file() {
|
||||
let (_tmp, story_wt, ctx) = setup_worktree();
|
||||
|
||||
// Make initial commit
|
||||
std::fs::write(story_wt.join("readme.txt"), "hello").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Add untracked file
|
||||
std::fs::write(story_wt.join("new_file.txt"), "content").unwrap();
|
||||
|
||||
let result = tool_git_status(
|
||||
&json!({"worktree_path": story_wt.to_str().unwrap()}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["clean"], false);
|
||||
let untracked = parsed["untracked"].as_array().unwrap();
|
||||
assert!(
|
||||
untracked.iter().any(|v| v.as_str().unwrap().contains("new_file.txt")),
|
||||
"expected new_file.txt in untracked: {parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── git_diff ──────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_diff_missing_worktree_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_git_diff(&json!({}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("worktree_path"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_diff_returns_diff() {
|
||||
let (_tmp, story_wt, ctx) = setup_worktree();
|
||||
|
||||
// Create initial commit
|
||||
std::fs::write(story_wt.join("file.txt"), "line1\n").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Modify file (unstaged)
|
||||
std::fs::write(story_wt.join("file.txt"), "line1\nline2\n").unwrap();
|
||||
|
||||
let result = tool_git_diff(
|
||||
&json!({"worktree_path": story_wt.to_str().unwrap()}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert!(
|
||||
parsed["diff"].as_str().unwrap().contains("line2"),
|
||||
"expected diff output: {parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_diff_staged_flag() {
|
||||
let (_tmp, story_wt, ctx) = setup_worktree();
|
||||
|
||||
// Create initial commit
|
||||
std::fs::write(story_wt.join("file.txt"), "line1\n").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Stage a modification
|
||||
std::fs::write(story_wt.join("file.txt"), "line1\nstaged_change\n").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "file.txt"])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let result = tool_git_diff(
|
||||
&json!({"worktree_path": story_wt.to_str().unwrap(), "staged": true}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert!(
|
||||
parsed["diff"].as_str().unwrap().contains("staged_change"),
|
||||
"expected staged diff: {parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── git_add ───────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_add_missing_worktree_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_git_add(&json!({"paths": ["file.txt"]}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("worktree_path"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_add_missing_paths() {
|
||||
let (_tmp, story_wt, ctx) = setup_worktree();
|
||||
let result = tool_git_add(
|
||||
&json!({"worktree_path": story_wt.to_str().unwrap()}),
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("paths"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_add_empty_paths() {
|
||||
let (_tmp, story_wt, ctx) = setup_worktree();
|
||||
let result = tool_git_add(
|
||||
&json!({"worktree_path": story_wt.to_str().unwrap(), "paths": []}),
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("non-empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_add_stages_file() {
|
||||
let (_tmp, story_wt, ctx) = setup_worktree();
|
||||
|
||||
std::fs::write(story_wt.join("file.txt"), "content").unwrap();
|
||||
|
||||
let result = tool_git_add(
|
||||
&json!({
|
||||
"worktree_path": story_wt.to_str().unwrap(),
|
||||
"paths": ["file.txt"]
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["exit_code"], 0);
|
||||
let staged = parsed["staged"].as_array().unwrap();
|
||||
assert!(staged.iter().any(|v| v.as_str().unwrap() == "file.txt"));
|
||||
|
||||
// Verify file is actually staged
|
||||
let status = std::process::Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
let output = String::from_utf8_lossy(&status.stdout);
|
||||
assert!(output.contains("A file.txt"), "file should be staged: {output}");
|
||||
}
|
||||
|
||||
// ── git_commit ────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_commit_missing_worktree_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_git_commit(&json!({"message": "test"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("worktree_path"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_commit_missing_message() {
|
||||
let (_tmp, story_wt, ctx) = setup_worktree();
|
||||
let result = tool_git_commit(
|
||||
&json!({"worktree_path": story_wt.to_str().unwrap()}),
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("message"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_commit_empty_message() {
|
||||
let (_tmp, story_wt, ctx) = setup_worktree();
|
||||
let result = tool_git_commit(
|
||||
&json!({"worktree_path": story_wt.to_str().unwrap(), "message": " "}),
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("empty"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_commit_creates_commit() {
|
||||
let (_tmp, story_wt, ctx) = setup_worktree();
|
||||
|
||||
// Stage a file
|
||||
std::fs::write(story_wt.join("file.txt"), "content").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "file.txt"])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let result = tool_git_commit(
|
||||
&json!({
|
||||
"worktree_path": story_wt.to_str().unwrap(),
|
||||
"message": "test commit message"
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["exit_code"], 0);
|
||||
|
||||
// Verify commit exists
|
||||
let log = std::process::Command::new("git")
|
||||
.args(["log", "--oneline"])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_output = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(
|
||||
log_output.contains("test commit message"),
|
||||
"expected commit in log: {log_output}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── git_log ───────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_log_missing_worktree_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_git_log(&json!({}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("worktree_path"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_log_returns_history() {
|
||||
let (_tmp, story_wt, ctx) = setup_worktree();
|
||||
|
||||
// Make a commit
|
||||
std::fs::write(story_wt.join("file.txt"), "content").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "first commit"])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let result = tool_git_log(
|
||||
&json!({"worktree_path": story_wt.to_str().unwrap()}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["exit_code"], 0);
|
||||
assert!(
|
||||
parsed["log"].as_str().unwrap().contains("first commit"),
|
||||
"expected commit in log: {parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_log_respects_count() {
|
||||
let (_tmp, story_wt, ctx) = setup_worktree();
|
||||
|
||||
// Make multiple commits
|
||||
for i in 0..5 {
|
||||
std::fs::write(story_wt.join("file.txt"), format!("content {i}")).unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", &format!("commit {i}")])
|
||||
.current_dir(&story_wt)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let result = tool_git_log(
|
||||
&json!({"worktree_path": story_wt.to_str().unwrap(), "count": 2}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
// With count=2, only 2 commit entries should appear
|
||||
let log = parsed["log"].as_str().unwrap();
|
||||
// Each log line is tab-separated; count newlines
|
||||
let lines: Vec<&str> = log.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "expected 2 log entries, got: {log}");
|
||||
}
|
||||
}
|
||||
380
server/src/http/mcp/merge_tools.rs
Normal file
380
server/src/http/mcp/merge_tools.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
use crate::agents::move_story_to_merge;
|
||||
use crate::http::context::AppContext;
|
||||
use crate::io::story_metadata::write_merge_failure;
|
||||
use crate::slog;
|
||||
use crate::slog_warn;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub(super) fn tool_merge_agent_work(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
ctx.agents.start_merge_agent_work(&project_root, story_id)?;
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"story_id": story_id,
|
||||
"status": "started",
|
||||
"message": "Merge pipeline started. Poll get_merge_status(story_id) every 10-15 seconds until status is 'completed' or 'failed'."
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
pub(super) fn tool_get_merge_status(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
|
||||
let job = ctx.agents.get_merge_status(story_id)
|
||||
.ok_or_else(|| format!("No merge job found for story '{story_id}'. Call merge_agent_work first."))?;
|
||||
|
||||
match &job.status {
|
||||
crate::agents::merge::MergeJobStatus::Running => {
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"story_id": story_id,
|
||||
"status": "running",
|
||||
"message": "Merge pipeline is still running. Poll again in 10-15 seconds."
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
crate::agents::merge::MergeJobStatus::Completed(report) => {
|
||||
let status_msg = if report.success && report.gates_passed && report.conflicts_resolved {
|
||||
"Merge complete: conflicts were auto-resolved and all quality gates passed. Story moved to done and worktree cleaned up."
|
||||
} else if report.success && report.gates_passed {
|
||||
"Merge complete: all quality gates passed. Story moved to done and worktree cleaned up."
|
||||
} else if report.had_conflicts && !report.conflicts_resolved {
|
||||
"Merge failed: conflicts detected that could not be auto-resolved. Merge was aborted — master is untouched. Call report_merge_failure with the conflict details so the human can resolve them. Do NOT manually move the story file or call accept_story."
|
||||
} else if report.success && !report.gates_passed {
|
||||
"Merge committed but quality gates failed. Review gate_output and fix issues before re-running."
|
||||
} else {
|
||||
"Merge failed. Review gate_output for details. Call report_merge_failure to record the failure. Do NOT manually move the story file or call accept_story."
|
||||
};
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"story_id": story_id,
|
||||
"status": "completed",
|
||||
"success": report.success,
|
||||
"had_conflicts": report.had_conflicts,
|
||||
"conflicts_resolved": report.conflicts_resolved,
|
||||
"conflict_details": report.conflict_details,
|
||||
"gates_passed": report.gates_passed,
|
||||
"gate_output": report.gate_output,
|
||||
"worktree_cleaned_up": report.worktree_cleaned_up,
|
||||
"story_archived": report.story_archived,
|
||||
"message": status_msg,
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
crate::agents::merge::MergeJobStatus::Failed(err) => {
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"story_id": story_id,
|
||||
"status": "failed",
|
||||
"error": err,
|
||||
"message": format!("Merge pipeline failed: {err}. Call report_merge_failure to record the failure.")
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn tool_move_story_to_merge(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
let agent_name = args
|
||||
.get("agent_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("mergemaster");
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
|
||||
// Move story from work/2_current/ to work/4_merge/
|
||||
move_story_to_merge(&project_root, story_id)?;
|
||||
|
||||
// Start the mergemaster agent on the story worktree
|
||||
let info = ctx
|
||||
.agents
|
||||
.start_agent(&project_root, story_id, Some(agent_name), None)
|
||||
.await?;
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"story_id": info.story_id,
|
||||
"agent_name": info.agent_name,
|
||||
"status": info.status.to_string(),
|
||||
"worktree_path": info.worktree_path,
|
||||
"message": format!(
|
||||
"Story '{story_id}' moved to work/4_merge/ and mergemaster agent '{}' started.",
|
||||
info.agent_name
|
||||
),
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
pub(super) fn tool_report_merge_failure(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
let reason = args
|
||||
.get("reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: reason")?;
|
||||
|
||||
slog!("[mergemaster] Merge failure reported for '{story_id}': {reason}");
|
||||
ctx.agents.set_merge_failure_reported(story_id);
|
||||
|
||||
// Broadcast the failure so the Matrix notification listener can post an
|
||||
// error message to configured rooms without coupling this tool to the bot.
|
||||
let _ = ctx.watcher_tx.send(crate::io::watcher::WatcherEvent::MergeFailure {
|
||||
story_id: story_id.to_string(),
|
||||
reason: reason.to_string(),
|
||||
});
|
||||
|
||||
// Persist the failure reason to the story file's front matter so it
|
||||
// survives server restarts and is visible in the web UI.
|
||||
if let Ok(project_root) = ctx.state.get_project_root() {
|
||||
let story_file = project_root
|
||||
.join(".storkit")
|
||||
.join("work")
|
||||
.join("4_merge")
|
||||
.join(format!("{story_id}.md"));
|
||||
if story_file.exists() {
|
||||
if let Err(e) = write_merge_failure(&story_file, reason) {
|
||||
slog_warn!(
|
||||
"[mergemaster] Failed to persist merge_failure to story file for '{story_id}': {e}"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
slog_warn!(
|
||||
"[mergemaster] Story file not found in 4_merge/ for '{story_id}'; \
|
||||
merge_failure not persisted to front matter"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Merge failure for '{story_id}' recorded. Story remains in work/4_merge/. Reason: {reason}"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::AppContext;
|
||||
|
||||
fn test_ctx(dir: &std::path::Path) -> AppContext {
|
||||
AppContext::new_test(dir.to_path_buf())
|
||||
}
|
||||
|
||||
fn setup_git_repo_in(dir: &std::path::Path) {
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_agent_work_in_tools_list() {
|
||||
use super::super::{handle_tools_list};
|
||||
let resp = handle_tools_list(Some(json!(1)));
|
||||
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
||||
let tool = tools.iter().find(|t| t["name"] == "merge_agent_work");
|
||||
assert!(tool.is_some(), "merge_agent_work missing from tools list");
|
||||
let t = tool.unwrap();
|
||||
assert!(t["description"].is_string());
|
||||
let required = t["inputSchema"]["required"].as_array().unwrap();
|
||||
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
|
||||
assert!(req_names.contains(&"story_id"));
|
||||
// agent_name is optional
|
||||
assert!(!req_names.contains(&"agent_name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_story_to_merge_in_tools_list() {
|
||||
use super::super::{handle_tools_list};
|
||||
let resp = handle_tools_list(Some(json!(1)));
|
||||
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
||||
let tool = tools.iter().find(|t| t["name"] == "move_story_to_merge");
|
||||
assert!(tool.is_some(), "move_story_to_merge missing from tools list");
|
||||
let t = tool.unwrap();
|
||||
assert!(t["description"].is_string());
|
||||
let required = t["inputSchema"]["required"].as_array().unwrap();
|
||||
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
|
||||
assert!(req_names.contains(&"story_id"));
|
||||
// agent_name is optional
|
||||
assert!(!req_names.contains(&"agent_name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_merge_agent_work_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_merge_agent_work(&json!({}), &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_move_story_to_merge_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_move_story_to_merge(&json!({}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_move_story_to_merge_moves_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo_in(tmp.path());
|
||||
let current_dir = tmp.path().join(".storkit/work/2_current");
|
||||
std::fs::create_dir_all(¤t_dir).unwrap();
|
||||
let story_file = current_dir.join("24_story_test.md");
|
||||
std::fs::write(&story_file, "---\nname: Test\n---\n").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "add story"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let ctx = test_ctx(tmp.path());
|
||||
// The agent start will fail in test (no worktree/config), but the file move should succeed
|
||||
let result = tool_move_story_to_merge(&json!({"story_id": "24_story_test"}), &ctx).await;
|
||||
// File should have been moved regardless of agent start outcome
|
||||
assert!(!story_file.exists(), "2_current file should be gone");
|
||||
assert!(
|
||||
tmp.path().join(".storkit/work/4_merge/24_story_test.md").exists(),
|
||||
"4_merge file should exist"
|
||||
);
|
||||
// Result is either Ok (agent started) or Err (agent failed - acceptable in tests)
|
||||
let _ = result;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_merge_agent_work_returns_started() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo_in(tmp.path());
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
let result = tool_merge_agent_work(
|
||||
&json!({"story_id": "99_nonexistent", "agent_name": "coder-1"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["story_id"], "99_nonexistent");
|
||||
assert_eq!(parsed["status"], "started");
|
||||
assert!(parsed.get("message").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_merge_status_no_job() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_get_merge_status(&json!({"story_id": "99_nonexistent"}), &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("No merge job"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_get_merge_status_returns_running() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo_in(tmp.path());
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
// Start a merge (it will run in background)
|
||||
tool_merge_agent_work(
|
||||
&json!({"story_id": "99_nonexistent"}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Immediately check — should be running (or already finished if very fast)
|
||||
let result = tool_get_merge_status(&json!({"story_id": "99_nonexistent"}), &ctx).unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
let status = parsed["status"].as_str().unwrap();
|
||||
assert!(
|
||||
status == "running" || status == "completed" || status == "failed",
|
||||
"unexpected status: {status}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_merge_failure_in_tools_list() {
|
||||
use super::super::{handle_tools_list};
|
||||
let resp = handle_tools_list(Some(json!(1)));
|
||||
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
||||
let tool = tools.iter().find(|t| t["name"] == "report_merge_failure");
|
||||
assert!(
|
||||
tool.is_some(),
|
||||
"report_merge_failure missing from tools list"
|
||||
);
|
||||
let t = tool.unwrap();
|
||||
assert!(t["description"].is_string());
|
||||
let required = t["inputSchema"]["required"].as_array().unwrap();
|
||||
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
|
||||
assert!(req_names.contains(&"story_id"));
|
||||
assert!(req_names.contains(&"reason"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_report_merge_failure_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_report_merge_failure(&json!({"reason": "conflicts"}), &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_report_merge_failure_missing_reason() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_report_merge_failure(&json!({"story_id": "42_story_foo"}), &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("reason"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_report_merge_failure_returns_confirmation() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_report_merge_failure(
|
||||
&json!({
|
||||
"story_id": "42_story_foo",
|
||||
"reason": "Unresolvable merge conflicts in src/main.rs"
|
||||
}),
|
||||
&ctx,
|
||||
);
|
||||
assert!(result.is_ok());
|
||||
let msg = result.unwrap();
|
||||
assert!(msg.contains("42_story_foo"));
|
||||
assert!(msg.contains("work/4_merge/"));
|
||||
assert!(msg.contains("Unresolvable merge conflicts"));
|
||||
}
|
||||
}
|
||||
1650
server/src/http/mcp/mod.rs
Normal file
1650
server/src/http/mcp/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
293
server/src/http/mcp/qa_tools.rs
Normal file
293
server/src/http/mcp/qa_tools.rs
Normal file
@@ -0,0 +1,293 @@
|
||||
use crate::agents::{move_story_to_merge, move_story_to_qa, reject_story_from_qa};
|
||||
use crate::http::context::AppContext;
|
||||
use crate::slog;
|
||||
use crate::slog_warn;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub(super) async fn tool_request_qa(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
let agent_name = args
|
||||
.get("agent_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("qa");
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
|
||||
// Move story from work/2_current/ to work/3_qa/
|
||||
move_story_to_qa(&project_root, story_id)?;
|
||||
|
||||
// Start the QA agent on the story worktree
|
||||
let info = ctx
|
||||
.agents
|
||||
.start_agent(&project_root, story_id, Some(agent_name), None)
|
||||
.await?;
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"story_id": info.story_id,
|
||||
"agent_name": info.agent_name,
|
||||
"status": info.status.to_string(),
|
||||
"worktree_path": info.worktree_path,
|
||||
"message": format!(
|
||||
"Story '{story_id}' moved to work/3_qa/ and QA agent '{}' started.",
|
||||
info.agent_name
|
||||
),
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
|
||||
// Clear review_hold before moving
|
||||
let qa_path = project_root
|
||||
.join(".storkit/work/3_qa")
|
||||
.join(format!("{story_id}.md"));
|
||||
if qa_path.exists() {
|
||||
let _ = crate::io::story_metadata::clear_front_matter_field(&qa_path, "review_hold");
|
||||
}
|
||||
|
||||
// Move story from work/3_qa/ to work/4_merge/
|
||||
move_story_to_merge(&project_root, story_id)?;
|
||||
|
||||
// Start the mergemaster agent
|
||||
let info = ctx
|
||||
.agents
|
||||
.start_agent(&project_root, story_id, Some("mergemaster"), None)
|
||||
.await?;
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"story_id": info.story_id,
|
||||
"agent_name": info.agent_name,
|
||||
"status": info.status.to_string(),
|
||||
"message": format!(
|
||||
"Story '{story_id}' approved. Moved to work/4_merge/ and mergemaster agent '{}' started.",
|
||||
info.agent_name
|
||||
),
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
pub(super) async fn tool_reject_qa(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
let notes = args
|
||||
.get("notes")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: notes")?;
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
|
||||
// Move story from work/3_qa/ back to work/2_current/ with rejection notes
|
||||
reject_story_from_qa(&project_root, story_id, notes)?;
|
||||
|
||||
// Restart the coder agent with rejection context
|
||||
let story_path = project_root
|
||||
.join(".storkit/work/2_current")
|
||||
.join(format!("{story_id}.md"));
|
||||
let agent_name = if story_path.exists() {
|
||||
let contents = std::fs::read_to_string(&story_path).unwrap_or_default();
|
||||
crate::io::story_metadata::parse_front_matter(&contents)
|
||||
.ok()
|
||||
.and_then(|meta| meta.agent)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let agent_name = agent_name.as_deref().unwrap_or("coder-opus");
|
||||
|
||||
let context = format!(
|
||||
"\n\n---\n## QA Rejection\n\
|
||||
Your previous implementation was rejected during human QA review.\n\
|
||||
Rejection notes:\n{notes}\n\n\
|
||||
Please fix the issues described above and try again."
|
||||
);
|
||||
if let Err(e) = ctx
|
||||
.agents
|
||||
.start_agent(&project_root, story_id, Some(agent_name), Some(&context))
|
||||
.await
|
||||
{
|
||||
slog_warn!("[qa] Failed to restart coder for '{story_id}' after rejection: {e}");
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"Story '{story_id}' rejected and moved back to work/2_current/. Coder agent '{agent_name}' restarted with rejection notes."
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn tool_launch_qa_app(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
|
||||
// Find the worktree path for this story
|
||||
let worktrees = crate::worktree::list_worktrees(&project_root)?;
|
||||
let wt = worktrees
|
||||
.iter()
|
||||
.find(|w| w.story_id == story_id)
|
||||
.ok_or_else(|| format!("No worktree found for story '{story_id}'"))?;
|
||||
let wt_path = wt.path.clone();
|
||||
|
||||
// Stop any existing QA app instance
|
||||
{
|
||||
let mut guard = ctx.qa_app_process.lock().unwrap();
|
||||
if let Some(mut child) = guard.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
slog!("[qa-app] Stopped previous QA app instance.");
|
||||
}
|
||||
}
|
||||
|
||||
// Find a free port starting from 3100
|
||||
let port = find_free_port(3100);
|
||||
|
||||
// Write .storkit_port so the frontend dev server knows where to connect
|
||||
let port_file = wt_path.join(".storkit_port");
|
||||
std::fs::write(&port_file, port.to_string())
|
||||
.map_err(|e| format!("Failed to write .storkit_port: {e}"))?;
|
||||
|
||||
// Launch the server from the worktree
|
||||
let child = std::process::Command::new("cargo")
|
||||
.args(["run"])
|
||||
.env("STORKIT_PORT", port.to_string())
|
||||
.current_dir(&wt_path)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to launch QA app: {e}"))?;
|
||||
|
||||
{
|
||||
let mut guard = ctx.qa_app_process.lock().unwrap();
|
||||
*guard = Some(child);
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"story_id": story_id,
|
||||
"port": port,
|
||||
"worktree_path": wt_path.to_string_lossy(),
|
||||
"message": format!("QA app launched on port {port} from worktree at {}", wt_path.display()),
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
/// Find a free TCP port starting from `start`.
|
||||
pub(super) fn find_free_port(start: u16) -> u16 {
|
||||
for port in start..start + 100 {
|
||||
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
start // fallback
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::AppContext;
|
||||
|
||||
fn test_ctx(dir: &std::path::Path) -> AppContext {
|
||||
AppContext::new_test(dir.to_path_buf())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_qa_in_tools_list() {
|
||||
use super::super::handle_tools_list;
|
||||
let resp = handle_tools_list(Some(json!(1)));
|
||||
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
||||
let tool = tools.iter().find(|t| t["name"] == "request_qa");
|
||||
assert!(tool.is_some(), "request_qa missing from tools list");
|
||||
let t = tool.unwrap();
|
||||
let required = t["inputSchema"]["required"].as_array().unwrap();
|
||||
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
|
||||
assert!(req_names.contains(&"story_id"));
|
||||
// agent_name is optional
|
||||
assert!(!req_names.contains(&"agent_name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approve_qa_in_tools_list() {
|
||||
use super::super::handle_tools_list;
|
||||
let resp = handle_tools_list(Some(json!(1)));
|
||||
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
||||
let tool = tools.iter().find(|t| t["name"] == "approve_qa");
|
||||
assert!(tool.is_some(), "approve_qa missing from tools list");
|
||||
let t = tool.unwrap();
|
||||
let required = t["inputSchema"]["required"].as_array().unwrap();
|
||||
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
|
||||
assert!(req_names.contains(&"story_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_qa_in_tools_list() {
|
||||
use super::super::handle_tools_list;
|
||||
let resp = handle_tools_list(Some(json!(1)));
|
||||
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
||||
let tool = tools.iter().find(|t| t["name"] == "reject_qa");
|
||||
assert!(tool.is_some(), "reject_qa missing from tools list");
|
||||
let t = tool.unwrap();
|
||||
let required = t["inputSchema"]["required"].as_array().unwrap();
|
||||
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
|
||||
assert!(req_names.contains(&"story_id"));
|
||||
assert!(req_names.contains(&"notes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_qa_app_in_tools_list() {
|
||||
use super::super::handle_tools_list;
|
||||
let resp = handle_tools_list(Some(json!(1)));
|
||||
let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone();
|
||||
let tool = tools.iter().find(|t| t["name"] == "launch_qa_app");
|
||||
assert!(tool.is_some(), "launch_qa_app missing from tools list");
|
||||
let t = tool.unwrap();
|
||||
let required = t["inputSchema"]["required"].as_array().unwrap();
|
||||
let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect();
|
||||
assert!(req_names.contains(&"story_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_approve_qa_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_approve_qa(&json!({}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_reject_qa_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_reject_qa(&json!({"notes": "broken"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_reject_qa_missing_notes() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_reject_qa(&json!({"story_id": "1_story_test"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("notes"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_request_qa_missing_story_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_request_qa(&json!({}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("story_id"));
|
||||
}
|
||||
}
|
||||
626
server/src/http/mcp/shell_tools.rs
Normal file
626
server/src/http/mcp/shell_tools.rs
Normal file
@@ -0,0 +1,626 @@
|
||||
use crate::http::context::AppContext;
|
||||
use bytes::Bytes;
|
||||
use futures::StreamExt;
|
||||
use poem::{Body, Response};
|
||||
use serde_json::{json, Value};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 120;
|
||||
const MAX_TIMEOUT_SECS: u64 = 600;
|
||||
|
||||
/// Patterns that are unconditionally blocked regardless of context.
|
||||
static BLOCKED_PATTERNS: &[&str] = &[
|
||||
"rm -rf /",
|
||||
"rm -fr /",
|
||||
"rm -rf /*",
|
||||
"rm -fr /*",
|
||||
"rm --no-preserve-root",
|
||||
":(){ :|:& };:",
|
||||
"> /dev/sda",
|
||||
"dd if=/dev",
|
||||
];
|
||||
|
||||
/// Binaries that are unconditionally blocked.
|
||||
static BLOCKED_BINARIES: &[&str] = &[
|
||||
"sudo",
|
||||
"su",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"halt",
|
||||
"poweroff",
|
||||
"mkfs",
|
||||
];
|
||||
|
||||
/// Returns an error message if the command matches a blocked pattern or binary.
|
||||
fn is_dangerous(command: &str) -> Option<String> {
|
||||
let trimmed = command.trim();
|
||||
|
||||
// Check each blocked pattern (substring match)
|
||||
for &pattern in BLOCKED_PATTERNS {
|
||||
if trimmed.contains(pattern) {
|
||||
return Some(format!(
|
||||
"Command blocked: dangerous pattern '{pattern}' detected"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Check first token of the command against blocked binaries
|
||||
if let Some(first_token) = trimmed.split_whitespace().next() {
|
||||
let binary = std::path::Path::new(first_token)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(first_token);
|
||||
if BLOCKED_BINARIES.contains(&binary) {
|
||||
return Some(format!("Command blocked: '{binary}' is not permitted"));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Validates that `working_dir` exists and is inside the project's
|
||||
/// `.storkit/worktrees/` directory. Returns the canonicalized path.
|
||||
fn validate_working_dir(working_dir: &str, ctx: &AppContext) -> Result<PathBuf, String> {
|
||||
let wd = PathBuf::from(working_dir);
|
||||
|
||||
if !wd.is_absolute() {
|
||||
return Err("working_dir must be an absolute path".to_string());
|
||||
}
|
||||
if !wd.exists() {
|
||||
return Err(format!("working_dir does not exist: {working_dir}"));
|
||||
}
|
||||
|
||||
let project_root = ctx.agents.get_project_root(&ctx.state)?;
|
||||
let worktrees_root = project_root.join(".storkit").join("worktrees");
|
||||
|
||||
let canonical_wd = wd
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot canonicalize working_dir: {e}"))?;
|
||||
|
||||
// If worktrees_root doesn't exist yet, we can't allow anything
|
||||
let canonical_wt = if worktrees_root.exists() {
|
||||
worktrees_root
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Cannot canonicalize worktrees root: {e}"))?
|
||||
} else {
|
||||
return Err("No worktrees directory found in project".to_string());
|
||||
};
|
||||
|
||||
if !canonical_wd.starts_with(&canonical_wt) {
|
||||
return Err(format!(
|
||||
"working_dir must be inside .storkit/worktrees/. Got: {working_dir}"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(canonical_wd)
|
||||
}
|
||||
|
||||
/// Regular (non-SSE) run_command: runs the bash command to completion and
|
||||
/// returns stdout, stderr, exit_code, and whether it timed out.
|
||||
pub(super) async fn tool_run_command(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let command = args
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: command")?
|
||||
.to_string();
|
||||
|
||||
let working_dir = args
|
||||
.get("working_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: working_dir")?;
|
||||
|
||||
let timeout_secs = args
|
||||
.get("timeout")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS)
|
||||
.min(MAX_TIMEOUT_SECS);
|
||||
|
||||
if let Some(reason) = is_dangerous(&command) {
|
||||
return Err(reason);
|
||||
}
|
||||
|
||||
let canonical_dir = validate_working_dir(working_dir, ctx)?;
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(timeout_secs),
|
||||
tokio::task::spawn_blocking({
|
||||
let cmd = command.clone();
|
||||
let dir = canonical_dir.clone();
|
||||
move || {
|
||||
std::process::Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(&cmd)
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(_) => {
|
||||
// timed out
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"stdout": "",
|
||||
"stderr": format!("Command timed out after {timeout_secs}s"),
|
||||
"exit_code": -1,
|
||||
"timed_out": true,
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
Ok(Err(e)) => Err(format!("Task join error: {e}")),
|
||||
Ok(Ok(Err(e))) => Err(format!("Failed to execute command: {e}")),
|
||||
Ok(Ok(Ok(output))) => {
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"stdout": String::from_utf8_lossy(&output.stdout),
|
||||
"stderr": String::from_utf8_lossy(&output.stderr),
|
||||
"exit_code": output.status.code().unwrap_or(-1),
|
||||
"timed_out": false,
|
||||
}))
|
||||
.map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SSE streaming run_command: spawns the process and emits stdout/stderr lines
|
||||
/// as JSON-RPC notifications, then a final response with exit_code.
|
||||
pub(super) fn handle_run_command_sse(
|
||||
id: Option<Value>,
|
||||
params: &Value,
|
||||
ctx: &AppContext,
|
||||
) -> Response {
|
||||
use super::{to_sse_response, JsonRpcResponse};
|
||||
|
||||
let args = params.get("arguments").cloned().unwrap_or(json!({}));
|
||||
|
||||
let command = match args.get("command").and_then(|v| v.as_str()) {
|
||||
Some(c) => c.to_string(),
|
||||
None => {
|
||||
return to_sse_response(JsonRpcResponse::error(
|
||||
id,
|
||||
-32602,
|
||||
"Missing required argument: command".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
|
||||
Some(d) => d.to_string(),
|
||||
None => {
|
||||
return to_sse_response(JsonRpcResponse::error(
|
||||
id,
|
||||
-32602,
|
||||
"Missing required argument: working_dir".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let timeout_secs = args
|
||||
.get("timeout")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS)
|
||||
.min(MAX_TIMEOUT_SECS);
|
||||
|
||||
if let Some(reason) = is_dangerous(&command) {
|
||||
return to_sse_response(JsonRpcResponse::error(id, -32602, reason));
|
||||
}
|
||||
|
||||
let canonical_dir = match validate_working_dir(&working_dir, ctx) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return to_sse_response(JsonRpcResponse::error(id, -32602, e)),
|
||||
};
|
||||
|
||||
let final_id = id;
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
|
||||
let mut child = match tokio::process::Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(&command)
|
||||
.current_dir(&canonical_dir)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let resp = JsonRpcResponse::success(
|
||||
final_id,
|
||||
json!({
|
||||
"content": [{"type": "text", "text": format!("Failed to spawn process: {e}")}],
|
||||
"isError": true
|
||||
}),
|
||||
);
|
||||
if let Ok(s) = serde_json::to_string(&resp) {
|
||||
yield Ok::<_, std::io::Error>(format!("data: {s}\n\n"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout piped");
|
||||
let stderr = child.stderr.take().expect("stderr piped");
|
||||
let mut stdout_lines = tokio::io::BufReader::new(stdout).lines();
|
||||
let mut stderr_lines = tokio::io::BufReader::new(stderr).lines();
|
||||
|
||||
let deadline = tokio::time::Instant::now()
|
||||
+ std::time::Duration::from_secs(timeout_secs);
|
||||
let mut stdout_done = false;
|
||||
let mut stderr_done = false;
|
||||
let mut timed_out = false;
|
||||
|
||||
loop {
|
||||
if stdout_done && stderr_done {
|
||||
break;
|
||||
}
|
||||
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
timed_out = true;
|
||||
let _ = child.kill().await;
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
line = stdout_lines.next_line(), if !stdout_done => {
|
||||
match line {
|
||||
Ok(Some(l)) => {
|
||||
let notif = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/tools/progress",
|
||||
"params": { "stream": "stdout", "line": l }
|
||||
});
|
||||
if let Ok(s) = serde_json::to_string(¬if) {
|
||||
yield Ok::<_, std::io::Error>(format!("data: {s}\n\n"));
|
||||
}
|
||||
}
|
||||
_ => { stdout_done = true; }
|
||||
}
|
||||
}
|
||||
line = stderr_lines.next_line(), if !stderr_done => {
|
||||
match line {
|
||||
Ok(Some(l)) => {
|
||||
let notif = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "notifications/tools/progress",
|
||||
"params": { "stream": "stderr", "line": l }
|
||||
});
|
||||
if let Ok(s) = serde_json::to_string(¬if) {
|
||||
yield Ok::<_, std::io::Error>(format!("data: {s}\n\n"));
|
||||
}
|
||||
}
|
||||
_ => { stderr_done = true; }
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep(remaining) => {
|
||||
timed_out = true;
|
||||
let _ = child.kill().await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let exit_code = child.wait().await.ok().and_then(|s| s.code()).unwrap_or(-1);
|
||||
|
||||
let summary = json!({
|
||||
"exit_code": exit_code,
|
||||
"timed_out": timed_out,
|
||||
});
|
||||
|
||||
let final_resp = JsonRpcResponse::success(
|
||||
final_id,
|
||||
json!({
|
||||
"content": [{"type": "text", "text": summary.to_string()}]
|
||||
}),
|
||||
);
|
||||
if let Ok(s) = serde_json::to_string(&final_resp) {
|
||||
yield Ok::<_, std::io::Error>(format!("data: {s}\n\n"));
|
||||
}
|
||||
};
|
||||
|
||||
Response::builder()
|
||||
.status(poem::http::StatusCode::OK)
|
||||
.header("Content-Type", "text/event-stream")
|
||||
.header("Cache-Control", "no-cache")
|
||||
.body(Body::from_bytes_stream(stream.map(|r| {
|
||||
r.map(Bytes::from)
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::AppContext;
|
||||
use serde_json::json;
|
||||
|
||||
fn test_ctx(dir: &std::path::Path) -> AppContext {
|
||||
AppContext::new_test(dir.to_path_buf())
|
||||
}
|
||||
|
||||
// ── is_dangerous ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn is_dangerous_blocks_rm_rf_root() {
|
||||
assert!(is_dangerous("rm -rf /").is_some());
|
||||
assert!(is_dangerous(" rm -rf / ").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_dangerous_blocks_rm_fr_root() {
|
||||
assert!(is_dangerous("rm -fr /").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_dangerous_blocks_rm_rf_star() {
|
||||
assert!(is_dangerous("rm -rf /*").is_some());
|
||||
assert!(is_dangerous("rm -fr /*").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_dangerous_blocks_sudo() {
|
||||
assert!(is_dangerous("sudo ls").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_dangerous_blocks_shutdown() {
|
||||
assert!(is_dangerous("shutdown -h now").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_dangerous_blocks_mkfs() {
|
||||
assert!(is_dangerous("mkfs /dev/sda1").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_dangerous_blocks_fork_bomb() {
|
||||
assert!(is_dangerous(":(){ :|:& };:").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_dangerous_allows_safe_commands() {
|
||||
assert!(is_dangerous("cargo build").is_none());
|
||||
assert!(is_dangerous("npm test").is_none());
|
||||
assert!(is_dangerous("git status").is_none());
|
||||
assert!(is_dangerous("ls -la").is_none());
|
||||
assert!(is_dangerous("rm -rf target/").is_none());
|
||||
}
|
||||
|
||||
// ── validate_working_dir ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn validate_working_dir_rejects_relative_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = validate_working_dir("relative/path", &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("absolute"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_working_dir_rejects_nonexistent_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = validate_working_dir("/nonexistent_path_xyz_abc", &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_working_dir_rejects_path_outside_worktrees() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Create the worktrees dir so it exists
|
||||
let wt_dir = tmp.path().join(".storkit").join("worktrees");
|
||||
std::fs::create_dir_all(&wt_dir).unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
// Try to use /tmp (outside worktrees)
|
||||
let result = validate_working_dir(tmp.path().to_str().unwrap(), &ctx);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().contains("inside .storkit/worktrees"),
|
||||
"expected sandbox error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_working_dir_accepts_path_inside_worktrees() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let story_wt = tmp
|
||||
.path()
|
||||
.join(".storkit")
|
||||
.join("worktrees")
|
||||
.join("42_test_story");
|
||||
std::fs::create_dir_all(&story_wt).unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = validate_working_dir(story_wt.to_str().unwrap(), &ctx);
|
||||
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_working_dir_rejects_no_worktrees_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Do NOT create worktrees dir
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = validate_working_dir(tmp.path().to_str().unwrap(), &ctx);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ── tool_run_command ───────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_run_command_missing_command() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_run_command(&json!({"working_dir": "/tmp"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("command"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_run_command_missing_working_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_run_command(&json!({"command": "ls"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("working_dir"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_run_command_blocks_dangerous_command() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_run_command(
|
||||
&json!({"command": "rm -rf /", "working_dir": "/tmp"}),
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("blocked"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_run_command_rejects_path_outside_worktrees() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let wt_dir = tmp.path().join(".storkit").join("worktrees");
|
||||
std::fs::create_dir_all(&wt_dir).unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_run_command(
|
||||
&json!({
|
||||
"command": "ls",
|
||||
"working_dir": tmp.path().to_str().unwrap()
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().contains("worktrees"),
|
||||
"expected sandbox error"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_run_command_runs_in_worktree_and_returns_output() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let story_wt = tmp
|
||||
.path()
|
||||
.join(".storkit")
|
||||
.join("worktrees")
|
||||
.join("42_test");
|
||||
std::fs::create_dir_all(&story_wt).unwrap();
|
||||
std::fs::write(story_wt.join("canary.txt"), "hello").unwrap();
|
||||
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_run_command(
|
||||
&json!({
|
||||
"command": "ls",
|
||||
"working_dir": story_wt.to_str().unwrap()
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["exit_code"], 0);
|
||||
assert!(parsed["stdout"].as_str().unwrap().contains("canary.txt"));
|
||||
assert_eq!(parsed["timed_out"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_run_command_captures_nonzero_exit_code() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let story_wt = tmp
|
||||
.path()
|
||||
.join(".storkit")
|
||||
.join("worktrees")
|
||||
.join("43_test");
|
||||
std::fs::create_dir_all(&story_wt).unwrap();
|
||||
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_run_command(
|
||||
&json!({
|
||||
"command": "exit 42",
|
||||
"working_dir": story_wt.to_str().unwrap()
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["exit_code"], 42);
|
||||
assert_eq!(parsed["timed_out"], false);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_run_command_timeout_returns_timed_out_true() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let story_wt = tmp
|
||||
.path()
|
||||
.join(".storkit")
|
||||
.join("worktrees")
|
||||
.join("44_test");
|
||||
std::fs::create_dir_all(&story_wt).unwrap();
|
||||
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_run_command(
|
||||
&json!({
|
||||
"command": "sleep 10",
|
||||
"working_dir": story_wt.to_str().unwrap(),
|
||||
"timeout": 1
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["timed_out"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_run_command_captures_stderr() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let story_wt = tmp
|
||||
.path()
|
||||
.join(".storkit")
|
||||
.join("worktrees")
|
||||
.join("45_test");
|
||||
std::fs::create_dir_all(&story_wt).unwrap();
|
||||
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_run_command(
|
||||
&json!({
|
||||
"command": "echo 'error msg' >&2",
|
||||
"working_dir": story_wt.to_str().unwrap()
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert!(
|
||||
parsed["stderr"].as_str().unwrap().contains("error msg"),
|
||||
"expected stderr: {parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_run_command_clamps_timeout_to_max() {
|
||||
// Verify timeout > 600 is clamped to 600. We don't run a 600s sleep;
|
||||
// just confirm the tool accepts the arg without error (sandbox check will
|
||||
// fail first in a different test, here we test the arg parsing path).
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
// Will fail at working_dir validation, not timeout parsing — that's fine
|
||||
let result = tool_run_command(
|
||||
&json!({"command": "ls", "working_dir": "/tmp", "timeout": 9999}),
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
// Just ensure it doesn't panic and returns an Err about sandbox (not timeout)
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
1407
server/src/http/mcp/story_tools.rs
Normal file
1407
server/src/http/mcp/story_tools.rs
Normal file
File diff suppressed because it is too large
Load Diff
364
server/src/http/mcp/whatsup_tools.rs
Normal file
364
server/src/http/mcp/whatsup_tools.rs
Normal file
@@ -0,0 +1,364 @@
|
||||
use crate::http::context::AppContext;
|
||||
use serde_json::{Value, json};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Parse all AC items from a story file, returning (text, is_checked) pairs.
|
||||
fn parse_ac_items(contents: &str) -> Vec<(String, bool)> {
|
||||
let mut in_ac_section = false;
|
||||
let mut items = Vec::new();
|
||||
|
||||
for line in contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "## Acceptance Criteria" {
|
||||
in_ac_section = true;
|
||||
continue;
|
||||
}
|
||||
// Stop at the next heading
|
||||
if in_ac_section && trimmed.starts_with("## ") {
|
||||
break;
|
||||
}
|
||||
if in_ac_section {
|
||||
if let Some(rest) = trimmed.strip_prefix("- [x] ").or(trimmed.strip_prefix("- [X] ")) {
|
||||
items.push((rest.to_string(), true));
|
||||
} else if let Some(rest) = trimmed.strip_prefix("- [ ] ") {
|
||||
items.push((rest.to_string(), false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
/// Find the most recent log file for any agent under `.storkit/logs/{story_id}/`.
|
||||
fn find_most_recent_log(project_root: &Path, story_id: &str) -> Option<PathBuf> {
|
||||
let dir = project_root
|
||||
.join(".storkit")
|
||||
.join("logs")
|
||||
.join(story_id);
|
||||
|
||||
if !dir.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut best: Option<(PathBuf, std::time::SystemTime)> = None;
|
||||
|
||||
let entries = fs::read_dir(&dir).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
if !name.ends_with(".log") {
|
||||
continue;
|
||||
}
|
||||
let modified = match entry.metadata().and_then(|m| m.modified()) {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if best.as_ref().is_none_or(|(_, t)| modified > *t) {
|
||||
best = Some((path, modified));
|
||||
}
|
||||
}
|
||||
|
||||
best.map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Return the last N raw lines from a file.
|
||||
fn last_n_lines(path: &Path, n: usize) -> Result<Vec<String>, String> {
|
||||
let content =
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read log file: {e}"))?;
|
||||
let lines: Vec<String> = content
|
||||
.lines()
|
||||
.rev()
|
||||
.take(n)
|
||||
.map(|l| l.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect();
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// Run `git diff --stat {base}...HEAD` in the worktree.
|
||||
async fn git_diff_stat(worktree: &Path, base: &str) -> Option<String> {
|
||||
let dir = worktree.to_path_buf();
|
||||
let base_arg = format!("{base}...HEAD");
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["diff", "--stat", &base_arg])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Return the last N commit messages on the current branch relative to base.
|
||||
async fn git_log_commits(worktree: &Path, base: &str, count: usize) -> Option<Vec<String>> {
|
||||
let dir = worktree.to_path_buf();
|
||||
let range = format!("{base}..HEAD");
|
||||
let count_str = count.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["log", &range, "--oneline", &format!("-{count_str}")])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
let lines: Vec<String> = String::from_utf8(output.stdout)
|
||||
.ok()?
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| l.to_string())
|
||||
.collect();
|
||||
Some(lines)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Return the active branch name for the given directory.
|
||||
async fn git_branch(dir: &Path) -> Option<String> {
|
||||
let dir = dir.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub(super) async fn tool_whatsup(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let story_id = args
|
||||
.get("story_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
|
||||
let root = ctx.state.get_project_root()?;
|
||||
let current_dir = root.join(".storkit").join("work").join("2_current");
|
||||
let filepath = current_dir.join(format!("{story_id}.md"));
|
||||
|
||||
if !filepath.exists() {
|
||||
return Err(format!(
|
||||
"Story '{story_id}' not found in work/2_current/. Check the story_id and ensure it is in the current stage."
|
||||
));
|
||||
}
|
||||
|
||||
let contents =
|
||||
fs::read_to_string(&filepath).map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||
|
||||
// --- Front matter ---
|
||||
let mut front_matter = serde_json::Map::new();
|
||||
if let Ok(meta) = crate::io::story_metadata::parse_front_matter(&contents) {
|
||||
if let Some(name) = &meta.name {
|
||||
front_matter.insert("name".to_string(), json!(name));
|
||||
}
|
||||
if let Some(agent) = &meta.agent {
|
||||
front_matter.insert("agent".to_string(), json!(agent));
|
||||
}
|
||||
if let Some(true) = meta.blocked {
|
||||
front_matter.insert("blocked".to_string(), json!(true));
|
||||
}
|
||||
if let Some(qa) = &meta.qa {
|
||||
front_matter.insert("qa".to_string(), json!(qa.as_str()));
|
||||
}
|
||||
if let Some(rc) = meta.retry_count
|
||||
&& rc > 0
|
||||
{
|
||||
front_matter.insert("retry_count".to_string(), json!(rc));
|
||||
}
|
||||
if let Some(mf) = &meta.merge_failure {
|
||||
front_matter.insert("merge_failure".to_string(), json!(mf));
|
||||
}
|
||||
if let Some(rh) = meta.review_hold
|
||||
&& rh
|
||||
{
|
||||
front_matter.insert("review_hold".to_string(), json!(rh));
|
||||
}
|
||||
}
|
||||
|
||||
// --- AC checklist ---
|
||||
let ac_items: Vec<Value> = parse_ac_items(&contents)
|
||||
.into_iter()
|
||||
.map(|(text, checked)| json!({ "text": text, "checked": checked }))
|
||||
.collect();
|
||||
|
||||
// --- Worktree ---
|
||||
let worktree_path = root.join(".storkit").join("worktrees").join(story_id);
|
||||
let (_, worktree_info) = if worktree_path.is_dir() {
|
||||
let branch = git_branch(&worktree_path).await;
|
||||
(
|
||||
branch.clone(),
|
||||
Some(json!({
|
||||
"path": worktree_path.to_string_lossy(),
|
||||
"branch": branch,
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// --- Git diff stat ---
|
||||
let diff_stat = if worktree_path.is_dir() {
|
||||
git_diff_stat(&worktree_path, "master").await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// --- Last 5 commits ---
|
||||
let commits = if worktree_path.is_dir() {
|
||||
git_log_commits(&worktree_path, "master", 5).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// --- Most recent agent log (last 20 lines) ---
|
||||
let agent_log = match find_most_recent_log(&root, story_id) {
|
||||
Some(log_path) => {
|
||||
let filename = log_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
match last_n_lines(&log_path, 20) {
|
||||
Ok(lines) => Some(json!({
|
||||
"file": filename,
|
||||
"lines": lines,
|
||||
})),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let result = json!({
|
||||
"story_id": story_id,
|
||||
"front_matter": front_matter,
|
||||
"acceptance_criteria": ac_items,
|
||||
"worktree": worktree_info,
|
||||
"git_diff_stat": diff_stat,
|
||||
"commits": commits,
|
||||
"agent_log": agent_log,
|
||||
});
|
||||
|
||||
serde_json::to_string_pretty(&result).map_err(|e| format!("Serialization error: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn parse_ac_items_returns_checked_and_unchecked() {
|
||||
let content = "---\nname: test\n---\n\n## Acceptance Criteria\n\n- [ ] item one\n- [x] item two\n- [X] item three\n\n## Out of Scope\n\n- [ ] not an ac\n";
|
||||
let items = parse_ac_items(content);
|
||||
assert_eq!(items.len(), 3);
|
||||
assert_eq!(items[0], ("item one".to_string(), false));
|
||||
assert_eq!(items[1], ("item two".to_string(), true));
|
||||
assert_eq!(items[2], ("item three".to_string(), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ac_items_empty_when_no_section() {
|
||||
let content = "---\nname: test\n---\n\nNo AC section here.\n";
|
||||
let items = parse_ac_items(content);
|
||||
assert!(items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_most_recent_log_returns_none_for_missing_dir() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let result = find_most_recent_log(tmp.path(), "nonexistent_story");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_most_recent_log_returns_newest_file() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let log_dir = tmp
|
||||
.path()
|
||||
.join(".storkit")
|
||||
.join("logs")
|
||||
.join("42_story_foo");
|
||||
fs::create_dir_all(&log_dir).unwrap();
|
||||
|
||||
let old_path = log_dir.join("coder-1-sess-old.log");
|
||||
fs::write(&old_path, "old content").unwrap();
|
||||
|
||||
// Ensure different mtime
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
let new_path = log_dir.join("coder-1-sess-new.log");
|
||||
fs::write(&new_path, "new content").unwrap();
|
||||
|
||||
let result = find_most_recent_log(tmp.path(), "42_story_foo").unwrap();
|
||||
assert!(
|
||||
result.to_string_lossy().contains("sess-new"),
|
||||
"Expected newest file, got: {}",
|
||||
result.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_whatsup_returns_error_for_missing_story() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
||||
let result = tool_whatsup(&json!({"story_id": "999_story_nonexistent"}), &ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found in work/2_current/"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_whatsup_returns_story_data() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let current_dir = tmp
|
||||
.path()
|
||||
.join(".storkit")
|
||||
.join("work")
|
||||
.join("2_current");
|
||||
fs::create_dir_all(¤t_dir).unwrap();
|
||||
|
||||
let story_content = "---\nname: My Test Story\nagent: coder-1\n---\n\n## Acceptance Criteria\n\n- [ ] First criterion\n- [x] Second criterion\n\n## Out of Scope\n\n- nothing\n";
|
||||
fs::write(current_dir.join("42_story_test.md"), story_content).unwrap();
|
||||
|
||||
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
||||
let result = tool_whatsup(&json!({"story_id": "42_story_test"}), &ctx)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
|
||||
assert_eq!(parsed["story_id"], "42_story_test");
|
||||
assert_eq!(parsed["front_matter"]["name"], "My Test Story");
|
||||
assert_eq!(parsed["front_matter"]["agent"], "coder-1");
|
||||
|
||||
let ac = parsed["acceptance_criteria"].as_array().unwrap();
|
||||
assert_eq!(ac.len(), 2);
|
||||
assert_eq!(ac[0]["text"], "First criterion");
|
||||
assert_eq!(ac[0]["checked"], false);
|
||||
assert_eq!(ac[1]["text"], "Second criterion");
|
||||
assert_eq!(ac[1]["checked"], true);
|
||||
}
|
||||
}
|
||||
215
server/src/http/mod.rs
Normal file
215
server/src/http/mod.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
pub mod agents;
|
||||
pub mod agents_sse;
|
||||
pub mod anthropic;
|
||||
pub mod assets;
|
||||
pub mod chat;
|
||||
pub mod context;
|
||||
pub mod health;
|
||||
pub mod io;
|
||||
pub mod mcp;
|
||||
pub mod model;
|
||||
pub mod settings;
|
||||
pub mod workflow;
|
||||
|
||||
pub mod project;
|
||||
pub mod ws;
|
||||
|
||||
use agents::AgentsApi;
|
||||
use anthropic::AnthropicApi;
|
||||
use chat::ChatApi;
|
||||
use context::AppContext;
|
||||
use health::HealthApi;
|
||||
use io::IoApi;
|
||||
use model::ModelApi;
|
||||
use poem::EndpointExt;
|
||||
use poem::{Route, get, post};
|
||||
use poem_openapi::OpenApiService;
|
||||
use project::ProjectApi;
|
||||
use settings::SettingsApi;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::slack::SlackWebhookContext;
|
||||
use crate::whatsapp::WhatsAppWebhookContext;
|
||||
|
||||
const DEFAULT_PORT: u16 = 3001;
|
||||
|
||||
pub fn parse_port(value: Option<String>) -> u16 {
|
||||
value
|
||||
.and_then(|v| v.parse::<u16>().ok())
|
||||
.unwrap_or(DEFAULT_PORT)
|
||||
}
|
||||
|
||||
pub fn resolve_port() -> u16 {
|
||||
parse_port(std::env::var("STORKIT_PORT").ok())
|
||||
}
|
||||
|
||||
pub fn write_port_file(dir: &Path, port: u16) -> Option<PathBuf> {
|
||||
let path = dir.join(".storkit_port");
|
||||
std::fs::write(&path, port.to_string()).ok()?;
|
||||
Some(path)
|
||||
}
|
||||
|
||||
pub fn remove_port_file(path: &Path) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
pub fn build_routes(
|
||||
ctx: AppContext,
|
||||
whatsapp_ctx: Option<Arc<WhatsAppWebhookContext>>,
|
||||
slack_ctx: Option<Arc<SlackWebhookContext>>,
|
||||
) -> impl poem::Endpoint {
|
||||
let ctx_arc = std::sync::Arc::new(ctx);
|
||||
|
||||
let (api_service, docs_service) = build_openapi_service(ctx_arc.clone());
|
||||
|
||||
let mut route = Route::new()
|
||||
.nest("/api", api_service)
|
||||
.nest("/docs", docs_service.swagger_ui())
|
||||
.at("/ws", get(ws::ws_handler))
|
||||
.at(
|
||||
"/agents/:story_id/:agent_name/stream",
|
||||
get(agents_sse::agent_stream),
|
||||
)
|
||||
.at(
|
||||
"/mcp",
|
||||
post(mcp::mcp_post_handler).get(mcp::mcp_get_handler),
|
||||
)
|
||||
.at("/health", get(health::health))
|
||||
.at("/assets/*path", get(assets::embedded_asset))
|
||||
.at("/", get(assets::embedded_index))
|
||||
.at("/*path", get(assets::embedded_file));
|
||||
|
||||
if let Some(wa_ctx) = whatsapp_ctx {
|
||||
route = route.at(
|
||||
"/webhook/whatsapp",
|
||||
get(crate::whatsapp::webhook_verify)
|
||||
.post(crate::whatsapp::webhook_receive)
|
||||
.data(wa_ctx),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(sl_ctx) = slack_ctx {
|
||||
route = route
|
||||
.at(
|
||||
"/webhook/slack",
|
||||
post(crate::slack::webhook_receive).data(sl_ctx.clone()),
|
||||
)
|
||||
.at(
|
||||
"/webhook/slack/command",
|
||||
post(crate::slack::slash_command_receive).data(sl_ctx),
|
||||
);
|
||||
}
|
||||
|
||||
route.data(ctx_arc)
|
||||
}
|
||||
|
||||
type ApiTuple = (
|
||||
ProjectApi,
|
||||
ModelApi,
|
||||
AnthropicApi,
|
||||
IoApi,
|
||||
ChatApi,
|
||||
AgentsApi,
|
||||
SettingsApi,
|
||||
HealthApi,
|
||||
);
|
||||
|
||||
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() },
|
||||
HealthApi,
|
||||
);
|
||||
|
||||
let api_service =
|
||||
OpenApiService::new(api, "Storkit 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 },
|
||||
HealthApi,
|
||||
);
|
||||
|
||||
let docs_service =
|
||||
OpenApiService::new(docs_api, "Storkit API", "1.0").server("http://127.0.0.1:3001/api");
|
||||
|
||||
(api_service, docs_service)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_port_defaults_to_3001() {
|
||||
assert_eq!(parse_port(None), 3001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_port_reads_valid_value() {
|
||||
assert_eq!(parse_port(Some("4200".to_string())), 4200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_port_ignores_invalid_value() {
|
||||
assert_eq!(parse_port(Some("not_a_number".to_string())), 3001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_remove_port_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let path = write_port_file(tmp.path(), 4567).expect("should write port file");
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "4567");
|
||||
|
||||
remove_port_file(&path);
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_port_file_returns_none_on_nonexistent_dir() {
|
||||
let bad = std::path::Path::new("/this_dir_does_not_exist_storykit_test_xyz");
|
||||
assert!(write_port_file(bad, 1234).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_port_file_does_not_panic_for_missing_file() {
|
||||
let path = std::path::Path::new("/tmp/nonexistent_storykit_port_test_xyz_999");
|
||||
remove_port_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_port_returns_a_valid_port() {
|
||||
// Exercises the resolve_port code path (reads STORKIT_PORT env var or defaults).
|
||||
let port = resolve_port();
|
||||
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();
|
||||
let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
|
||||
let _endpoint = build_routes(ctx, None, None);
|
||||
}
|
||||
}
|
||||
129
server/src/http/model.rs
Normal file
129
server/src/http/model.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
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,
|
||||
}
|
||||
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::AppContext;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_api(dir: &TempDir) -> ModelApi {
|
||||
ModelApi {
|
||||
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_model_preference_returns_none_when_unset() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api(&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(&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(&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(&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(&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());
|
||||
}
|
||||
}
|
||||
213
server/src/http/project.rs
Normal file
213
server/src/http/project.rs
Normal file
@@ -0,0 +1,213 @@
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use crate::io::fs;
|
||||
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,
|
||||
}
|
||||
|
||||
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 = fs::get_current_project(&self.ctx.state, self.ctx.store.as_ref())
|
||||
.map_err(bad_request)?;
|
||||
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 = fs::open_project(
|
||||
payload.0.path,
|
||||
&self.ctx.state,
|
||||
self.ctx.store.as_ref(),
|
||||
)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
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."
|
||||
);
|
||||
fs::close_project(&self.ctx.state, self.ctx.store.as_ref()).map_err(bad_request)?;
|
||||
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 = fs::get_known_projects(self.ctx.store.as_ref()).map_err(bad_request)?;
|
||||
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>> {
|
||||
fs::forget_known_project(payload.0.path, self.ctx.store.as_ref()).map_err(bad_request)?;
|
||||
Ok(Json(true))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::AppContext;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_api(dir: &TempDir) -> ProjectApi {
|
||||
ProjectApi {
|
||||
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_current_project_returns_none_when_unset() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api(&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(&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(&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(&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(&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(&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(&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(&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(&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(&dir);
|
||||
let result = api
|
||||
.forget_known_project(Json(PathPayload {
|
||||
path: "/some/unknown/path".to_string(),
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.0);
|
||||
}
|
||||
}
|
||||
369
server/src/http/settings.rs
Normal file
369
server/src/http/settings.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use crate::store::StoreOps;
|
||||
use poem_openapi::{Object, OpenApi, Tags, param::Query, payload::Json};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
const EDITOR_COMMAND_KEY: &str = "editor_command";
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
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 = self
|
||||
.ctx
|
||||
.store
|
||||
.get(EDITOR_COMMAND_KEY)
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()));
|
||||
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>> {
|
||||
let editor_command = get_editor_command_from_store(&self.ctx)
|
||||
.ok_or_else(|| bad_request("No editor configured".to_string()))?;
|
||||
|
||||
let file_ref = match line.0 {
|
||||
Some(l) => format!("{}:{}", path.0, l),
|
||||
None => path.0.clone(),
|
||||
};
|
||||
|
||||
std::process::Command::new(&editor_command)
|
||||
.arg(&file_ref)
|
||||
.spawn()
|
||||
.map_err(|e| bad_request(format!("Failed to open editor: {e}")))?;
|
||||
|
||||
Ok(Json(OpenFileResponse { success: true }))
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_editor_command_from_store(ctx: &AppContext) -> Option<String> {
|
||||
ctx.store
|
||||
.get(EDITOR_COMMAND_KEY)
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::context::AppContext;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn test_ctx(dir: &TempDir) -> AppContext {
|
||||
AppContext::new_test(dir.path().to_path_buf())
|
||||
}
|
||||
|
||||
fn make_api(dir: &TempDir) -> SettingsApi {
|
||||
SettingsApi {
|
||||
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_editor_returns_none_when_unset() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let api = make_api(&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(&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(&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(&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(&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(&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);
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
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(".storkit_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);
|
||||
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);
|
||||
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);
|
||||
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(&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(&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(&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(&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());
|
||||
}
|
||||
}
|
||||
586
server/src/http/workflow/bug_ops.rs
Normal file
586
server/src/http/workflow/bug_ops.rs
Normal file
@@ -0,0 +1,586 @@
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::{next_item_number, slugify_name};
|
||||
|
||||
/// Create a bug file in `work/1_backlog/` with a deterministic filename and auto-commit.
|
||||
///
|
||||
/// Returns the bug_id (e.g. `"4_bug_login_crash"`).
|
||||
pub fn create_bug_file(
|
||||
root: &Path,
|
||||
name: &str,
|
||||
description: &str,
|
||||
steps_to_reproduce: &str,
|
||||
actual_result: &str,
|
||||
expected_result: &str,
|
||||
acceptance_criteria: Option<&[String]>,
|
||||
) -> Result<String, String> {
|
||||
let bug_number = next_item_number(root)?;
|
||||
let slug = slugify_name(name);
|
||||
|
||||
if slug.is_empty() {
|
||||
return Err("Name must contain at least one alphanumeric character.".to_string());
|
||||
}
|
||||
|
||||
let filename = format!("{bug_number}_bug_{slug}.md");
|
||||
let bugs_dir = root.join(".storkit").join("work").join("1_backlog");
|
||||
fs::create_dir_all(&bugs_dir)
|
||||
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
|
||||
|
||||
let filepath = bugs_dir.join(&filename);
|
||||
if filepath.exists() {
|
||||
return Err(format!("Bug file already exists: {filename}"));
|
||||
}
|
||||
|
||||
let bug_id = filepath
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let mut content = String::new();
|
||||
content.push_str("---\n");
|
||||
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\"")));
|
||||
content.push_str("---\n\n");
|
||||
content.push_str(&format!("# Bug {bug_number}: {name}\n\n"));
|
||||
content.push_str("## Description\n\n");
|
||||
content.push_str(description);
|
||||
content.push_str("\n\n");
|
||||
content.push_str("## How to Reproduce\n\n");
|
||||
content.push_str(steps_to_reproduce);
|
||||
content.push_str("\n\n");
|
||||
content.push_str("## Actual Result\n\n");
|
||||
content.push_str(actual_result);
|
||||
content.push_str("\n\n");
|
||||
content.push_str("## Expected Result\n\n");
|
||||
content.push_str(expected_result);
|
||||
content.push_str("\n\n");
|
||||
content.push_str("## Acceptance Criteria\n\n");
|
||||
if let Some(criteria) = acceptance_criteria {
|
||||
for criterion in criteria {
|
||||
content.push_str(&format!("- [ ] {criterion}\n"));
|
||||
}
|
||||
} else {
|
||||
content.push_str("- [ ] Bug is fixed and verified\n");
|
||||
}
|
||||
|
||||
fs::write(&filepath, &content).map_err(|e| format!("Failed to write bug file: {e}"))?;
|
||||
|
||||
// Watcher handles the git commit asynchronously.
|
||||
|
||||
Ok(bug_id)
|
||||
}
|
||||
|
||||
/// Create a spike file in `work/1_backlog/` with a deterministic filename.
|
||||
///
|
||||
/// Returns the spike_id (e.g. `"4_spike_filesystem_watcher_architecture"`).
|
||||
pub fn create_spike_file(
|
||||
root: &Path,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let spike_number = next_item_number(root)?;
|
||||
let slug = slugify_name(name);
|
||||
|
||||
if slug.is_empty() {
|
||||
return Err("Name must contain at least one alphanumeric character.".to_string());
|
||||
}
|
||||
|
||||
let filename = format!("{spike_number}_spike_{slug}.md");
|
||||
let backlog_dir = root.join(".storkit").join("work").join("1_backlog");
|
||||
fs::create_dir_all(&backlog_dir)
|
||||
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
|
||||
|
||||
let filepath = backlog_dir.join(&filename);
|
||||
if filepath.exists() {
|
||||
return Err(format!("Spike file already exists: {filename}"));
|
||||
}
|
||||
|
||||
let spike_id = filepath
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let mut content = String::new();
|
||||
content.push_str("---\n");
|
||||
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\"")));
|
||||
content.push_str("---\n\n");
|
||||
content.push_str(&format!("# Spike {spike_number}: {name}\n\n"));
|
||||
content.push_str("## Question\n\n");
|
||||
if let Some(desc) = description {
|
||||
content.push_str(desc);
|
||||
content.push('\n');
|
||||
} else {
|
||||
content.push_str("- TBD\n");
|
||||
}
|
||||
content.push('\n');
|
||||
content.push_str("## Hypothesis\n\n");
|
||||
content.push_str("- TBD\n\n");
|
||||
content.push_str("## Timebox\n\n");
|
||||
content.push_str("- TBD\n\n");
|
||||
content.push_str("## Investigation Plan\n\n");
|
||||
content.push_str("- TBD\n\n");
|
||||
content.push_str("## Findings\n\n");
|
||||
content.push_str("- TBD\n\n");
|
||||
content.push_str("## Recommendation\n\n");
|
||||
content.push_str("- TBD\n");
|
||||
|
||||
fs::write(&filepath, &content).map_err(|e| format!("Failed to write spike file: {e}"))?;
|
||||
|
||||
// Watcher handles the git commit asynchronously.
|
||||
|
||||
Ok(spike_id)
|
||||
}
|
||||
|
||||
/// Create a refactor work item file in `work/1_backlog/`.
|
||||
///
|
||||
/// Returns the refactor_id (e.g. `"5_refactor_split_agents_rs"`).
|
||||
pub fn create_refactor_file(
|
||||
root: &Path,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
acceptance_criteria: Option<&[String]>,
|
||||
) -> Result<String, String> {
|
||||
let refactor_number = next_item_number(root)?;
|
||||
let slug = slugify_name(name);
|
||||
|
||||
if slug.is_empty() {
|
||||
return Err("Name must contain at least one alphanumeric character.".to_string());
|
||||
}
|
||||
|
||||
let filename = format!("{refactor_number}_refactor_{slug}.md");
|
||||
let backlog_dir = root.join(".storkit").join("work").join("1_backlog");
|
||||
fs::create_dir_all(&backlog_dir)
|
||||
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
|
||||
|
||||
let filepath = backlog_dir.join(&filename);
|
||||
if filepath.exists() {
|
||||
return Err(format!("Refactor file already exists: {filename}"));
|
||||
}
|
||||
|
||||
let refactor_id = filepath
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let mut content = String::new();
|
||||
content.push_str("---\n");
|
||||
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\"")));
|
||||
content.push_str("---\n\n");
|
||||
content.push_str(&format!("# Refactor {refactor_number}: {name}\n\n"));
|
||||
content.push_str("## Current State\n\n");
|
||||
content.push_str("- TBD\n\n");
|
||||
content.push_str("## Desired State\n\n");
|
||||
if let Some(desc) = description {
|
||||
content.push_str(desc);
|
||||
content.push('\n');
|
||||
} else {
|
||||
content.push_str("- TBD\n");
|
||||
}
|
||||
content.push('\n');
|
||||
content.push_str("## Acceptance Criteria\n\n");
|
||||
if let Some(criteria) = acceptance_criteria {
|
||||
for criterion in criteria {
|
||||
content.push_str(&format!("- [ ] {criterion}\n"));
|
||||
}
|
||||
} else {
|
||||
content.push_str("- [ ] Refactoring complete and all tests pass\n");
|
||||
}
|
||||
content.push('\n');
|
||||
content.push_str("## Out of Scope\n\n");
|
||||
content.push_str("- TBD\n");
|
||||
|
||||
fs::write(&filepath, &content)
|
||||
.map_err(|e| format!("Failed to write refactor file: {e}"))?;
|
||||
|
||||
// Watcher handles the git commit asynchronously.
|
||||
|
||||
Ok(refactor_id)
|
||||
}
|
||||
|
||||
/// Returns true if the item stem (filename without extension) is a bug item.
|
||||
/// Bug items follow the pattern: {N}_bug_{slug}
|
||||
fn is_bug_item(stem: &str) -> bool {
|
||||
// Format: {digits}_bug_{rest}
|
||||
let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit());
|
||||
after_num.starts_with("_bug_")
|
||||
}
|
||||
|
||||
/// Extract the human-readable name from a bug file's first heading.
|
||||
fn extract_bug_name(path: &Path) -> Option<String> {
|
||||
let contents = fs::read_to_string(path).ok()?;
|
||||
for line in contents.lines() {
|
||||
if let Some(rest) = line.strip_prefix("# Bug ") {
|
||||
// Format: "N: Name"
|
||||
if let Some(colon_pos) = rest.find(": ") {
|
||||
return Some(rest[colon_pos + 2..].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// List all open bugs — files in `work/1_backlog/` matching the `_bug_` naming pattern.
|
||||
///
|
||||
/// Returns a sorted list of `(bug_id, name)` pairs.
|
||||
pub fn list_bug_files(root: &Path) -> Result<Vec<(String, String)>, String> {
|
||||
let backlog_dir = root.join(".storkit").join("work").join("1_backlog");
|
||||
if !backlog_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut bugs = Vec::new();
|
||||
for entry in
|
||||
fs::read_dir(&backlog_dir).map_err(|e| format!("Failed to read backlog directory: {e}"))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| "Invalid file name.".to_string())?;
|
||||
|
||||
// Only include bug items: {N}_bug_{slug}
|
||||
if !is_bug_item(stem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let bug_id = stem.to_string();
|
||||
let name = extract_bug_name(&path).unwrap_or_else(|| bug_id.clone());
|
||||
bugs.push((bug_id, name));
|
||||
}
|
||||
|
||||
bugs.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Ok(bugs)
|
||||
}
|
||||
|
||||
/// Returns true if the item stem (filename without extension) is a refactor item.
|
||||
/// Refactor items follow the pattern: {N}_refactor_{slug}
|
||||
fn is_refactor_item(stem: &str) -> bool {
|
||||
let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit());
|
||||
after_num.starts_with("_refactor_")
|
||||
}
|
||||
|
||||
/// List all open refactors — files in `work/1_backlog/` matching the `_refactor_` naming pattern.
|
||||
///
|
||||
/// Returns a sorted list of `(refactor_id, name)` pairs.
|
||||
pub fn list_refactor_files(root: &Path) -> Result<Vec<(String, String)>, String> {
|
||||
let backlog_dir = root.join(".storkit").join("work").join("1_backlog");
|
||||
if !backlog_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut refactors = Vec::new();
|
||||
for entry in fs::read_dir(&backlog_dir)
|
||||
.map_err(|e| format!("Failed to read backlog directory: {e}"))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| "Invalid file name.".to_string())?;
|
||||
|
||||
if !is_refactor_item(stem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let refactor_id = stem.to_string();
|
||||
let name = fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|contents| parse_front_matter(&contents).ok())
|
||||
.and_then(|m| m.name)
|
||||
.unwrap_or_else(|| refactor_id.clone());
|
||||
refactors.push((refactor_id, name));
|
||||
}
|
||||
|
||||
refactors.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
Ok(refactors)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn setup_git_repo(root: &std::path::Path) {
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ── Bug file helper tests ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn next_item_number_starts_at_1_when_empty_bugs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
assert_eq!(super::super::next_item_number(tmp.path()).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_item_number_increments_from_existing_bugs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let backlog = tmp.path().join(".storkit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(backlog.join("1_bug_crash.md"), "").unwrap();
|
||||
fs::write(backlog.join("3_bug_another.md"), "").unwrap();
|
||||
assert_eq!(super::super::next_item_number(tmp.path()).unwrap(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_item_number_scans_archived_too() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let backlog = tmp.path().join(".storkit/work/1_backlog");
|
||||
let archived = tmp.path().join(".storkit/work/5_done");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::create_dir_all(&archived).unwrap();
|
||||
fs::write(archived.join("5_bug_old.md"), "").unwrap();
|
||||
assert_eq!(super::super::next_item_number(tmp.path()).unwrap(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_bug_files_empty_when_no_bugs_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = list_bug_files(tmp.path()).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_bug_files_excludes_archive_subdir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let backlog_dir = tmp.path().join(".storkit/work/1_backlog");
|
||||
let archived_dir = tmp.path().join(".storkit/work/5_done");
|
||||
fs::create_dir_all(&backlog_dir).unwrap();
|
||||
fs::create_dir_all(&archived_dir).unwrap();
|
||||
fs::write(backlog_dir.join("1_bug_open.md"), "# Bug 1: Open Bug\n").unwrap();
|
||||
fs::write(archived_dir.join("2_bug_closed.md"), "# Bug 2: Closed Bug\n").unwrap();
|
||||
|
||||
let result = list_bug_files(tmp.path()).unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].0, "1_bug_open");
|
||||
assert_eq!(result[0].1, "Open Bug");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_bug_files_sorted_by_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let backlog_dir = tmp.path().join(".storkit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog_dir).unwrap();
|
||||
fs::write(backlog_dir.join("3_bug_third.md"), "# Bug 3: Third\n").unwrap();
|
||||
fs::write(backlog_dir.join("1_bug_first.md"), "# Bug 1: First\n").unwrap();
|
||||
fs::write(backlog_dir.join("2_bug_second.md"), "# Bug 2: Second\n").unwrap();
|
||||
|
||||
let result = list_bug_files(tmp.path()).unwrap();
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[0].0, "1_bug_first");
|
||||
assert_eq!(result[1].0, "2_bug_second");
|
||||
assert_eq!(result[2].0, "3_bug_third");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_bug_name_parses_heading() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("bug-1-crash.md");
|
||||
fs::write(&path, "# Bug 1: Login page crashes\n\n## Description\n").unwrap();
|
||||
let name = extract_bug_name(&path).unwrap();
|
||||
assert_eq!(name, "Login page crashes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_bug_file_writes_correct_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo(tmp.path());
|
||||
|
||||
let bug_id = create_bug_file(
|
||||
tmp.path(),
|
||||
"Login Crash",
|
||||
"The login page crashes on submit.",
|
||||
"1. Go to /login\n2. Click submit",
|
||||
"Page crashes with 500 error",
|
||||
"Login succeeds",
|
||||
Some(&["Login form submits without error".to_string()]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(bug_id, "1_bug_login_crash");
|
||||
|
||||
let filepath = tmp
|
||||
.path()
|
||||
.join(".storkit/work/1_backlog/1_bug_login_crash.md");
|
||||
assert!(filepath.exists());
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(
|
||||
contents.starts_with("---\nname: \"Login Crash\"\n---"),
|
||||
"bug file must start with YAML front matter"
|
||||
);
|
||||
assert!(contents.contains("# Bug 1: Login Crash"));
|
||||
assert!(contents.contains("## Description"));
|
||||
assert!(contents.contains("The login page crashes on submit."));
|
||||
assert!(contents.contains("## How to Reproduce"));
|
||||
assert!(contents.contains("1. Go to /login"));
|
||||
assert!(contents.contains("## Actual Result"));
|
||||
assert!(contents.contains("Page crashes with 500 error"));
|
||||
assert!(contents.contains("## Expected Result"));
|
||||
assert!(contents.contains("Login succeeds"));
|
||||
assert!(contents.contains("## Acceptance Criteria"));
|
||||
assert!(contents.contains("- [ ] Login form submits without error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_bug_file_rejects_empty_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = create_bug_file(tmp.path(), "!!!", "desc", "steps", "actual", "expected", None);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("alphanumeric"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_bug_file_uses_default_acceptance_criterion() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo(tmp.path());
|
||||
|
||||
create_bug_file(
|
||||
tmp.path(),
|
||||
"Some Bug",
|
||||
"desc",
|
||||
"steps",
|
||||
"actual",
|
||||
"expected",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let filepath = tmp.path().join(".storkit/work/1_backlog/1_bug_some_bug.md");
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(
|
||||
contents.starts_with("---\nname: \"Some Bug\"\n---"),
|
||||
"bug file must have YAML front matter"
|
||||
);
|
||||
assert!(contents.contains("- [ ] Bug is fixed and verified"));
|
||||
}
|
||||
|
||||
// ── create_spike_file tests ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn create_spike_file_writes_correct_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let spike_id =
|
||||
create_spike_file(tmp.path(), "Filesystem Watcher Architecture", None).unwrap();
|
||||
|
||||
assert_eq!(spike_id, "1_spike_filesystem_watcher_architecture");
|
||||
|
||||
let filepath = tmp
|
||||
.path()
|
||||
.join(".storkit/work/1_backlog/1_spike_filesystem_watcher_architecture.md");
|
||||
assert!(filepath.exists());
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(
|
||||
contents.starts_with("---\nname: \"Filesystem Watcher Architecture\"\n---"),
|
||||
"spike file must start with YAML front matter"
|
||||
);
|
||||
assert!(contents.contains("# Spike 1: Filesystem Watcher Architecture"));
|
||||
assert!(contents.contains("## Question"));
|
||||
assert!(contents.contains("## Hypothesis"));
|
||||
assert!(contents.contains("## Timebox"));
|
||||
assert!(contents.contains("## Investigation Plan"));
|
||||
assert!(contents.contains("## Findings"));
|
||||
assert!(contents.contains("## Recommendation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_spike_file_uses_description_when_provided() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let description = "What is the best approach for watching filesystem events?";
|
||||
|
||||
create_spike_file(tmp.path(), "FS Watcher Spike", Some(description)).unwrap();
|
||||
|
||||
let filepath =
|
||||
tmp.path().join(".storkit/work/1_backlog/1_spike_fs_watcher_spike.md");
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(contents.contains(description));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_spike_file_uses_placeholder_when_no_description() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
create_spike_file(tmp.path(), "My Spike", None).unwrap();
|
||||
|
||||
let filepath = tmp.path().join(".storkit/work/1_backlog/1_spike_my_spike.md");
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
// Should have placeholder TBD in Question section
|
||||
assert!(contents.contains("## Question\n\n- TBD\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_spike_file_rejects_empty_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = create_spike_file(tmp.path(), "!!!", None);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("alphanumeric"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let name = "Spike: compare \"fast\" vs slow encoders";
|
||||
let result = create_spike_file(tmp.path(), name, None);
|
||||
assert!(result.is_ok(), "create_spike_file failed: {result:?}");
|
||||
|
||||
let backlog = tmp.path().join(".storkit/work/1_backlog");
|
||||
let spike_id = result.unwrap();
|
||||
let filename = format!("{spike_id}.md");
|
||||
let contents = fs::read_to_string(backlog.join(&filename)).unwrap();
|
||||
|
||||
let meta = parse_front_matter(&contents).expect("front matter should be valid YAML");
|
||||
assert_eq!(meta.name.as_deref(), Some(name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_spike_file_increments_from_existing_items() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let backlog = tmp.path().join(".storkit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(backlog.join("5_story_existing.md"), "").unwrap();
|
||||
|
||||
let spike_id = create_spike_file(tmp.path(), "My Spike", None).unwrap();
|
||||
assert!(spike_id.starts_with("6_spike_"), "expected spike number 6, got: {spike_id}");
|
||||
}
|
||||
}
|
||||
745
server/src/http/workflow/mod.rs
Normal file
745
server/src/http/workflow/mod.rs
Normal file
@@ -0,0 +1,745 @@
|
||||
mod bug_ops;
|
||||
mod story_ops;
|
||||
mod test_results;
|
||||
|
||||
pub use bug_ops::{
|
||||
create_bug_file, create_refactor_file, create_spike_file, list_bug_files, list_refactor_files,
|
||||
};
|
||||
pub use story_ops::{
|
||||
add_criterion_to_file, check_criterion_in_file, create_story_file, update_story_in_file,
|
||||
};
|
||||
pub use test_results::{
|
||||
read_test_results_from_story_file, write_coverage_baseline_to_story_file,
|
||||
write_test_results_to_story_file,
|
||||
};
|
||||
|
||||
use crate::agents::AgentStatus;
|
||||
use crate::http::context::AppContext;
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Agent assignment embedded in a pipeline stage item.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AgentAssignment {
|
||||
pub agent_name: String,
|
||||
pub model: Option<String>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct UpcomingStory {
|
||||
pub story_id: String,
|
||||
pub name: Option<String>,
|
||||
pub error: Option<String>,
|
||||
/// Merge failure reason persisted to front matter by the mergemaster agent.
|
||||
pub merge_failure: Option<String>,
|
||||
/// Active agent working on this item, if any.
|
||||
pub agent: Option<AgentAssignment>,
|
||||
/// True when the item is held in QA for human review.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub review_hold: Option<bool>,
|
||||
/// QA mode for this item: "human", "server", or "agent".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub qa: Option<String>,
|
||||
/// Number of retries at the current pipeline stage.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub retry_count: Option<u32>,
|
||||
/// True when the story has exceeded its retry limit and will not be auto-assigned.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub blocked: Option<bool>,
|
||||
}
|
||||
|
||||
pub struct StoryValidationResult {
|
||||
pub story_id: String,
|
||||
pub valid: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Full pipeline state across all stages.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PipelineState {
|
||||
pub backlog: Vec<UpcomingStory>,
|
||||
pub current: Vec<UpcomingStory>,
|
||||
pub qa: Vec<UpcomingStory>,
|
||||
pub merge: Vec<UpcomingStory>,
|
||||
pub done: Vec<UpcomingStory>,
|
||||
}
|
||||
|
||||
/// Load the full pipeline state (all 5 active stages).
|
||||
pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
||||
let agent_map = build_active_agent_map(ctx);
|
||||
Ok(PipelineState {
|
||||
backlog: load_stage_items(ctx, "1_backlog", &HashMap::new())?,
|
||||
current: load_stage_items(ctx, "2_current", &agent_map)?,
|
||||
qa: load_stage_items(ctx, "3_qa", &agent_map)?,
|
||||
merge: load_stage_items(ctx, "4_merge", &agent_map)?,
|
||||
done: load_stage_items(ctx, "5_done", &HashMap::new())?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a map from story_id → AgentAssignment for all pending/running agents.
|
||||
fn build_active_agent_map(ctx: &AppContext) -> HashMap<String, AgentAssignment> {
|
||||
let agents = match ctx.agents.list_agents() {
|
||||
Ok(a) => a,
|
||||
Err(_) => return HashMap::new(),
|
||||
};
|
||||
|
||||
let config_opt = ctx
|
||||
.state
|
||||
.get_project_root()
|
||||
.ok()
|
||||
.and_then(|root| crate::config::ProjectConfig::load(&root).ok());
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for agent in agents {
|
||||
if !matches!(agent.status, AgentStatus::Pending | AgentStatus::Running) {
|
||||
continue;
|
||||
}
|
||||
let model = config_opt
|
||||
.as_ref()
|
||||
.and_then(|cfg| cfg.find_agent(&agent.agent_name))
|
||||
.and_then(|ac| ac.model.clone());
|
||||
map.insert(
|
||||
agent.story_id.clone(),
|
||||
AgentAssignment {
|
||||
agent_name: agent.agent_name,
|
||||
model,
|
||||
status: agent.status.to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Load work items from any pipeline stage directory.
|
||||
fn load_stage_items(
|
||||
ctx: &AppContext,
|
||||
stage_dir: &str,
|
||||
agent_map: &HashMap<String, AgentAssignment>,
|
||||
) -> Result<Vec<UpcomingStory>, String> {
|
||||
let root = ctx.state.get_project_root()?;
|
||||
let dir = root.join(".storkit").join("work").join(stage_dir);
|
||||
|
||||
if !dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut stories = Vec::new();
|
||||
for entry in fs::read_dir(&dir)
|
||||
.map_err(|e| format!("Failed to read {stage_dir} directory: {e}"))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Failed to read {stage_dir} entry: {e}"))?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
let story_id = path
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.ok_or_else(|| "Invalid story file name.".to_string())?
|
||||
.to_string();
|
||||
let contents = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
|
||||
let (name, error, merge_failure, review_hold, qa, retry_count, blocked) = match parse_front_matter(&contents) {
|
||||
Ok(meta) => (meta.name, None, meta.merge_failure, meta.review_hold, meta.qa.map(|m| m.as_str().to_string()), meta.retry_count, meta.blocked),
|
||||
Err(e) => (None, Some(e.to_string()), None, None, None, None, None),
|
||||
};
|
||||
let agent = agent_map.get(&story_id).cloned();
|
||||
stories.push(UpcomingStory { story_id, name, error, merge_failure, agent, review_hold, qa, retry_count, blocked });
|
||||
}
|
||||
|
||||
stories.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
||||
Ok(stories)
|
||||
}
|
||||
|
||||
pub fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
|
||||
load_stage_items(ctx, "1_backlog", &HashMap::new())
|
||||
}
|
||||
|
||||
pub fn validate_story_dirs(
|
||||
root: &std::path::Path,
|
||||
) -> Result<Vec<StoryValidationResult>, String> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Directories to validate: work/2_current/ + work/1_backlog/
|
||||
let dirs_to_validate: Vec<PathBuf> = vec![
|
||||
root.join(".storkit").join("work").join("2_current"),
|
||||
root.join(".storkit").join("work").join("1_backlog"),
|
||||
];
|
||||
|
||||
for dir in &dirs_to_validate {
|
||||
let subdir = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
for entry in
|
||||
fs::read_dir(dir).map_err(|e| format!("Failed to read {subdir} directory: {e}"))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
let story_id = path
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let contents = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
|
||||
match parse_front_matter(&contents) {
|
||||
Ok(meta) => {
|
||||
let mut errors = Vec::new();
|
||||
if meta.name.is_none() {
|
||||
errors.push("Missing 'name' field".to_string());
|
||||
}
|
||||
if errors.is_empty() {
|
||||
results.push(StoryValidationResult {
|
||||
story_id,
|
||||
valid: true,
|
||||
error: None,
|
||||
});
|
||||
} else {
|
||||
results.push(StoryValidationResult {
|
||||
story_id,
|
||||
valid: false,
|
||||
error: Some(errors.join("; ")),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => results.push(StoryValidationResult {
|
||||
story_id,
|
||||
valid: false,
|
||||
error: Some(e.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
// ── Shared utilities used by submodules ──────────────────────────
|
||||
|
||||
/// Locate a work item file by searching all active pipeline stages.
|
||||
///
|
||||
/// Searches in priority order: 2_current, 1_backlog, 3_qa, 4_merge, 5_done, 6_archived.
|
||||
pub(super) fn find_story_file(project_root: &Path, story_id: &str) -> Result<PathBuf, String> {
|
||||
let filename = format!("{story_id}.md");
|
||||
let sk = project_root.join(".storkit").join("work");
|
||||
for stage in &["2_current", "1_backlog", "3_qa", "4_merge", "5_done", "6_archived"] {
|
||||
let path = sk.join(stage).join(&filename);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"Story '{story_id}' not found in any pipeline stage."
|
||||
))
|
||||
}
|
||||
|
||||
/// Replace the content of a named `## Section` in a story file.
|
||||
///
|
||||
/// Finds the first occurrence of `## {section_name}` and replaces everything
|
||||
/// until the next `##` heading (or end of file) with the provided text.
|
||||
/// Returns an error if the section is not found.
|
||||
pub(super) fn replace_section_content(content: &str, section_name: &str, new_text: &str) -> Result<String, String> {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let heading = format!("## {section_name}");
|
||||
|
||||
let mut section_start: Option<usize> = None;
|
||||
let mut section_end: Option<usize> = None;
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == heading {
|
||||
section_start = Some(i);
|
||||
continue;
|
||||
}
|
||||
if section_start.is_some() && trimmed.starts_with("## ") {
|
||||
section_end = Some(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let section_start =
|
||||
section_start.ok_or_else(|| format!("Section '{heading}' not found in story file."))?;
|
||||
|
||||
let mut new_lines: Vec<String> = Vec::new();
|
||||
// Keep everything up to and including the section heading.
|
||||
for line in lines.iter().take(section_start + 1) {
|
||||
new_lines.push(line.to_string());
|
||||
}
|
||||
// Blank line, new content, blank line.
|
||||
new_lines.push(String::new());
|
||||
new_lines.push(new_text.to_string());
|
||||
new_lines.push(String::new());
|
||||
// Resume from the next section heading (or EOF).
|
||||
let resume_from = section_end.unwrap_or(lines.len());
|
||||
for line in lines.iter().skip(resume_from) {
|
||||
new_lines.push(line.to_string());
|
||||
}
|
||||
|
||||
let mut new_str = new_lines.join("\n");
|
||||
if content.ends_with('\n') {
|
||||
new_str.push('\n');
|
||||
}
|
||||
Ok(new_str)
|
||||
}
|
||||
|
||||
/// Replace the `## Test Results` section in `contents` with `new_section`,
|
||||
/// or append it if not present.
|
||||
pub(super) fn replace_or_append_section(contents: &str, header: &str, new_section: &str) -> String {
|
||||
let lines: Vec<&str> = contents.lines().collect();
|
||||
let header_trimmed = header.trim();
|
||||
|
||||
// Find the start of the existing section
|
||||
let section_start = lines.iter().position(|l| l.trim() == header_trimmed);
|
||||
|
||||
if let Some(start) = section_start {
|
||||
// Find the next `##` heading after the section start (the end of this section)
|
||||
let section_end = lines[start + 1..]
|
||||
.iter()
|
||||
.position(|l| {
|
||||
let t = l.trim();
|
||||
t.starts_with("## ") && t != header_trimmed
|
||||
})
|
||||
.map(|i| start + 1 + i)
|
||||
.unwrap_or(lines.len());
|
||||
|
||||
let mut result = lines[..start].join("\n");
|
||||
if !result.is_empty() {
|
||||
result.push('\n');
|
||||
}
|
||||
result.push_str(new_section);
|
||||
if section_end < lines.len() {
|
||||
result.push('\n');
|
||||
result.push_str(&lines[section_end..].join("\n"));
|
||||
}
|
||||
if contents.ends_with('\n') {
|
||||
result.push('\n');
|
||||
}
|
||||
result
|
||||
} else {
|
||||
// Append at the end
|
||||
let mut result = contents.trim_end_matches('\n').to_string();
|
||||
result.push_str("\n\n");
|
||||
result.push_str(new_section);
|
||||
if !result.ends_with('\n') {
|
||||
result.push('\n');
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn slugify_name(name: &str) -> String {
|
||||
let slug: String = name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Collapse consecutive underscores and trim edges
|
||||
let mut result = String::new();
|
||||
let mut prev_underscore = true; // start true to trim leading _
|
||||
for ch in slug.chars() {
|
||||
if ch == '_' {
|
||||
if !prev_underscore {
|
||||
result.push('_');
|
||||
}
|
||||
prev_underscore = true;
|
||||
} else {
|
||||
result.push(ch);
|
||||
prev_underscore = false;
|
||||
}
|
||||
}
|
||||
// Trim trailing underscore
|
||||
if result.ends_with('_') {
|
||||
result.pop();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Scan all `work/` subdirectories for the highest item number across all types (stories, bugs, spikes).
|
||||
pub(super) fn next_item_number(root: &std::path::Path) -> Result<u32, String> {
|
||||
let work_base = root.join(".storkit").join("work");
|
||||
let mut max_num: u32 = 0;
|
||||
|
||||
for subdir in &["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] {
|
||||
let dir = work_base.join(subdir);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
}
|
||||
for entry in
|
||||
fs::read_dir(&dir).map_err(|e| format!("Failed to read {subdir} directory: {e}"))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
// Filename format: {N}_{type}_{slug}.md — extract leading N
|
||||
let num_str: String = name_str.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(n) = num_str.parse::<u32>()
|
||||
&& n > max_num
|
||||
{
|
||||
max_num = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(max_num + 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn load_pipeline_state_loads_all_stages() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
|
||||
for (stage, id) in &[
|
||||
("1_backlog", "10_story_upcoming"),
|
||||
("2_current", "20_story_current"),
|
||||
("3_qa", "30_story_qa"),
|
||||
("4_merge", "40_story_merge"),
|
||||
("5_done", "50_story_done"),
|
||||
] {
|
||||
let dir = root.join(".storkit").join("work").join(stage);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(
|
||||
dir.join(format!("{id}.md")),
|
||||
format!("---\nname: {id}\n---\n"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let ctx = crate::http::context::AppContext::new_test(root);
|
||||
let state = load_pipeline_state(&ctx).unwrap();
|
||||
|
||||
assert_eq!(state.backlog.len(), 1);
|
||||
assert_eq!(state.backlog[0].story_id, "10_story_upcoming");
|
||||
|
||||
assert_eq!(state.current.len(), 1);
|
||||
assert_eq!(state.current[0].story_id, "20_story_current");
|
||||
|
||||
assert_eq!(state.qa.len(), 1);
|
||||
assert_eq!(state.qa[0].story_id, "30_story_qa");
|
||||
|
||||
assert_eq!(state.merge.len(), 1);
|
||||
assert_eq!(state.merge[0].story_id, "40_story_merge");
|
||||
|
||||
assert_eq!(state.done.len(), 1);
|
||||
assert_eq!(state.done[0].story_id, "50_story_done");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_upcoming_returns_empty_when_no_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
// No .storkit directory at all
|
||||
let ctx = crate::http::context::AppContext::new_test(root);
|
||||
let result = load_upcoming_stories(&ctx).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_state_includes_agent_for_running_story() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
|
||||
let current = root.join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(
|
||||
current.join("10_story_test.md"),
|
||||
"---\nname: Test Story\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let ctx = crate::http::context::AppContext::new_test(root);
|
||||
ctx.agents.inject_test_agent("10_story_test", "coder-1", crate::agents::AgentStatus::Running);
|
||||
|
||||
let state = load_pipeline_state(&ctx).unwrap();
|
||||
|
||||
assert_eq!(state.current.len(), 1);
|
||||
let item = &state.current[0];
|
||||
assert!(item.agent.is_some(), "running agent should appear on work item");
|
||||
let agent = item.agent.as_ref().unwrap();
|
||||
assert_eq!(agent.agent_name, "coder-1");
|
||||
assert_eq!(agent.status, "running");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_state_no_agent_for_completed_story() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
|
||||
let current = root.join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(
|
||||
current.join("11_story_done.md"),
|
||||
"---\nname: Done Story\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let ctx = crate::http::context::AppContext::new_test(root);
|
||||
ctx.agents.inject_test_agent("11_story_done", "coder-1", crate::agents::AgentStatus::Completed);
|
||||
|
||||
let state = load_pipeline_state(&ctx).unwrap();
|
||||
|
||||
assert_eq!(state.current.len(), 1);
|
||||
assert!(
|
||||
state.current[0].agent.is_none(),
|
||||
"completed agent should not appear on work item"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_state_pending_agent_included() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
|
||||
let current = root.join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(
|
||||
current.join("12_story_pending.md"),
|
||||
"---\nname: Pending Story\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let ctx = crate::http::context::AppContext::new_test(root);
|
||||
ctx.agents.inject_test_agent("12_story_pending", "coder-1", crate::agents::AgentStatus::Pending);
|
||||
|
||||
let state = load_pipeline_state(&ctx).unwrap();
|
||||
|
||||
assert_eq!(state.current.len(), 1);
|
||||
let item = &state.current[0];
|
||||
assert!(item.agent.is_some(), "pending agent should appear on work item");
|
||||
assert_eq!(item.agent.as_ref().unwrap().status, "pending");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_upcoming_parses_metadata() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let backlog = tmp.path().join(".storkit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(
|
||||
backlog.join("31_story_view_upcoming.md"),
|
||||
"---\nname: View Upcoming\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
backlog.join("32_story_worktree.md"),
|
||||
"---\nname: Worktree Orchestration\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
||||
let stories = load_upcoming_stories(&ctx).unwrap();
|
||||
assert_eq!(stories.len(), 2);
|
||||
assert_eq!(stories[0].story_id, "31_story_view_upcoming");
|
||||
assert_eq!(stories[0].name.as_deref(), Some("View Upcoming"));
|
||||
assert_eq!(stories[1].story_id, "32_story_worktree");
|
||||
assert_eq!(stories[1].name.as_deref(), Some("Worktree Orchestration"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_upcoming_skips_non_md_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let backlog = tmp.path().join(".storkit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(backlog.join(".gitkeep"), "").unwrap();
|
||||
fs::write(
|
||||
backlog.join("31_story_example.md"),
|
||||
"---\nname: A Story\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
||||
let stories = load_upcoming_stories(&ctx).unwrap();
|
||||
assert_eq!(stories.len(), 1);
|
||||
assert_eq!(stories[0].story_id, "31_story_example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_story_dirs_valid_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
let backlog = tmp.path().join(".storkit/work/1_backlog");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(
|
||||
current.join("28_story_todos.md"),
|
||||
"---\nname: Show TODOs\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
backlog.join("36_story_front_matter.md"),
|
||||
"---\nname: Enforce Front Matter\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = validate_story_dirs(tmp.path()).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results.iter().all(|r| r.valid));
|
||||
assert!(results.iter().all(|r| r.error.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_story_dirs_missing_front_matter() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("28_story_todos.md"), "# No front matter\n").unwrap();
|
||||
|
||||
let results = validate_story_dirs(tmp.path()).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(!results[0].valid);
|
||||
assert_eq!(results[0].error.as_deref(), Some("Missing front matter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_story_dirs_missing_required_fields() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("28_story_todos.md"), "---\n---\n# Story\n").unwrap();
|
||||
|
||||
let results = validate_story_dirs(tmp.path()).unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(!results[0].valid);
|
||||
let err = results[0].error.as_deref().unwrap();
|
||||
assert!(err.contains("Missing 'name' field"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_story_dirs_empty_when_no_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let results = validate_story_dirs(tmp.path()).unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
// --- slugify_name tests ---
|
||||
|
||||
#[test]
|
||||
fn slugify_simple_name() {
|
||||
assert_eq!(
|
||||
slugify_name("Enforce Front Matter on All Story Files"),
|
||||
"enforce_front_matter_on_all_story_files"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_with_special_chars() {
|
||||
assert_eq!(slugify_name("Hello, World! (v2)"), "hello_world_v2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_leading_trailing_underscores() {
|
||||
assert_eq!(slugify_name(" spaces "), "spaces");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_consecutive_separators() {
|
||||
assert_eq!(slugify_name("a--b__c d"), "a_b_c_d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_empty_after_strip() {
|
||||
assert_eq!(slugify_name("!!!"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_already_snake_case() {
|
||||
assert_eq!(slugify_name("my_story_name"), "my_story_name");
|
||||
}
|
||||
|
||||
// --- next_item_number tests ---
|
||||
|
||||
#[test]
|
||||
fn next_item_number_empty_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = tmp.path().join(".storkit/work/1_backlog");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
assert_eq!(next_item_number(tmp.path()).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_item_number_scans_all_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let backlog = tmp.path().join(".storkit/work/1_backlog");
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
let archived = tmp.path().join(".storkit/work/5_done");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::create_dir_all(&archived).unwrap();
|
||||
fs::write(backlog.join("10_story_foo.md"), "").unwrap();
|
||||
fs::write(current.join("20_story_bar.md"), "").unwrap();
|
||||
fs::write(archived.join("15_story_baz.md"), "").unwrap();
|
||||
assert_eq!(next_item_number(tmp.path()).unwrap(), 21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_item_number_no_work_dirs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// No .storkit at all
|
||||
assert_eq!(next_item_number(tmp.path()).unwrap(), 1);
|
||||
}
|
||||
|
||||
// --- find_story_file tests ---
|
||||
|
||||
#[test]
|
||||
fn find_story_file_searches_current_then_backlog() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
let backlog = tmp.path().join(".storkit/work/1_backlog");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
|
||||
// Only in backlog
|
||||
fs::write(backlog.join("6_test.md"), "").unwrap();
|
||||
let found = find_story_file(tmp.path(), "6_test").unwrap();
|
||||
assert!(found.ends_with("1_backlog/6_test.md") || found.ends_with("1_backlog\\6_test.md"));
|
||||
|
||||
// Also in current — current should win
|
||||
fs::write(current.join("6_test.md"), "").unwrap();
|
||||
let found = find_story_file(tmp.path(), "6_test").unwrap();
|
||||
assert!(found.ends_with("2_current/6_test.md") || found.ends_with("2_current\\6_test.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_story_file_returns_error_when_not_found() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = find_story_file(tmp.path(), "99_missing");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not found"));
|
||||
}
|
||||
|
||||
// --- replace_or_append_section tests ---
|
||||
|
||||
#[test]
|
||||
fn replace_or_append_section_appends_when_absent() {
|
||||
let contents = "---\nname: T\n---\n# Story\n";
|
||||
let new = replace_or_append_section(contents, "## Test Results", "## Test Results\n\nfoo\n");
|
||||
assert!(new.contains("## Test Results"));
|
||||
assert!(new.contains("foo"));
|
||||
assert!(new.contains("# Story"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_or_append_section_replaces_existing() {
|
||||
let contents = "# Story\n\n## Test Results\n\nold content\n\n## Other\n\nother content\n";
|
||||
let new = replace_or_append_section(contents, "## Test Results", "## Test Results\n\nnew content\n");
|
||||
assert!(new.contains("new content"));
|
||||
assert!(!new.contains("old content"));
|
||||
assert!(new.contains("## Other"));
|
||||
}
|
||||
}
|
||||
592
server/src/http/workflow/story_ops.rs
Normal file
592
server/src/http/workflow/story_ops.rs
Normal file
@@ -0,0 +1,592 @@
|
||||
use crate::io::story_metadata::set_front_matter_field;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::{find_story_file, next_item_number, replace_section_content, slugify_name};
|
||||
|
||||
/// Shared create-story logic used by both the OpenApi and MCP handlers.
|
||||
///
|
||||
/// When `commit` is `true`, the new story file is git-added and committed to
|
||||
/// the current branch immediately after creation.
|
||||
pub fn create_story_file(
|
||||
root: &std::path::Path,
|
||||
name: &str,
|
||||
user_story: Option<&str>,
|
||||
acceptance_criteria: Option<&[String]>,
|
||||
commit: bool,
|
||||
) -> Result<String, String> {
|
||||
let story_number = next_item_number(root)?;
|
||||
let slug = slugify_name(name);
|
||||
|
||||
if slug.is_empty() {
|
||||
return Err("Name must contain at least one alphanumeric character.".to_string());
|
||||
}
|
||||
|
||||
let filename = format!("{story_number}_story_{slug}.md");
|
||||
let backlog_dir = root.join(".storkit").join("work").join("1_backlog");
|
||||
fs::create_dir_all(&backlog_dir)
|
||||
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
|
||||
|
||||
let filepath = backlog_dir.join(&filename);
|
||||
if filepath.exists() {
|
||||
return Err(format!("Story file already exists: {filename}"));
|
||||
}
|
||||
|
||||
let story_id = filepath
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
|
||||
let mut content = String::new();
|
||||
content.push_str("---\n");
|
||||
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\"")));
|
||||
content.push_str("---\n\n");
|
||||
content.push_str(&format!("# Story {story_number}: {name}\n\n"));
|
||||
|
||||
content.push_str("## User Story\n\n");
|
||||
if let Some(us) = user_story {
|
||||
content.push_str(us);
|
||||
content.push('\n');
|
||||
} else {
|
||||
content.push_str("As a ..., I want ..., so that ...\n");
|
||||
}
|
||||
content.push('\n');
|
||||
|
||||
content.push_str("## Acceptance Criteria\n\n");
|
||||
if let Some(criteria) = acceptance_criteria {
|
||||
for criterion in criteria {
|
||||
content.push_str(&format!("- [ ] {criterion}\n"));
|
||||
}
|
||||
} else {
|
||||
content.push_str("- [ ] TODO\n");
|
||||
}
|
||||
content.push('\n');
|
||||
|
||||
content.push_str("## Out of Scope\n\n");
|
||||
content.push_str("- TBD\n");
|
||||
|
||||
fs::write(&filepath, &content)
|
||||
.map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||
|
||||
// Watcher handles the git commit asynchronously.
|
||||
let _ = commit; // kept for API compat, ignored
|
||||
|
||||
Ok(story_id)
|
||||
}
|
||||
|
||||
/// Check off the Nth unchecked acceptance criterion in a story file and auto-commit.
|
||||
///
|
||||
/// `criterion_index` is 0-based among unchecked (`- [ ]`) items.
|
||||
pub fn check_criterion_in_file(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
criterion_index: usize,
|
||||
) -> Result<(), String> {
|
||||
let filepath = find_story_file(project_root, story_id)?;
|
||||
let contents = fs::read_to_string(&filepath)
|
||||
.map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||
|
||||
let mut unchecked_count: usize = 0;
|
||||
let mut found = false;
|
||||
let new_lines: Vec<String> = contents
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix("- [ ] ") {
|
||||
if unchecked_count == criterion_index {
|
||||
unchecked_count += 1;
|
||||
found = true;
|
||||
let indent_len = line.len() - trimmed.len();
|
||||
let indent = &line[..indent_len];
|
||||
return format!("{indent}- [x] {rest}");
|
||||
}
|
||||
unchecked_count += 1;
|
||||
}
|
||||
line.to_string()
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !found {
|
||||
return Err(format!(
|
||||
"Criterion index {criterion_index} out of range. Story '{story_id}' has \
|
||||
{unchecked_count} unchecked criteria (indices 0..{}).",
|
||||
unchecked_count.saturating_sub(1)
|
||||
));
|
||||
}
|
||||
|
||||
let mut new_str = new_lines.join("\n");
|
||||
if contents.ends_with('\n') {
|
||||
new_str.push('\n');
|
||||
}
|
||||
fs::write(&filepath, &new_str)
|
||||
.map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||
|
||||
// Watcher handles the git commit asynchronously.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a new acceptance criterion to a story file.
|
||||
///
|
||||
/// Appends `- [ ] {criterion}` after the last existing criterion line in the
|
||||
/// "## Acceptance Criteria" section, or directly after the section heading if
|
||||
/// the section is empty. The filesystem watcher auto-commits the change.
|
||||
pub fn add_criterion_to_file(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
criterion: &str,
|
||||
) -> Result<(), String> {
|
||||
let filepath = find_story_file(project_root, story_id)?;
|
||||
let contents = fs::read_to_string(&filepath)
|
||||
.map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||
|
||||
let lines: Vec<&str> = contents.lines().collect();
|
||||
let mut in_ac_section = false;
|
||||
let mut ac_section_start: Option<usize> = None;
|
||||
let mut last_criterion_line: Option<usize> = None;
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "## Acceptance Criteria" {
|
||||
in_ac_section = true;
|
||||
ac_section_start = Some(i);
|
||||
continue;
|
||||
}
|
||||
if in_ac_section {
|
||||
if trimmed.starts_with("## ") {
|
||||
break;
|
||||
}
|
||||
if trimmed.starts_with("- [ ] ") || trimmed.starts_with("- [x] ") {
|
||||
last_criterion_line = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let insert_after = last_criterion_line
|
||||
.or(ac_section_start)
|
||||
.ok_or_else(|| {
|
||||
format!("Story '{story_id}' has no '## Acceptance Criteria' section.")
|
||||
})?;
|
||||
|
||||
let mut new_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect();
|
||||
new_lines.insert(insert_after + 1, format!("- [ ] {criterion}"));
|
||||
|
||||
let mut new_str = new_lines.join("\n");
|
||||
if contents.ends_with('\n') {
|
||||
new_str.push('\n');
|
||||
}
|
||||
fs::write(&filepath, &new_str)
|
||||
.map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||
|
||||
// Watcher handles the git commit asynchronously.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the user story text and/or description in a story file.
|
||||
///
|
||||
/// At least one of `user_story` or `description` must be provided.
|
||||
/// Replaces the content of the corresponding `##` section in place.
|
||||
/// The filesystem watcher auto-commits the change.
|
||||
pub fn update_story_in_file(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
user_story: Option<&str>,
|
||||
description: Option<&str>,
|
||||
front_matter: Option<&HashMap<String, String>>,
|
||||
) -> Result<(), String> {
|
||||
let has_front_matter_updates = front_matter.map(|m| !m.is_empty()).unwrap_or(false);
|
||||
if user_story.is_none() && description.is_none() && !has_front_matter_updates {
|
||||
return Err(
|
||||
"At least one of 'user_story', 'description', or 'front_matter' must be provided."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let filepath = find_story_file(project_root, story_id)?;
|
||||
let mut contents = fs::read_to_string(&filepath)
|
||||
.map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||
|
||||
if let Some(fields) = front_matter {
|
||||
for (key, value) in fields {
|
||||
let yaml_value = format!("\"{}\"", value.replace('"', "\\\"").replace('\n', " ").replace('\r', ""));
|
||||
contents = set_front_matter_field(&contents, key, &yaml_value);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(us) = user_story {
|
||||
contents = replace_section_content(&contents, "User Story", us)?;
|
||||
}
|
||||
if let Some(desc) = description {
|
||||
contents = replace_section_content(&contents, "Description", desc)?;
|
||||
}
|
||||
|
||||
fs::write(&filepath, &contents)
|
||||
.map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||
|
||||
// Watcher handles the git commit asynchronously.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
|
||||
fn setup_git_repo(root: &std::path::Path) {
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(root)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn story_with_criteria(n: usize) -> String {
|
||||
let mut s = "---\nname: Test Story\n---\n\n## Acceptance Criteria\n\n".to_string();
|
||||
for i in 0..n {
|
||||
s.push_str(&format!("- [ ] Criterion {i}\n"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
// --- create_story integration tests ---
|
||||
|
||||
#[test]
|
||||
fn create_story_writes_correct_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let backlog = tmp.path().join(".storkit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::write(backlog.join("36_story_existing.md"), "").unwrap();
|
||||
|
||||
let number = super::super::next_item_number(tmp.path()).unwrap();
|
||||
assert_eq!(number, 37);
|
||||
|
||||
let slug = super::super::slugify_name("My New Feature");
|
||||
assert_eq!(slug, "my_new_feature");
|
||||
|
||||
let filename = format!("{number}_{slug}.md");
|
||||
let filepath = backlog.join(&filename);
|
||||
|
||||
let mut content = String::new();
|
||||
content.push_str("---\n");
|
||||
content.push_str("name: \"My New Feature\"\n");
|
||||
content.push_str("---\n\n");
|
||||
content.push_str(&format!("# Story {number}: My New Feature\n\n"));
|
||||
content.push_str("## User Story\n\n");
|
||||
content.push_str("As a dev, I want this feature\n\n");
|
||||
content.push_str("## Acceptance Criteria\n\n");
|
||||
content.push_str("- [ ] It works\n");
|
||||
content.push_str("- [ ] It is tested\n\n");
|
||||
content.push_str("## Out of Scope\n\n");
|
||||
content.push_str("- TBD\n");
|
||||
|
||||
fs::write(&filepath, &content).unwrap();
|
||||
|
||||
let written = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(written.starts_with("---\nname: \"My New Feature\"\n---"));
|
||||
assert!(written.contains("# Story 37: My New Feature"));
|
||||
assert!(written.contains("- [ ] It works"));
|
||||
assert!(written.contains("- [ ] It is tested"));
|
||||
assert!(written.contains("## Out of Scope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_story_with_colon_in_name_produces_valid_yaml() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let name = "Server-owned agent completion: remove report_completion dependency";
|
||||
let result = create_story_file(tmp.path(), name, None, None, false);
|
||||
assert!(result.is_ok(), "create_story_file failed: {result:?}");
|
||||
|
||||
let backlog = tmp.path().join(".storkit/work/1_backlog");
|
||||
let story_id = result.unwrap();
|
||||
let filename = format!("{story_id}.md");
|
||||
let contents = fs::read_to_string(backlog.join(&filename)).unwrap();
|
||||
|
||||
let meta = parse_front_matter(&contents).expect("front matter should be valid YAML");
|
||||
assert_eq!(meta.name.as_deref(), Some(name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_story_rejects_duplicate() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let backlog = tmp.path().join(".storkit/work/1_backlog");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
|
||||
let filepath = backlog.join("1_story_my_feature.md");
|
||||
fs::write(&filepath, "existing").unwrap();
|
||||
|
||||
// Simulate the check
|
||||
assert!(filepath.exists());
|
||||
}
|
||||
|
||||
// ── check_criterion_in_file tests ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn check_criterion_marks_first_unchecked() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo(tmp.path());
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let filepath = current.join("1_test.md");
|
||||
fs::write(&filepath, story_with_criteria(3)).unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "add story"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
check_criterion_in_file(tmp.path(), "1_test", 0).unwrap();
|
||||
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(contents.contains("- [x] Criterion 0"), "first should be checked");
|
||||
assert!(contents.contains("- [ ] Criterion 1"), "second should stay unchecked");
|
||||
assert!(contents.contains("- [ ] Criterion 2"), "third should stay unchecked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_criterion_marks_second_unchecked() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo(tmp.path());
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let filepath = current.join("2_test.md");
|
||||
fs::write(&filepath, story_with_criteria(3)).unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "add story"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
check_criterion_in_file(tmp.path(), "2_test", 1).unwrap();
|
||||
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(contents.contains("- [ ] Criterion 0"), "first should stay unchecked");
|
||||
assert!(contents.contains("- [x] Criterion 1"), "second should be checked");
|
||||
assert!(contents.contains("- [ ] Criterion 2"), "third should stay unchecked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_criterion_out_of_range_returns_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo(tmp.path());
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let filepath = current.join("3_test.md");
|
||||
fs::write(&filepath, story_with_criteria(2)).unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "add story"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let result = check_criterion_in_file(tmp.path(), "3_test", 5);
|
||||
assert!(result.is_err(), "should fail for out-of-range index");
|
||||
assert!(result.unwrap_err().contains("out of range"));
|
||||
}
|
||||
|
||||
// ── add_criterion_to_file tests ───────────────────────────────────────────
|
||||
|
||||
fn story_with_ac_section(criteria: &[&str]) -> String {
|
||||
let mut s = "---\nname: Test\n---\n\n## User Story\n\nAs a user...\n\n## Acceptance Criteria\n\n".to_string();
|
||||
for c in criteria {
|
||||
s.push_str(&format!("- [ ] {c}\n"));
|
||||
}
|
||||
s.push_str("\n## Out of Scope\n\n- N/A\n");
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_criterion_appends_after_last_criterion() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let filepath = current.join("10_test.md");
|
||||
fs::write(&filepath, story_with_ac_section(&["First", "Second"])).unwrap();
|
||||
|
||||
add_criterion_to_file(tmp.path(), "10_test", "Third").unwrap();
|
||||
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(contents.contains("- [ ] First\n"));
|
||||
assert!(contents.contains("- [ ] Second\n"));
|
||||
assert!(contents.contains("- [ ] Third\n"));
|
||||
// Third should come after Second
|
||||
let pos_second = contents.find("- [ ] Second").unwrap();
|
||||
let pos_third = contents.find("- [ ] Third").unwrap();
|
||||
assert!(pos_third > pos_second, "Third should appear after Second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_criterion_to_empty_section() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let filepath = current.join("11_test.md");
|
||||
let content = "---\nname: Test\n---\n\n## Acceptance Criteria\n\n## Out of Scope\n\n- N/A\n";
|
||||
fs::write(&filepath, content).unwrap();
|
||||
|
||||
add_criterion_to_file(tmp.path(), "11_test", "New AC").unwrap();
|
||||
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(contents.contains("- [ ] New AC\n"), "criterion should be present");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_criterion_missing_section_returns_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let filepath = current.join("12_test.md");
|
||||
fs::write(&filepath, "---\nname: Test\n---\n\nNo AC section here.\n").unwrap();
|
||||
|
||||
let result = add_criterion_to_file(tmp.path(), "12_test", "X");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Acceptance Criteria"));
|
||||
}
|
||||
|
||||
// ── update_story_in_file tests ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn update_story_replaces_user_story_section() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let filepath = current.join("20_test.md");
|
||||
let content = "---\nname: T\n---\n\n## User Story\n\nOld text\n\n## Acceptance Criteria\n\n- [ ] AC\n";
|
||||
fs::write(&filepath, content).unwrap();
|
||||
|
||||
update_story_in_file(tmp.path(), "20_test", Some("New user story text"), None, None).unwrap();
|
||||
|
||||
let result = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(result.contains("New user story text"), "new text should be present");
|
||||
assert!(!result.contains("Old text"), "old text should be replaced");
|
||||
assert!(result.contains("## Acceptance Criteria"), "other sections preserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_story_replaces_description_section() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let filepath = current.join("21_test.md");
|
||||
let content = "---\nname: T\n---\n\n## Description\n\nOld description\n\n## Acceptance Criteria\n\n- [ ] AC\n";
|
||||
fs::write(&filepath, content).unwrap();
|
||||
|
||||
update_story_in_file(tmp.path(), "21_test", None, Some("New description"), None).unwrap();
|
||||
|
||||
let result = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(result.contains("New description"), "new description present");
|
||||
assert!(!result.contains("Old description"), "old description replaced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_story_no_args_returns_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(current.join("22_test.md"), "---\nname: T\n---\n").unwrap();
|
||||
|
||||
let result = update_story_in_file(tmp.path(), "22_test", None, None, None);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("At least one"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_story_missing_section_returns_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(
|
||||
current.join("23_test.md"),
|
||||
"---\nname: T\n---\n\nNo sections here.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = update_story_in_file(tmp.path(), "23_test", Some("new text"), None, None);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("User Story"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_story_sets_agent_front_matter_field() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let filepath = current.join("24_test.md");
|
||||
fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap();
|
||||
|
||||
let mut fields = HashMap::new();
|
||||
fields.insert("agent".to_string(), "dev".to_string());
|
||||
update_story_in_file(tmp.path(), "24_test", None, None, Some(&fields)).unwrap();
|
||||
|
||||
let result = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(result.contains("agent: \"dev\""), "agent field should be set");
|
||||
assert!(result.contains("name: T"), "name field preserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_story_sets_arbitrary_front_matter_fields() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let filepath = current.join("25_test.md");
|
||||
fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap();
|
||||
|
||||
let mut fields = HashMap::new();
|
||||
fields.insert("qa".to_string(), "human".to_string());
|
||||
fields.insert("priority".to_string(), "high".to_string());
|
||||
update_story_in_file(tmp.path(), "25_test", None, None, Some(&fields)).unwrap();
|
||||
|
||||
let result = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(result.contains("qa: \"human\""), "qa field should be set");
|
||||
assert!(result.contains("priority: \"high\""), "priority field should be set");
|
||||
assert!(result.contains("name: T"), "name field preserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_story_front_matter_only_no_section_required() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
// File without a User Story section — front matter update should succeed
|
||||
let filepath = current.join("26_test.md");
|
||||
fs::write(&filepath, "---\nname: T\n---\n\nNo sections here.\n").unwrap();
|
||||
|
||||
let mut fields = HashMap::new();
|
||||
fields.insert("agent".to_string(), "dev".to_string());
|
||||
let result = update_story_in_file(tmp.path(), "26_test", None, None, Some(&fields));
|
||||
assert!(result.is_ok(), "front-matter-only update should not require body sections");
|
||||
|
||||
let contents = fs::read_to_string(&filepath).unwrap();
|
||||
assert!(contents.contains("agent: \"dev\""));
|
||||
}
|
||||
}
|
||||
307
server/src/http/workflow/test_results.rs
Normal file
307
server/src/http/workflow/test_results.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
use crate::io::story_metadata::write_coverage_baseline;
|
||||
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::{find_story_file, replace_or_append_section};
|
||||
|
||||
const TEST_RESULTS_MARKER: &str = "<!-- storkit-test-results:";
|
||||
|
||||
/// Write (or overwrite) the `## Test Results` section in a story file.
|
||||
///
|
||||
/// The section contains an HTML comment with JSON for machine parsing and a
|
||||
/// human-readable summary below it. If the section already exists it is
|
||||
/// replaced in-place. If the story file is not found, this is a no-op.
|
||||
pub fn write_test_results_to_story_file(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
results: &StoryTestResults,
|
||||
) -> Result<(), String> {
|
||||
let path = find_story_file(project_root, story_id)?;
|
||||
let contents =
|
||||
fs::read_to_string(&path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||
|
||||
let json = serde_json::to_string(results)
|
||||
.map_err(|e| format!("Failed to serialize test results: {e}"))?;
|
||||
|
||||
let section = build_test_results_section(&json, results);
|
||||
let new_contents = replace_or_append_section(&contents, "## Test Results", §ion);
|
||||
|
||||
fs::write(&path, &new_contents).map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read test results from the `## Test Results` section of a story file.
|
||||
///
|
||||
/// Returns `None` if the file is not found or contains no test results section.
|
||||
pub fn read_test_results_from_story_file(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
) -> Option<StoryTestResults> {
|
||||
let path = find_story_file(project_root, story_id).ok()?;
|
||||
let contents = fs::read_to_string(&path).ok()?;
|
||||
parse_test_results_from_contents(&contents)
|
||||
}
|
||||
|
||||
/// Write coverage baseline to the front matter of a story file.
|
||||
///
|
||||
/// If the story file is not found, this is a no-op (returns Ok).
|
||||
pub fn write_coverage_baseline_to_story_file(
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
coverage_pct: f64,
|
||||
) -> Result<(), String> {
|
||||
let path = match find_story_file(project_root, story_id) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return Ok(()), // No story file — skip silently
|
||||
};
|
||||
write_coverage_baseline(&path, coverage_pct)
|
||||
}
|
||||
|
||||
/// Build the `## Test Results` section text including JSON comment and human-readable summary.
|
||||
fn build_test_results_section(json: &str, results: &StoryTestResults) -> String {
|
||||
let mut s = String::from("## Test Results\n\n");
|
||||
s.push_str(&format!("{TEST_RESULTS_MARKER} {json} -->\n\n"));
|
||||
|
||||
// Unit tests
|
||||
let (unit_pass, unit_fail) = count_pass_fail(&results.unit);
|
||||
s.push_str(&format!(
|
||||
"### Unit Tests ({unit_pass} passed, {unit_fail} failed)\n\n"
|
||||
));
|
||||
if results.unit.is_empty() {
|
||||
s.push_str("*No unit tests recorded.*\n");
|
||||
} else {
|
||||
for t in &results.unit {
|
||||
s.push_str(&format_test_line(t));
|
||||
}
|
||||
}
|
||||
s.push('\n');
|
||||
|
||||
// Integration tests
|
||||
let (int_pass, int_fail) = count_pass_fail(&results.integration);
|
||||
s.push_str(&format!(
|
||||
"### Integration Tests ({int_pass} passed, {int_fail} failed)\n\n"
|
||||
));
|
||||
if results.integration.is_empty() {
|
||||
s.push_str("*No integration tests recorded.*\n");
|
||||
} else {
|
||||
for t in &results.integration {
|
||||
s.push_str(&format_test_line(t));
|
||||
}
|
||||
}
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
fn count_pass_fail(tests: &[TestCaseResult]) -> (usize, usize) {
|
||||
let pass = tests
|
||||
.iter()
|
||||
.filter(|t| t.status == TestStatus::Pass)
|
||||
.count();
|
||||
(pass, tests.len() - pass)
|
||||
}
|
||||
|
||||
fn format_test_line(t: &TestCaseResult) -> String {
|
||||
let icon = if t.status == TestStatus::Pass {
|
||||
"✅"
|
||||
} else {
|
||||
"❌"
|
||||
};
|
||||
match &t.details {
|
||||
Some(d) if !d.is_empty() => format!("- {icon} {} — {d}\n", t.name),
|
||||
_ => format!("- {icon} {}\n", t.name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `StoryTestResults` from the JSON embedded in the `## Test Results` section.
|
||||
fn parse_test_results_from_contents(contents: &str) -> Option<StoryTestResults> {
|
||||
for line in contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix(TEST_RESULTS_MARKER) {
|
||||
// rest looks like: ` {...} -->`
|
||||
if let Some(json_end) = rest.rfind("-->") {
|
||||
let json_str = rest[..json_end].trim();
|
||||
if let Ok(results) = serde_json::from_str::<StoryTestResults>(json_str) {
|
||||
return Some(results);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
|
||||
|
||||
fn make_results() -> StoryTestResults {
|
||||
StoryTestResults {
|
||||
unit: vec![
|
||||
TestCaseResult {
|
||||
name: "unit-pass".to_string(),
|
||||
status: TestStatus::Pass,
|
||||
details: None,
|
||||
},
|
||||
TestCaseResult {
|
||||
name: "unit-fail".to_string(),
|
||||
status: TestStatus::Fail,
|
||||
details: Some("assertion failed".to_string()),
|
||||
},
|
||||
],
|
||||
integration: vec![TestCaseResult {
|
||||
name: "int-pass".to_string(),
|
||||
status: TestStatus::Pass,
|
||||
details: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_read_test_results_roundtrip() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(
|
||||
current.join("1_story_test.md"),
|
||||
"---\nname: Test\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = make_results();
|
||||
write_test_results_to_story_file(tmp.path(), "1_story_test", &results).unwrap();
|
||||
|
||||
let read_back = read_test_results_from_story_file(tmp.path(), "1_story_test")
|
||||
.expect("should read back results");
|
||||
assert_eq!(read_back.unit.len(), 2);
|
||||
assert_eq!(read_back.integration.len(), 1);
|
||||
assert_eq!(read_back.unit[0].name, "unit-pass");
|
||||
assert_eq!(read_back.unit[1].status, TestStatus::Fail);
|
||||
assert_eq!(
|
||||
read_back.unit[1].details.as_deref(),
|
||||
Some("assertion failed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_test_results_creates_readable_section() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let story_path = current.join("2_story_check.md");
|
||||
fs::write(
|
||||
&story_path,
|
||||
"---\nname: Check\n---\n# Story\n\n## Acceptance Criteria\n\n- [ ] AC1\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = make_results();
|
||||
write_test_results_to_story_file(tmp.path(), "2_story_check", &results).unwrap();
|
||||
|
||||
let contents = fs::read_to_string(&story_path).unwrap();
|
||||
assert!(contents.contains("## Test Results"));
|
||||
assert!(contents.contains("✅ unit-pass"));
|
||||
assert!(contents.contains("❌ unit-fail"));
|
||||
assert!(contents.contains("assertion failed"));
|
||||
assert!(contents.contains("storkit-test-results:"));
|
||||
// Original content still present
|
||||
assert!(contents.contains("## Acceptance Criteria"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_test_results_overwrites_existing_section() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
let story_path = current.join("3_story_overwrite.md");
|
||||
fs::write(
|
||||
&story_path,
|
||||
"---\nname: Overwrite\n---\n# Story\n\n## Test Results\n\n<!-- storkit-test-results: {} -->\n\n### Unit Tests (0 passed, 0 failed)\n\n*No unit tests recorded.*\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = make_results();
|
||||
write_test_results_to_story_file(tmp.path(), "3_story_overwrite", &results).unwrap();
|
||||
|
||||
let contents = fs::read_to_string(&story_path).unwrap();
|
||||
assert!(contents.contains("✅ unit-pass"));
|
||||
// Should have only one ## Test Results header
|
||||
let count = contents.matches("## Test Results").count();
|
||||
assert_eq!(count, 1, "should have exactly one ## Test Results section");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_test_results_returns_none_when_no_section() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(
|
||||
current.join("4_story_empty.md"),
|
||||
"---\nname: Empty\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = read_test_results_from_story_file(tmp.path(), "4_story_empty");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_test_results_returns_none_for_unknown_story() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let result = read_test_results_from_story_file(tmp.path(), "99_story_unknown");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_test_results_finds_story_in_any_stage() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let qa_dir = tmp.path().join(".storkit/work/3_qa");
|
||||
fs::create_dir_all(&qa_dir).unwrap();
|
||||
fs::write(
|
||||
qa_dir.join("5_story_qa.md"),
|
||||
"---\nname: QA Story\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let results = StoryTestResults {
|
||||
unit: vec![TestCaseResult {
|
||||
name: "u1".to_string(),
|
||||
status: TestStatus::Pass,
|
||||
details: None,
|
||||
}],
|
||||
integration: vec![],
|
||||
};
|
||||
write_test_results_to_story_file(tmp.path(), "5_story_qa", &results).unwrap();
|
||||
|
||||
let read_back = read_test_results_from_story_file(tmp.path(), "5_story_qa").unwrap();
|
||||
assert_eq!(read_back.unit.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_coverage_baseline_to_story_file_updates_front_matter() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".storkit/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(
|
||||
current.join("6_story_cov.md"),
|
||||
"---\nname: Cov Story\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
write_coverage_baseline_to_story_file(tmp.path(), "6_story_cov", 75.4).unwrap();
|
||||
|
||||
let contents = fs::read_to_string(current.join("6_story_cov.md")).unwrap();
|
||||
assert!(
|
||||
contents.contains("coverage_baseline: 75.4%"),
|
||||
"got: {contents}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_coverage_baseline_to_story_file_silent_on_missing_story() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Story doesn't exist — should succeed silently
|
||||
let result = write_coverage_baseline_to_story_file(tmp.path(), "99_story_missing", 50.0);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
1401
server/src/http/ws.rs
Normal file
1401
server/src/http/ws.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user