Story 26: Establish TDD workflow and quality gates
Add workflow engine with acceptance gates, test recording, and review queue. Frontend displays gate status (blocked/ready), test summaries, failing badges, and warnings. Proceed action is disabled when gates are not met. Includes 13 unit tests (Vitest) and 9 E2E tests (Playwright) covering all five acceptance criteria. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ eventsource-stream = { workspace = true }
|
||||
rust-embed = { workspace = true }
|
||||
mime_guess = { workspace = true }
|
||||
homedir = { workspace = true }
|
||||
serde_yaml = "0.9"
|
||||
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::state::SessionState;
|
||||
use crate::store::JsonFileStore;
|
||||
use crate::workflow::WorkflowState;
|
||||
use poem::http::StatusCode;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -7,6 +8,7 @@ use std::sync::Arc;
|
||||
pub struct AppContext {
|
||||
pub state: Arc<SessionState>,
|
||||
pub store: Arc<JsonFileStore>,
|
||||
pub workflow: Arc<std::sync::Mutex<WorkflowState>>,
|
||||
}
|
||||
|
||||
pub type OpenApiResult<T> = poem::Result<T>;
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod context;
|
||||
pub mod health;
|
||||
pub mod io;
|
||||
pub mod model;
|
||||
pub mod workflow;
|
||||
|
||||
pub mod project;
|
||||
pub mod ws;
|
||||
@@ -19,6 +20,7 @@ use poem::{Route, get};
|
||||
use poem_openapi::OpenApiService;
|
||||
use project::ProjectApi;
|
||||
use std::sync::Arc;
|
||||
use workflow::WorkflowApi;
|
||||
|
||||
pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
|
||||
let ctx_arc = std::sync::Arc::new(ctx);
|
||||
@@ -36,7 +38,14 @@ pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
|
||||
.data(ctx_arc)
|
||||
}
|
||||
|
||||
type ApiTuple = (ProjectApi, ModelApi, AnthropicApi, IoApi, ChatApi);
|
||||
type ApiTuple = (
|
||||
ProjectApi,
|
||||
ModelApi,
|
||||
AnthropicApi,
|
||||
IoApi,
|
||||
ChatApi,
|
||||
WorkflowApi,
|
||||
);
|
||||
|
||||
type ApiService = OpenApiService<ApiTuple, ()>;
|
||||
|
||||
@@ -48,6 +57,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
||||
AnthropicApi::new(ctx.clone()),
|
||||
IoApi { ctx: ctx.clone() },
|
||||
ChatApi { ctx: ctx.clone() },
|
||||
WorkflowApi { ctx: ctx.clone() },
|
||||
);
|
||||
|
||||
let api_service =
|
||||
@@ -58,7 +68,8 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
||||
ModelApi { ctx: ctx.clone() },
|
||||
AnthropicApi::new(ctx.clone()),
|
||||
IoApi { ctx: ctx.clone() },
|
||||
ChatApi { ctx },
|
||||
ChatApi { ctx: ctx.clone() },
|
||||
WorkflowApi { ctx },
|
||||
);
|
||||
|
||||
let docs_service =
|
||||
|
||||
319
server/src/http/workflow.rs
Normal file
319
server/src/http/workflow.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use crate::io::story_metadata::{StoryMetadata, parse_front_matter};
|
||||
use crate::workflow::{
|
||||
StoryTestResults, TestCaseResult, TestStatus, evaluate_acceptance, summarize_results,
|
||||
};
|
||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Tags)]
|
||||
enum WorkflowTags {
|
||||
Workflow,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Object)]
|
||||
struct TestCasePayload {
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Object)]
|
||||
struct RecordTestsPayload {
|
||||
pub story_id: String,
|
||||
pub unit: Vec<TestCasePayload>,
|
||||
pub integration: Vec<TestCasePayload>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Object)]
|
||||
struct AcceptanceRequest {
|
||||
pub story_id: String,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct TestRunSummaryResponse {
|
||||
pub total: usize,
|
||||
pub passed: usize,
|
||||
pub failed: usize,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct AcceptanceResponse {
|
||||
pub can_accept: bool,
|
||||
pub reasons: Vec<String>,
|
||||
pub warning: Option<String>,
|
||||
pub summary: TestRunSummaryResponse,
|
||||
pub missing_categories: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct ReviewStory {
|
||||
pub story_id: String,
|
||||
pub can_accept: bool,
|
||||
pub reasons: Vec<String>,
|
||||
pub warning: Option<String>,
|
||||
pub summary: TestRunSummaryResponse,
|
||||
pub missing_categories: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct ReviewListResponse {
|
||||
pub stories: Vec<ReviewStory>,
|
||||
}
|
||||
|
||||
fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMetadata)>, String> {
|
||||
let root = ctx.state.get_project_root()?;
|
||||
let current_dir = root.join(".story_kit").join("stories").join("current");
|
||||
|
||||
if !current_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut stories = Vec::new();
|
||||
for entry in fs::read_dir(¤t_dir)
|
||||
.map_err(|e| format!("Failed to read current stories directory: {e}"))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Failed to read current story 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 metadata = parse_front_matter(&contents)
|
||||
.map_err(|e| format!("Failed to parse front matter for {story_id}: {e:?}"))?;
|
||||
stories.push((story_id, metadata));
|
||||
}
|
||||
|
||||
Ok(stories)
|
||||
}
|
||||
|
||||
fn to_review_story(story_id: &str, results: &StoryTestResults) -> ReviewStory {
|
||||
let decision = evaluate_acceptance(results);
|
||||
let summary = summarize_results(results);
|
||||
|
||||
let mut missing_categories = Vec::new();
|
||||
let mut reasons = decision.reasons;
|
||||
|
||||
if results.unit.is_empty() {
|
||||
missing_categories.push("unit".to_string());
|
||||
reasons.push("Missing unit test results.".to_string());
|
||||
}
|
||||
if results.integration.is_empty() {
|
||||
missing_categories.push("integration".to_string());
|
||||
reasons.push("Missing integration test results.".to_string());
|
||||
}
|
||||
|
||||
let can_accept = decision.can_accept && missing_categories.is_empty();
|
||||
|
||||
ReviewStory {
|
||||
story_id: story_id.to_string(),
|
||||
can_accept,
|
||||
reasons,
|
||||
warning: decision.warning,
|
||||
summary: TestRunSummaryResponse {
|
||||
total: summary.total,
|
||||
passed: summary.passed,
|
||||
failed: summary.failed,
|
||||
},
|
||||
missing_categories,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WorkflowApi {
|
||||
pub ctx: Arc<AppContext>,
|
||||
}
|
||||
|
||||
#[OpenApi(tag = "WorkflowTags::Workflow")]
|
||||
impl WorkflowApi {
|
||||
/// Record test results for a story (unit + integration).
|
||||
#[oai(path = "/workflow/tests/record", method = "post")]
|
||||
async fn record_tests(&self, payload: Json<RecordTestsPayload>) -> OpenApiResult<Json<bool>> {
|
||||
let unit = payload
|
||||
.0
|
||||
.unit
|
||||
.into_iter()
|
||||
.map(to_test_case)
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
.map_err(bad_request)?;
|
||||
let integration = payload
|
||||
.0
|
||||
.integration
|
||||
.into_iter()
|
||||
.map(to_test_case)
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let mut workflow = self
|
||||
.ctx
|
||||
.workflow
|
||||
.lock()
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
workflow
|
||||
.record_test_results_validated(payload.0.story_id, unit, integration)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
/// Evaluate acceptance readiness for a story.
|
||||
#[oai(path = "/workflow/acceptance", method = "post")]
|
||||
async fn acceptance(
|
||||
&self,
|
||||
payload: Json<AcceptanceRequest>,
|
||||
) -> OpenApiResult<Json<AcceptanceResponse>> {
|
||||
let results = {
|
||||
let workflow = self
|
||||
.ctx
|
||||
.workflow
|
||||
.lock()
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
workflow
|
||||
.results
|
||||
.get(&payload.0.story_id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let decision = evaluate_acceptance(&results);
|
||||
let summary = summarize_results(&results);
|
||||
|
||||
let mut missing_categories = Vec::new();
|
||||
let mut reasons = decision.reasons;
|
||||
|
||||
if results.unit.is_empty() {
|
||||
missing_categories.push("unit".to_string());
|
||||
reasons.push("Missing unit test results.".to_string());
|
||||
}
|
||||
if results.integration.is_empty() {
|
||||
missing_categories.push("integration".to_string());
|
||||
reasons.push("Missing integration test results.".to_string());
|
||||
}
|
||||
|
||||
let can_accept = decision.can_accept && missing_categories.is_empty();
|
||||
|
||||
Ok(Json(AcceptanceResponse {
|
||||
can_accept,
|
||||
reasons,
|
||||
warning: decision.warning,
|
||||
summary: TestRunSummaryResponse {
|
||||
total: summary.total,
|
||||
passed: summary.passed,
|
||||
failed: summary.failed,
|
||||
},
|
||||
missing_categories,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List stories that are ready for human review.
|
||||
#[oai(path = "/workflow/review", method = "get")]
|
||||
async fn review_queue(&self) -> OpenApiResult<Json<ReviewListResponse>> {
|
||||
let stories = {
|
||||
let workflow = self
|
||||
.ctx
|
||||
.workflow
|
||||
.lock()
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
workflow
|
||||
.results
|
||||
.iter()
|
||||
.map(|(story_id, results)| to_review_story(story_id, results))
|
||||
.filter(|story| story.can_accept)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
Ok(Json(ReviewListResponse { stories }))
|
||||
}
|
||||
|
||||
/// List stories in the review queue, including blocked items and current stories.
|
||||
#[oai(path = "/workflow/review/all", method = "get")]
|
||||
async fn review_queue_all(&self) -> OpenApiResult<Json<ReviewListResponse>> {
|
||||
let current_stories =
|
||||
load_current_story_metadata(self.ctx.as_ref()).map_err(bad_request)?;
|
||||
let stories = {
|
||||
let mut workflow = self
|
||||
.ctx
|
||||
.workflow
|
||||
.lock()
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
|
||||
if !current_stories.is_empty() {
|
||||
workflow.load_story_metadata(current_stories);
|
||||
}
|
||||
|
||||
let mut story_ids = BTreeSet::new();
|
||||
|
||||
for story_id in workflow.results.keys() {
|
||||
story_ids.insert(story_id.clone());
|
||||
}
|
||||
for story_id in workflow.stories.keys() {
|
||||
story_ids.insert(story_id.clone());
|
||||
}
|
||||
|
||||
story_ids
|
||||
.into_iter()
|
||||
.map(|story_id| {
|
||||
let results = workflow.results.get(&story_id).cloned().unwrap_or_default();
|
||||
to_review_story(&story_id, &results)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
Ok(Json(ReviewListResponse { stories }))
|
||||
}
|
||||
|
||||
/// Ensure a story can be accepted; returns an error when gates fail.
|
||||
#[oai(path = "/workflow/acceptance/ensure", method = "post")]
|
||||
async fn ensure_acceptance(
|
||||
&self,
|
||||
payload: Json<AcceptanceRequest>,
|
||||
) -> OpenApiResult<Json<bool>> {
|
||||
let response = self.acceptance(payload).await?.0;
|
||||
if response.can_accept {
|
||||
return Ok(Json(true));
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
if !response.reasons.is_empty() {
|
||||
parts.push(response.reasons.join("; "));
|
||||
}
|
||||
if let Some(warning) = response.warning {
|
||||
parts.push(warning);
|
||||
}
|
||||
|
||||
let message = if parts.is_empty() {
|
||||
"Acceptance is blocked.".to_string()
|
||||
} else {
|
||||
format!("Acceptance is blocked: {}", parts.join("; "))
|
||||
};
|
||||
|
||||
Err(bad_request(message))
|
||||
}
|
||||
}
|
||||
|
||||
fn to_test_case(input: TestCasePayload) -> Result<TestCaseResult, String> {
|
||||
let status = parse_test_status(&input.status)?;
|
||||
Ok(TestCaseResult {
|
||||
name: input.name,
|
||||
status,
|
||||
details: input.details,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_test_status(value: &str) -> Result<TestStatus, String> {
|
||||
match value {
|
||||
"pass" => Ok(TestStatus::Pass),
|
||||
"fail" => Ok(TestStatus::Fail),
|
||||
other => Err(format!(
|
||||
"Invalid test status '{other}'. Use 'pass' or 'fail'."
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::io::story_metadata::{TestPlanStatus, parse_front_matter};
|
||||
use crate::state::SessionState;
|
||||
use crate::store::StoreOps;
|
||||
use serde::Serialize;
|
||||
@@ -276,6 +277,7 @@ This project is a standalone Rust **web server binary** that serves a Vite/React
|
||||
* **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints.
|
||||
* **Frontend:** TypeScript + React
|
||||
* **Build Tool:** Vite
|
||||
* **Package Manager:** pnpm (required)
|
||||
* **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules)
|
||||
* **State Management:** React Context / Hooks
|
||||
* **Chat UI:** Rendered Markdown with syntax highlighting.
|
||||
@@ -394,6 +396,34 @@ fn resolve_path_impl(root: PathBuf, relative_path: &str) -> Result<PathBuf, Stri
|
||||
Ok(root.join(relative_path))
|
||||
}
|
||||
|
||||
fn is_story_kit_path(path: &str) -> bool {
|
||||
path == ".story_kit" || path.starts_with(".story_kit/")
|
||||
}
|
||||
|
||||
async fn ensure_test_plan_approved(root: PathBuf) -> Result<(), String> {
|
||||
let approved = tokio::task::spawn_blocking(move || {
|
||||
let story_path = root
|
||||
.join(".story_kit")
|
||||
.join("stories")
|
||||
.join("current")
|
||||
.join("26_establish_tdd_workflow_and_gates.md");
|
||||
let contents = fs::read_to_string(&story_path)
|
||||
.map_err(|e| format!("Failed to read story file for test plan approval: {e}"))?;
|
||||
let metadata = parse_front_matter(&contents)
|
||||
.map_err(|e| format!("Failed to parse story front matter: {e:?}"))?;
|
||||
|
||||
Ok::<bool, String>(matches!(metadata.test_plan, Some(TestPlanStatus::Approved)))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))??;
|
||||
|
||||
if approved {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Test plan is not approved for the current story.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a relative path against the active project root.
|
||||
/// Returns error if no project is open or if path attempts traversal (..).
|
||||
fn resolve_path(state: &SessionState, relative_path: &str) -> Result<PathBuf, String> {
|
||||
@@ -597,7 +627,11 @@ async fn write_file_impl(full_path: PathBuf, content: String) -> Result<(), Stri
|
||||
}
|
||||
|
||||
pub async fn write_file(path: String, content: String, state: &SessionState) -> Result<(), String> {
|
||||
let full_path = resolve_path(state, &path)?;
|
||||
let root = state.get_project_root()?;
|
||||
if !is_story_kit_path(&path) {
|
||||
ensure_test_plan_approved(root.clone()).await?;
|
||||
}
|
||||
let full_path = resolve_path_impl(root, &path)?;
|
||||
write_file_impl(full_path, content).await
|
||||
}
|
||||
|
||||
@@ -658,3 +692,27 @@ pub async fn create_directory_absolute(path: String) -> Result<bool, String> {
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {}", e))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_file_requires_approved_test_plan() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let state = SessionState::default();
|
||||
|
||||
{
|
||||
let mut root = state.project_root.lock().expect("lock project root");
|
||||
*root = Some(dir.path().to_path_buf());
|
||||
}
|
||||
|
||||
let result = write_file("notes.txt".to_string(), "hello".to_string(), &state).await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"expected write to be blocked when test plan is not approved"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod fs;
|
||||
pub mod search;
|
||||
pub mod shell;
|
||||
pub mod story_metadata;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::io::story_metadata::{TestPlanStatus, parse_front_matter};
|
||||
use crate::state::SessionState;
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
@@ -8,6 +10,30 @@ fn get_project_root(state: &SessionState) -> Result<PathBuf, String> {
|
||||
state.get_project_root()
|
||||
}
|
||||
|
||||
async fn ensure_test_plan_approved(root: PathBuf) -> Result<(), String> {
|
||||
let approved = tokio::task::spawn_blocking(move || {
|
||||
let story_path = root
|
||||
.join(".story_kit")
|
||||
.join("stories")
|
||||
.join("current")
|
||||
.join("26_establish_tdd_workflow_and_gates.md");
|
||||
let contents = fs::read_to_string(&story_path)
|
||||
.map_err(|e| format!("Failed to read story file for test plan approval: {e}"))?;
|
||||
let metadata = parse_front_matter(&contents)
|
||||
.map_err(|e| format!("Failed to parse story front matter: {e:?}"))?;
|
||||
|
||||
Ok::<bool, String>(matches!(metadata.test_plan, Some(TestPlanStatus::Approved)))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))??;
|
||||
|
||||
if approved {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Test plan is not approved for the current story.".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, poem_openapi::Object)]
|
||||
pub struct CommandOutput {
|
||||
pub stdout: String,
|
||||
@@ -54,5 +80,30 @@ pub async fn exec_shell(
|
||||
state: &SessionState,
|
||||
) -> Result<CommandOutput, String> {
|
||||
let root = get_project_root(state)?;
|
||||
ensure_test_plan_approved(root.clone()).await?;
|
||||
exec_shell_impl(command, args, root).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_shell_requires_approved_test_plan() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let state = SessionState::default();
|
||||
|
||||
{
|
||||
let mut root = state.project_root.lock().expect("lock project root");
|
||||
*root = Some(dir.path().to_path_buf());
|
||||
}
|
||||
|
||||
let result = exec_shell("ls".to_string(), Vec::new(), &state).await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"expected shell execution to be blocked when test plan is not approved"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
111
server/src/io/story_metadata.rs
Normal file
111
server/src/io/story_metadata.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TestPlanStatus {
|
||||
Approved,
|
||||
WaitingForApproval,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct StoryMetadata {
|
||||
pub name: Option<String>,
|
||||
pub test_plan: Option<TestPlanStatus>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum StoryMetaError {
|
||||
MissingFrontMatter,
|
||||
InvalidFrontMatter(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FrontMatter {
|
||||
name: Option<String>,
|
||||
test_plan: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
|
||||
let mut lines = contents.lines();
|
||||
|
||||
let first = lines.next().unwrap_or_default().trim();
|
||||
if first != "---" {
|
||||
return Err(StoryMetaError::MissingFrontMatter);
|
||||
}
|
||||
|
||||
let mut front_lines = Vec::new();
|
||||
for line in &mut lines {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "---" {
|
||||
let raw = front_lines.join("\n");
|
||||
let front: FrontMatter = serde_yaml::from_str(&raw)
|
||||
.map_err(|e| StoryMetaError::InvalidFrontMatter(e.to_string()))?;
|
||||
return Ok(build_metadata(front));
|
||||
}
|
||||
front_lines.push(line);
|
||||
}
|
||||
|
||||
Err(StoryMetaError::InvalidFrontMatter(
|
||||
"Missing closing front matter delimiter".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_metadata(front: FrontMatter) -> StoryMetadata {
|
||||
let test_plan = front.test_plan.as_deref().map(parse_test_plan_status);
|
||||
|
||||
StoryMetadata {
|
||||
name: front.name,
|
||||
test_plan,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_test_plan_status(value: &str) -> TestPlanStatus {
|
||||
match value {
|
||||
"approved" => TestPlanStatus::Approved,
|
||||
"waiting_for_approval" => TestPlanStatus::WaitingForApproval,
|
||||
other => TestPlanStatus::Unknown(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_front_matter_metadata() {
|
||||
let input = r#"---
|
||||
name: Establish the TDD Workflow and Gates
|
||||
test_plan: approved
|
||||
workflow: tdd
|
||||
---
|
||||
# Story 26
|
||||
"#;
|
||||
|
||||
let meta = parse_front_matter(input).expect("front matter");
|
||||
assert_eq!(
|
||||
meta,
|
||||
StoryMetadata {
|
||||
name: Some("Establish the TDD Workflow and Gates".to_string()),
|
||||
test_plan: Some(TestPlanStatus::Approved),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_front_matter() {
|
||||
let input = "# Story 26\n";
|
||||
assert_eq!(
|
||||
parse_front_matter(input),
|
||||
Err(StoryMetaError::MissingFrontMatter)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unclosed_front_matter() {
|
||||
let input = "---\nname: Test\n";
|
||||
assert!(matches!(
|
||||
parse_front_matter(input),
|
||||
Err(StoryMetaError::InvalidFrontMatter(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ mod io;
|
||||
mod llm;
|
||||
mod state;
|
||||
mod store;
|
||||
mod workflow;
|
||||
|
||||
use crate::http::build_routes;
|
||||
use crate::http::context::AppContext;
|
||||
use crate::state::SessionState;
|
||||
use crate::store::JsonFileStore;
|
||||
use crate::workflow::WorkflowState;
|
||||
use poem::Server;
|
||||
use poem::listener::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
@@ -19,10 +21,12 @@ async fn main() -> Result<(), std::io::Error> {
|
||||
let store = Arc::new(
|
||||
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
|
||||
);
|
||||
let workflow = Arc::new(std::sync::Mutex::new(WorkflowState::default()));
|
||||
|
||||
let ctx = AppContext {
|
||||
state: app_state,
|
||||
store,
|
||||
workflow,
|
||||
};
|
||||
|
||||
let app = build_routes(ctx);
|
||||
|
||||
242
server/src/workflow.rs
Normal file
242
server/src/workflow.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
//! Workflow module: story gating and test result tracking.
|
||||
//!
|
||||
//! This module provides the in-memory primitives for:
|
||||
//! - reading story metadata (front matter) for gating decisions
|
||||
//! - tracking test run results
|
||||
//! - evaluating acceptance readiness
|
||||
//!
|
||||
//! NOTE: This is a naive, local-only implementation that will be
|
||||
//! refactored later into orchestration-aware components.
|
||||
|
||||
use crate::io::story_metadata::{StoryMetadata, TestPlanStatus};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TestStatus {
|
||||
Pass,
|
||||
Fail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TestCaseResult {
|
||||
pub name: String,
|
||||
pub status: TestStatus,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TestRunSummary {
|
||||
pub total: usize,
|
||||
pub passed: usize,
|
||||
pub failed: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AcceptanceDecision {
|
||||
pub can_accept: bool,
|
||||
pub reasons: Vec<String>,
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StoryTestResults {
|
||||
pub unit: Vec<TestCaseResult>,
|
||||
pub integration: Vec<TestCaseResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[allow(dead_code)]
|
||||
pub struct WorkflowState {
|
||||
pub stories: HashMap<String, StoryMetadata>,
|
||||
pub results: HashMap<String, StoryTestResults>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl WorkflowState {
|
||||
pub fn upsert_story(&mut self, story_id: String, metadata: StoryMetadata) {
|
||||
self.stories.insert(story_id, metadata);
|
||||
}
|
||||
|
||||
pub fn load_story_metadata(&mut self, stories: Vec<(String, StoryMetadata)>) {
|
||||
for (story_id, metadata) in stories {
|
||||
self.stories.insert(story_id, metadata);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_story_metadata(&mut self, story_id: String, metadata: StoryMetadata) -> bool {
|
||||
match self.stories.get(&story_id) {
|
||||
Some(existing) if existing == &metadata => false,
|
||||
_ => {
|
||||
self.stories.insert(story_id, metadata);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_test_results(
|
||||
&mut self,
|
||||
story_id: String,
|
||||
unit: Vec<TestCaseResult>,
|
||||
integration: Vec<TestCaseResult>,
|
||||
) {
|
||||
let _ = self.record_test_results_validated(story_id, unit, integration);
|
||||
}
|
||||
|
||||
pub fn record_test_results_validated(
|
||||
&mut self,
|
||||
story_id: String,
|
||||
unit: Vec<TestCaseResult>,
|
||||
integration: Vec<TestCaseResult>,
|
||||
) -> Result<(), String> {
|
||||
let failures = unit
|
||||
.iter()
|
||||
.chain(integration.iter())
|
||||
.filter(|test| test.status == TestStatus::Fail)
|
||||
.count();
|
||||
|
||||
if failures > 1 {
|
||||
return Err(format!(
|
||||
"Multiple failing tests detected ({failures}); register failures one at a time."
|
||||
));
|
||||
}
|
||||
|
||||
self.results
|
||||
.insert(story_id, StoryTestResults { unit, integration });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn can_start_implementation(metadata: &StoryMetadata) -> Result<(), String> {
|
||||
match metadata.test_plan {
|
||||
Some(TestPlanStatus::Approved) => Ok(()),
|
||||
Some(TestPlanStatus::WaitingForApproval) => {
|
||||
Err("Test plan is waiting for approval; implementation is blocked.".to_string())
|
||||
}
|
||||
Some(TestPlanStatus::Unknown(ref value)) => Err(format!(
|
||||
"Test plan state is unknown ({value}); implementation is blocked."
|
||||
)),
|
||||
None => Err("Missing test plan status; implementation is blocked.".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn summarize_results(results: &StoryTestResults) -> TestRunSummary {
|
||||
let mut total = 0;
|
||||
let mut passed = 0;
|
||||
let mut failed = 0;
|
||||
|
||||
for test in results.unit.iter().chain(results.integration.iter()) {
|
||||
total += 1;
|
||||
match test.status {
|
||||
TestStatus::Pass => passed += 1,
|
||||
TestStatus::Fail => failed += 1,
|
||||
}
|
||||
}
|
||||
|
||||
TestRunSummary {
|
||||
total,
|
||||
passed,
|
||||
failed,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn evaluate_acceptance(results: &StoryTestResults) -> AcceptanceDecision {
|
||||
let summary = summarize_results(results);
|
||||
|
||||
if summary.failed == 0 && summary.total > 0 {
|
||||
return AcceptanceDecision {
|
||||
can_accept: true,
|
||||
reasons: Vec::new(),
|
||||
warning: None,
|
||||
};
|
||||
}
|
||||
|
||||
let mut reasons = Vec::new();
|
||||
if summary.total == 0 {
|
||||
reasons.push("No test results recorded for the story.".to_string());
|
||||
}
|
||||
if summary.failed > 0 {
|
||||
reasons.push(format!(
|
||||
"{} test(s) are failing; acceptance is blocked.",
|
||||
summary.failed
|
||||
));
|
||||
}
|
||||
|
||||
let warning = if summary.failed > 1 {
|
||||
Some(format!(
|
||||
"Multiple tests are failing ({} failures).",
|
||||
summary.failed
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
AcceptanceDecision {
|
||||
can_accept: false,
|
||||
reasons,
|
||||
warning,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn warns_when_multiple_tests_fail() {
|
||||
let results = StoryTestResults {
|
||||
unit: vec![
|
||||
TestCaseResult {
|
||||
name: "unit-1".to_string(),
|
||||
status: TestStatus::Fail,
|
||||
details: None,
|
||||
},
|
||||
TestCaseResult {
|
||||
name: "unit-2".to_string(),
|
||||
status: TestStatus::Fail,
|
||||
details: None,
|
||||
},
|
||||
],
|
||||
integration: vec![TestCaseResult {
|
||||
name: "integration-1".to_string(),
|
||||
status: TestStatus::Pass,
|
||||
details: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let decision = evaluate_acceptance(&results);
|
||||
|
||||
assert!(!decision.can_accept);
|
||||
assert_eq!(
|
||||
decision.warning,
|
||||
Some("Multiple tests are failing (2 failures).".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_recording_multiple_failures() {
|
||||
let mut state = WorkflowState::default();
|
||||
let unit = vec![
|
||||
TestCaseResult {
|
||||
name: "unit-1".to_string(),
|
||||
status: TestStatus::Fail,
|
||||
details: None,
|
||||
},
|
||||
TestCaseResult {
|
||||
name: "unit-2".to_string(),
|
||||
status: TestStatus::Fail,
|
||||
details: None,
|
||||
},
|
||||
];
|
||||
let integration = vec![TestCaseResult {
|
||||
name: "integration-1".to_string(),
|
||||
status: TestStatus::Pass,
|
||||
details: None,
|
||||
}];
|
||||
|
||||
let result = state.record_test_results_validated("story-26".to_string(), unit, integration);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user