RFC 0014: Workflow Enforcement Parity (#1)
Co-authored-by: Eric Garcia <eric.garcia@gmail.com> Co-committed-by: Eric Garcia <eric.garcia@gmail.com>
This commit is contained in:
parent
1afdd05ea6
commit
a07737f3dc
5 changed files with 132 additions and 32 deletions
|
|
@ -35,4 +35,4 @@ pub use repo::{detect_blue, BlueHome, RepoError, WorktreeInfo};
|
||||||
pub use state::{ItemType, ProjectState, StateError, StatusSummary, WorkItem};
|
pub use state::{ItemType, ProjectState, StateError, StatusSummary, WorkItem};
|
||||||
pub use store::{DocType, Document, DocumentStore, FileIndexEntry, IndexSearchResult, IndexStatus, LinkType, Reminder, ReminderStatus, SearchResult, Session, SessionType, StagingLock, StagingLockQueueEntry, StagingLockResult, StoreError, SymbolIndexEntry, Task as StoreTask, TaskProgress, Worktree, INDEX_PROMPT_VERSION};
|
pub use store::{DocType, Document, DocumentStore, FileIndexEntry, IndexSearchResult, IndexStatus, LinkType, Reminder, ReminderStatus, SearchResult, Session, SessionType, StagingLock, StagingLockQueueEntry, StagingLockResult, StoreError, SymbolIndexEntry, Task as StoreTask, TaskProgress, Worktree, INDEX_PROMPT_VERSION};
|
||||||
pub use voice::*;
|
pub use voice::*;
|
||||||
pub use workflow::{PrdStatus, RfcStatus, SpikeOutcome as WorkflowSpikeOutcome, SpikeStatus, WorkflowError};
|
pub use workflow::{PrdStatus, RfcStatus, SpikeOutcome as WorkflowSpikeOutcome, SpikeStatus, WorkflowError, validate_rfc_transition};
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ pub enum ServerError {
|
||||||
|
|
||||||
#[error("Not found: {0}")]
|
#[error("Not found: {0}")]
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("Workflow error: {0}")]
|
||||||
|
Workflow(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerError {
|
impl ServerError {
|
||||||
|
|
@ -41,6 +44,7 @@ impl ServerError {
|
||||||
ServerError::StateLoadFailed(_) => -32001,
|
ServerError::StateLoadFailed(_) => -32001,
|
||||||
ServerError::CommandFailed(_) => -32002,
|
ServerError::CommandFailed(_) => -32002,
|
||||||
ServerError::NotFound(_) => -32003,
|
ServerError::NotFound(_) => -32003,
|
||||||
|
ServerError::Workflow(_) => -32004,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
|
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use blue_core::{CreatePrOpts, MergeStrategy, ProjectState, create_forge_cached, detect_forge_type_cached, parse_git_url};
|
use blue_core::{CreatePrOpts, DocType, MergeStrategy, ProjectState, create_forge_cached, detect_forge_type_cached, parse_git_url};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::error::ServerError;
|
use crate::error::ServerError;
|
||||||
|
|
@ -41,6 +41,36 @@ pub enum TaskCategory {
|
||||||
pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
|
pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
|
||||||
let rfc = args.get("rfc").and_then(|v| v.as_str());
|
let rfc = args.get("rfc").and_then(|v| v.as_str());
|
||||||
|
|
||||||
|
// If RFC is provided, validate workflow state (RFC 0014)
|
||||||
|
if let Some(rfc_title) = rfc {
|
||||||
|
// Check RFC exists and has worktree
|
||||||
|
if let Ok(doc) = state.store.find_document(DocType::Rfc, rfc_title) {
|
||||||
|
// Warn if RFC isn't implemented yet
|
||||||
|
if doc.status != "implemented" && doc.status != "in-progress" {
|
||||||
|
return Ok(json!({
|
||||||
|
"status": "error",
|
||||||
|
"message": blue_core::voice::error(
|
||||||
|
&format!("RFC '{}' is {} - complete implementation first", rfc_title, doc.status),
|
||||||
|
"Use blue_rfc_complete after finishing work"
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check worktree exists
|
||||||
|
if let Some(doc_id) = doc.id {
|
||||||
|
if state.store.get_worktree(doc_id).ok().flatten().is_none() {
|
||||||
|
return Ok(json!({
|
||||||
|
"status": "warning",
|
||||||
|
"message": blue_core::voice::error(
|
||||||
|
"No worktree for this RFC",
|
||||||
|
"PRs usually come from worktrees. Proceed with caution."
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If RFC is provided, format title as "RFC NNNN: Title Case Name"
|
// If RFC is provided, format title as "RFC NNNN: Title Case Name"
|
||||||
let title = if let Some(rfc_title) = rfc {
|
let title = if let Some(rfc_title) = rfc {
|
||||||
let (stripped, number) = strip_rfc_number_prefix(rfc_title);
|
let (stripped, number) = strip_rfc_number_prefix(rfc_title);
|
||||||
|
|
|
||||||
|
|
@ -119,17 +119,37 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check RFC has a plan (RFC 0014: plan enforcement)
|
||||||
|
let doc_id = doc.id.ok_or(ServerError::InvalidParams)?;
|
||||||
|
let tasks = state
|
||||||
|
.store
|
||||||
|
.get_tasks(doc_id)
|
||||||
|
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
if tasks.is_empty() {
|
||||||
|
return Ok(json!({
|
||||||
|
"status": "error",
|
||||||
|
"message": blue_core::voice::error(
|
||||||
|
&format!("RFC '{}' needs a plan before creating worktree", title),
|
||||||
|
"Create a plan first with blue_rfc_plan"
|
||||||
|
),
|
||||||
|
"next_action": {
|
||||||
|
"tool": "blue_rfc_plan",
|
||||||
|
"args": { "title": title },
|
||||||
|
"hint": "Create implementation tasks before starting work"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Check if worktree already exists
|
// Check if worktree already exists
|
||||||
if let Some(id) = doc.id {
|
if let Ok(Some(_existing)) = state.store.get_worktree(doc_id) {
|
||||||
if let Ok(Some(_existing)) = state.store.get_worktree(id) {
|
return Ok(json!({
|
||||||
return Ok(json!({
|
"status": "error",
|
||||||
"status": "error",
|
"message": blue_core::voice::error(
|
||||||
"message": blue_core::voice::error(
|
&format!("Worktree for '{}' already exists", title),
|
||||||
&format!("Worktree for '{}' already exists", title),
|
"Use blue_worktree_list to see active worktrees"
|
||||||
"Use blue_worktree_list to see active worktrees"
|
)
|
||||||
)
|
}));
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create branch name and worktree path (RFC 0007: strip number prefix)
|
// Create branch name and worktree path (RFC 0007: strip number prefix)
|
||||||
|
|
@ -144,16 +164,14 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
|
||||||
match blue_core::repo::create_worktree(&repo, &branch_name, &worktree_path) {
|
match blue_core::repo::create_worktree(&repo, &branch_name, &worktree_path) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
// Record in store
|
// Record in store
|
||||||
if let Some(doc_id) = doc.id {
|
let wt = StoreWorktree {
|
||||||
let wt = StoreWorktree {
|
id: None,
|
||||||
id: None,
|
document_id: doc_id,
|
||||||
document_id: doc_id,
|
branch_name: branch_name.clone(),
|
||||||
branch_name: branch_name.clone(),
|
worktree_path: worktree_path.display().to_string(),
|
||||||
worktree_path: worktree_path.display().to_string(),
|
created_at: None,
|
||||||
created_at: None,
|
};
|
||||||
};
|
let _ = state.store.add_worktree(&wt);
|
||||||
let _ = state.store.add_worktree(&wt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update RFC status to in-progress if accepted
|
// Update RFC status to in-progress if accepted
|
||||||
if doc.status == "accepted" {
|
if doc.status == "accepted" {
|
||||||
|
|
@ -479,4 +497,23 @@ mod tests {
|
||||||
assert_eq!(stripped, "0007feature");
|
assert_eq!(stripped, "0007feature");
|
||||||
assert_eq!(number, None);
|
assert_eq!(number, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_worktree_requires_plan() {
|
||||||
|
use blue_core::{Document, ProjectState};
|
||||||
|
|
||||||
|
let state = ProjectState::for_test();
|
||||||
|
|
||||||
|
// Create an accepted RFC without a plan
|
||||||
|
let mut doc = Document::new(DocType::Rfc, "test-rfc", "accepted");
|
||||||
|
doc.number = Some(1);
|
||||||
|
state.store.add_document(&doc).unwrap();
|
||||||
|
|
||||||
|
// Try to create worktree - should fail due to missing plan
|
||||||
|
let args = serde_json::json!({ "title": "test-rfc" });
|
||||||
|
let result = handle_create(&state, &args).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result["status"], "error");
|
||||||
|
assert!(result["message"].as_str().unwrap().contains("needs a plan"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
use blue_core::{detect_blue, DocType, Document, ProjectState, Rfc};
|
use blue_core::{detect_blue, DocType, Document, ProjectState, Rfc, RfcStatus, validate_rfc_transition};
|
||||||
|
|
||||||
use crate::error::ServerError;
|
use crate::error::ServerError;
|
||||||
|
|
||||||
|
|
@ -2398,39 +2398,68 @@ impl BlueServer {
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or(ServerError::InvalidParams)?;
|
.ok_or(ServerError::InvalidParams)?;
|
||||||
|
|
||||||
let status = args
|
let status_str = args
|
||||||
.get("status")
|
.get("status")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or(ServerError::InvalidParams)?;
|
.ok_or(ServerError::InvalidParams)?;
|
||||||
|
|
||||||
let state = self.ensure_state()?;
|
let state = self.ensure_state()?;
|
||||||
|
|
||||||
// Find the document to get its file path
|
// Find the document to get its file path and current status
|
||||||
let doc = state.store.find_document(DocType::Rfc, title)
|
let doc = state.store.find_document(DocType::Rfc, title)
|
||||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
// Parse statuses and validate transition (RFC 0014)
|
||||||
|
let current_status = RfcStatus::parse(&doc.status)
|
||||||
|
.map_err(|e| ServerError::Workflow(e.to_string()))?;
|
||||||
|
let target_status = RfcStatus::parse(status_str)
|
||||||
|
.map_err(|e| ServerError::Workflow(e.to_string()))?;
|
||||||
|
|
||||||
|
// Validate the transition
|
||||||
|
validate_rfc_transition(current_status, target_status)
|
||||||
|
.map_err(|e| ServerError::Workflow(e.to_string()))?;
|
||||||
|
|
||||||
// Check for worktree if going to in-progress (RFC 0011)
|
// Check for worktree if going to in-progress (RFC 0011)
|
||||||
let has_worktree = state.has_worktree(title);
|
let has_worktree = state.has_worktree(title);
|
||||||
let worktree_warning = if status == "in-progress" && !has_worktree {
|
let worktree_warning = if status_str == "in-progress" && !has_worktree {
|
||||||
Some("No worktree exists for this RFC. Consider using blue_worktree_create for isolated development.")
|
Some("No worktree exists for this RFC. Consider using blue_worktree_create for isolated development.")
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update database
|
// Update database
|
||||||
state.store.update_document_status(DocType::Rfc, title, status)
|
state.store.update_document_status(DocType::Rfc, title, status_str)
|
||||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||||
|
|
||||||
// Update markdown file (RFC 0008)
|
// Update markdown file (RFC 0008)
|
||||||
let file_updated = if let Some(ref file_path) = doc.file_path {
|
let file_updated = if let Some(ref file_path) = doc.file_path {
|
||||||
let full_path = state.home.docs_path.join(file_path);
|
let full_path = state.home.docs_path.join(file_path);
|
||||||
blue_core::update_markdown_status(&full_path, status).unwrap_or(false)
|
blue_core::update_markdown_status(&full_path, status_str).unwrap_or(false)
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Conversational hints guide Claude to next action (RFC 0014)
|
||||||
|
let hint = match target_status {
|
||||||
|
RfcStatus::Accepted => Some(
|
||||||
|
"RFC accepted. Ask the user: 'Ready to begin implementation? \
|
||||||
|
I'll create a worktree and set up the environment.'"
|
||||||
|
),
|
||||||
|
RfcStatus::InProgress => Some(
|
||||||
|
"Implementation started. Work in the worktree, mark plan tasks \
|
||||||
|
as you complete them."
|
||||||
|
),
|
||||||
|
RfcStatus::Implemented => Some(
|
||||||
|
"Implementation complete. Ask the user: 'Ready to create a PR?'"
|
||||||
|
),
|
||||||
|
RfcStatus::Superseded => Some(
|
||||||
|
"RFC superseded. The newer RFC takes precedence."
|
||||||
|
),
|
||||||
|
RfcStatus::Draft => None,
|
||||||
|
};
|
||||||
|
|
||||||
// Build next_action for accepted status (RFC 0011)
|
// Build next_action for accepted status (RFC 0011)
|
||||||
let next_action = if status == "accepted" {
|
let next_action = if status_str == "accepted" {
|
||||||
Some(json!({
|
Some(json!({
|
||||||
"tool": "blue_worktree_create",
|
"tool": "blue_worktree_create",
|
||||||
"args": { "title": title },
|
"args": { "title": title },
|
||||||
|
|
@ -2443,11 +2472,11 @@ impl BlueServer {
|
||||||
let mut response = json!({
|
let mut response = json!({
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"title": title,
|
"title": title,
|
||||||
"new_status": status,
|
"new_status": status_str,
|
||||||
"file_updated": file_updated,
|
"file_updated": file_updated,
|
||||||
"message": blue_core::voice::success(
|
"message": blue_core::voice::success(
|
||||||
&format!("Updated '{}' to {}", title, status),
|
&format!("Updated '{}' to {}", title, status_str),
|
||||||
None
|
hint
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue