feat: Phase 9 - post-mortem and runbook tools
Add 4 tools: - blue_postmortem_create: Create post-mortem documents for incident tracking - blue_postmortem_action_to_rfc: Convert post-mortem action items to RFCs - blue_runbook_create: Create runbook documents for operations - blue_runbook_update: Update runbooks with new operations/troubleshooting Also adds DocType::Postmortem and DocType::Runbook to blue-core. Total: 54 tools ported from coherence-mcp Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f186a470c8
commit
6969a9caff
5 changed files with 1002 additions and 0 deletions
|
|
@ -162,6 +162,8 @@ pub enum DocType {
|
|||
Adr,
|
||||
Decision,
|
||||
Prd,
|
||||
Postmortem,
|
||||
Runbook,
|
||||
}
|
||||
|
||||
impl DocType {
|
||||
|
|
@ -172,6 +174,8 @@ impl DocType {
|
|||
DocType::Adr => "adr",
|
||||
DocType::Decision => "decision",
|
||||
DocType::Prd => "prd",
|
||||
DocType::Postmortem => "postmortem",
|
||||
DocType::Runbook => "runbook",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -182,6 +186,8 @@ impl DocType {
|
|||
"adr" => Some(DocType::Adr),
|
||||
"decision" => Some(DocType::Decision),
|
||||
"prd" => Some(DocType::Prd),
|
||||
"postmortem" => Some(DocType::Postmortem),
|
||||
"runbook" => Some(DocType::Runbook),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -194,6 +200,8 @@ impl DocType {
|
|||
DocType::Adr => "ADRs",
|
||||
DocType::Decision => "decisions",
|
||||
DocType::Prd => "PRDs",
|
||||
DocType::Postmortem => "post-mortems",
|
||||
DocType::Runbook => "runbooks",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ pub mod env;
|
|||
pub mod guide;
|
||||
pub mod lint;
|
||||
pub mod playwright;
|
||||
pub mod postmortem;
|
||||
pub mod pr;
|
||||
pub mod prd;
|
||||
pub mod release;
|
||||
pub mod reminder;
|
||||
pub mod rfc;
|
||||
pub mod runbook;
|
||||
pub mod session;
|
||||
pub mod spike;
|
||||
pub mod staging;
|
||||
|
|
|
|||
486
crates/blue-mcp/src/handlers/postmortem.rs
Normal file
486
crates/blue-mcp/src/handlers/postmortem.rs
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
//! Post-Mortem tool handlers
|
||||
//!
|
||||
//! Handles post-mortem creation and action item tracking.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use blue_core::{DocType, Document, ProjectState, Rfc};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::error::ServerError;
|
||||
|
||||
/// Severity levels for post-mortems
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Severity {
|
||||
P1, // Critical - major outage
|
||||
P2, // High - significant impact
|
||||
P3, // Medium - moderate impact
|
||||
P4, // Low - minor impact
|
||||
}
|
||||
|
||||
impl Severity {
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_uppercase().as_str() {
|
||||
"P1" | "CRITICAL" => Some(Severity::P1),
|
||||
"P2" | "HIGH" => Some(Severity::P2),
|
||||
"P3" | "MEDIUM" => Some(Severity::P3),
|
||||
"P4" | "LOW" => Some(Severity::P4),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Severity::P1 => "P1",
|
||||
Severity::P2 => "P2",
|
||||
Severity::P3 => "P3",
|
||||
Severity::P4 => "P4",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle blue_postmortem_create
|
||||
pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result<Value, ServerError> {
|
||||
let title = args
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let severity_str = args
|
||||
.get("severity")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let severity = Severity::from_str(severity_str).ok_or_else(|| {
|
||||
ServerError::CommandFailed(format!(
|
||||
"Invalid severity '{}'. Use P1, P2, P3, or P4.",
|
||||
severity_str
|
||||
))
|
||||
})?;
|
||||
|
||||
let summary = args.get("summary").and_then(|v| v.as_str());
|
||||
let root_cause = args.get("root_cause").and_then(|v| v.as_str());
|
||||
let duration = args.get("duration").and_then(|v| v.as_str());
|
||||
|
||||
let impact: Vec<String> = args
|
||||
.get("impact")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Get next postmortem number
|
||||
let pm_number = state
|
||||
.store
|
||||
.next_number(DocType::Postmortem)
|
||||
.map_err(|e| ServerError::CommandFailed(e.to_string()))?;
|
||||
|
||||
// Generate file path with date prefix
|
||||
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
|
||||
let file_name = format!("{}-{}.md", date, to_kebab_case(title));
|
||||
let file_path = PathBuf::from("postmortems").join(&file_name);
|
||||
let docs_path = state.home.docs_path(&state.project);
|
||||
let pm_path = docs_path.join(&file_path);
|
||||
|
||||
// Generate markdown content
|
||||
let markdown = generate_postmortem_markdown(title, severity, summary, root_cause, duration, &impact);
|
||||
|
||||
// Create document in SQLite store
|
||||
let doc = Document {
|
||||
id: None,
|
||||
doc_type: DocType::Postmortem,
|
||||
number: Some(pm_number),
|
||||
title: title.to_string(),
|
||||
status: "open".to_string(),
|
||||
file_path: Some(file_path.to_string_lossy().to_string()),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
state
|
||||
.store
|
||||
.add_document(&doc)
|
||||
.map_err(|e| ServerError::CommandFailed(e.to_string()))?;
|
||||
|
||||
// Create postmortems directory if it doesn't exist
|
||||
if let Some(parent) = pm_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| ServerError::CommandFailed(e.to_string()))?;
|
||||
}
|
||||
fs::write(&pm_path, &markdown).map_err(|e| ServerError::CommandFailed(e.to_string()))?;
|
||||
|
||||
let hint = "Post-mortem created. Fill in the timeline and lessons learned sections.";
|
||||
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"message": blue_core::voice::info(
|
||||
&format!("Post-mortem created: {}", title),
|
||||
Some(hint)
|
||||
),
|
||||
"title": title,
|
||||
"severity": severity.as_str(),
|
||||
"file": pm_path.display().to_string(),
|
||||
"content": markdown,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Handle blue_postmortem_action_to_rfc
|
||||
pub fn handle_action_to_rfc(state: &mut ProjectState, args: &Value) -> Result<Value, ServerError> {
|
||||
let postmortem_title = args
|
||||
.get("postmortem_title")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let action = args
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let rfc_title_override = args.get("rfc_title").and_then(|v| v.as_str());
|
||||
|
||||
// Find the post-mortem
|
||||
let pm_doc = state
|
||||
.store
|
||||
.find_document(DocType::Postmortem, postmortem_title)
|
||||
.map_err(|_| {
|
||||
ServerError::NotFound(format!("Post-mortem '{}' not found", postmortem_title))
|
||||
})?;
|
||||
|
||||
let pm_file_path = pm_doc.file_path.as_ref().ok_or_else(|| {
|
||||
ServerError::CommandFailed("Post-mortem has no file path".to_string())
|
||||
})?;
|
||||
|
||||
let docs_path = state.home.docs_path(&state.project);
|
||||
let pm_path = docs_path.join(pm_file_path);
|
||||
let pm_content = fs::read_to_string(&pm_path)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to read post-mortem: {}", e)))?;
|
||||
|
||||
// Find the action item
|
||||
let (action_idx, action_description) = find_action_item(&pm_content, action)?;
|
||||
|
||||
// Generate RFC title from action item if not provided
|
||||
let rfc_title = rfc_title_override
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| {
|
||||
action_description
|
||||
.chars()
|
||||
.take(50)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
// Create RFC with post-mortem reference
|
||||
let mut rfc = Rfc::new(&rfc_title);
|
||||
rfc.problem = Some(format!(
|
||||
"From post-mortem: {}\n\nAction item: {}",
|
||||
postmortem_title, action_description
|
||||
));
|
||||
|
||||
// Get next RFC number
|
||||
let rfc_number = state
|
||||
.store
|
||||
.next_number(DocType::Rfc)
|
||||
.map_err(|e| ServerError::CommandFailed(e.to_string()))?;
|
||||
|
||||
// Generate file path
|
||||
let rfc_file_name = format!("{:04}-{}.md", rfc_number, to_kebab_case(&rfc_title));
|
||||
let rfc_file_path = PathBuf::from("rfcs").join(&rfc_file_name);
|
||||
let rfc_path = docs_path.join(&rfc_file_path);
|
||||
|
||||
// Generate RFC markdown with post-mortem link
|
||||
let mut markdown = rfc.to_markdown(rfc_number as u32);
|
||||
|
||||
// Add source post-mortem link
|
||||
let pm_link = format!(
|
||||
"| **Source Post-Mortem** | [{}](../postmortems/{}) |",
|
||||
postmortem_title,
|
||||
pm_file_path.replace("postmortems/", "")
|
||||
);
|
||||
markdown = markdown.replace(
|
||||
"| **Status** | Draft |",
|
||||
&format!("| **Status** | Draft |\n{}", pm_link),
|
||||
);
|
||||
|
||||
// Create RFC document in store
|
||||
let rfc_doc = Document {
|
||||
id: None,
|
||||
doc_type: DocType::Rfc,
|
||||
number: Some(rfc_number),
|
||||
title: rfc_title.clone(),
|
||||
status: "draft".to_string(),
|
||||
file_path: Some(rfc_file_path.to_string_lossy().to_string()),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
state
|
||||
.store
|
||||
.add_document(&rfc_doc)
|
||||
.map_err(|e| ServerError::CommandFailed(e.to_string()))?;
|
||||
|
||||
// Write RFC file
|
||||
if let Some(parent) = rfc_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| ServerError::CommandFailed(e.to_string()))?;
|
||||
}
|
||||
fs::write(&rfc_path, &markdown).map_err(|e| ServerError::CommandFailed(e.to_string()))?;
|
||||
|
||||
// Update post-mortem action item with RFC link
|
||||
let updated_pm_content = update_action_item_with_rfc(
|
||||
&pm_content,
|
||||
action_idx,
|
||||
&format!("RFC {:04}: {}", rfc_number, rfc_title),
|
||||
);
|
||||
fs::write(&pm_path, updated_pm_content)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to update post-mortem: {}", e)))?;
|
||||
|
||||
let hint = format!(
|
||||
"RFC created from post-mortem action item. Review and expand the design: {}",
|
||||
rfc_path.display()
|
||||
);
|
||||
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"message": blue_core::voice::info(
|
||||
&format!("RFC {:04} created from post-mortem", rfc_number),
|
||||
Some(&hint)
|
||||
),
|
||||
"rfc_title": rfc_title,
|
||||
"rfc_number": rfc_number,
|
||||
"rfc_file": rfc_path.display().to_string(),
|
||||
"source_postmortem": postmortem_title,
|
||||
"action_item": action_description,
|
||||
"action_index": action_idx,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Find action item by index or substring
|
||||
fn find_action_item(content: &str, identifier: &str) -> Result<(usize, String), ServerError> {
|
||||
let actions = parse_all_actions(content);
|
||||
|
||||
// Try to parse as index first
|
||||
if let Ok(idx) = identifier.parse::<usize>() {
|
||||
if idx > 0 && idx <= actions.len() {
|
||||
return Ok((idx, actions[idx - 1].clone()));
|
||||
}
|
||||
return Err(ServerError::NotFound(format!(
|
||||
"Action item #{} not found. Found {} action items.",
|
||||
idx,
|
||||
actions.len()
|
||||
)));
|
||||
}
|
||||
|
||||
// Try substring match
|
||||
for (i, action) in actions.iter().enumerate() {
|
||||
if action.to_lowercase().contains(&identifier.to_lowercase()) {
|
||||
return Ok((i + 1, action.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
Err(ServerError::NotFound(format!(
|
||||
"No action item matching '{}' found",
|
||||
identifier
|
||||
)))
|
||||
}
|
||||
|
||||
/// Parse all action items from post-mortem content
|
||||
fn parse_all_actions(content: &str) -> Vec<String> {
|
||||
let mut actions = Vec::new();
|
||||
let mut in_actions_section = false;
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed.starts_with('#') && trimmed.to_lowercase().contains("action item") {
|
||||
in_actions_section = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_actions_section && trimmed.starts_with('#') {
|
||||
break;
|
||||
}
|
||||
|
||||
if in_actions_section && trimmed.starts_with('|') && !trimmed.contains("---") {
|
||||
let parts: Vec<&str> = trimmed.split('|').map(|s| s.trim()).collect();
|
||||
if parts.len() >= 2 && !parts[1].is_empty() && parts[1] != "Item" {
|
||||
actions.push(parts[1].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
/// Update action item row with RFC link
|
||||
fn update_action_item_with_rfc(content: &str, action_idx: usize, rfc_ref: &str) -> String {
|
||||
let mut lines: Vec<String> = content.lines().map(String::from).collect();
|
||||
let mut in_actions_section = false;
|
||||
let mut current_action = 0;
|
||||
|
||||
for line in lines.iter_mut() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed.starts_with('#') && trimmed.to_lowercase().contains("action item") {
|
||||
in_actions_section = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_actions_section && trimmed.starts_with('#') {
|
||||
break;
|
||||
}
|
||||
|
||||
if in_actions_section && trimmed.starts_with('|') && !trimmed.contains("---") {
|
||||
let parts: Vec<&str> = trimmed.split('|').map(|s| s.trim()).collect();
|
||||
if parts.len() >= 2 && !parts[1].is_empty() && parts[1] != "Item" {
|
||||
current_action += 1;
|
||||
if current_action == action_idx {
|
||||
// Update the RFC column (last column)
|
||||
let mut new_parts: Vec<String> = parts.iter().map(|s| s.to_string()).collect();
|
||||
if new_parts.len() > 5 {
|
||||
new_parts[5] = rfc_ref.to_string();
|
||||
} else {
|
||||
while new_parts.len() <= 5 {
|
||||
new_parts.push(String::new());
|
||||
}
|
||||
new_parts[5] = rfc_ref.to_string();
|
||||
}
|
||||
*line = format!("| {} |", new_parts[1..].join(" | "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Generate post-mortem markdown content
|
||||
fn generate_postmortem_markdown(
|
||||
title: &str,
|
||||
severity: Severity,
|
||||
summary: Option<&str>,
|
||||
root_cause: Option<&str>,
|
||||
duration: Option<&str>,
|
||||
impact: &[String],
|
||||
) -> String {
|
||||
let mut md = String::new();
|
||||
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
|
||||
|
||||
// Title
|
||||
md.push_str(&format!(
|
||||
"# Post-Mortem: {}\n\n",
|
||||
to_title_case(title)
|
||||
));
|
||||
|
||||
// Metadata table
|
||||
md.push_str("| | |\n|---|---|\n");
|
||||
md.push_str(&format!("| **Date** | {} |\n", date));
|
||||
md.push_str(&format!("| **Severity** | {} |\n", severity.as_str()));
|
||||
if let Some(dur) = duration {
|
||||
md.push_str(&format!("| **Duration** | {} |\n", dur));
|
||||
}
|
||||
md.push_str("| **Author** | [Name] |\n");
|
||||
md.push_str("\n---\n\n");
|
||||
|
||||
// Summary
|
||||
md.push_str("## Summary\n\n");
|
||||
if let Some(sum) = summary {
|
||||
md.push_str(sum);
|
||||
} else {
|
||||
md.push_str("[One paragraph summary of the incident]");
|
||||
}
|
||||
md.push_str("\n\n");
|
||||
|
||||
// Timeline
|
||||
md.push_str("## Timeline\n\n");
|
||||
md.push_str("| Time | Event |\n");
|
||||
md.push_str("|------|-------|\n");
|
||||
md.push_str("| HH:MM | [Event] |\n");
|
||||
md.push_str("\n");
|
||||
|
||||
// Root Cause
|
||||
md.push_str("## Root Cause\n\n");
|
||||
if let Some(rc) = root_cause {
|
||||
md.push_str(rc);
|
||||
} else {
|
||||
md.push_str("[What actually caused the incident]");
|
||||
}
|
||||
md.push_str("\n\n");
|
||||
|
||||
// Impact
|
||||
md.push_str("## Impact\n\n");
|
||||
if !impact.is_empty() {
|
||||
for item in impact {
|
||||
md.push_str(&format!("- {}\n", item));
|
||||
}
|
||||
} else {
|
||||
md.push_str("- [Impact 1]\n");
|
||||
}
|
||||
md.push_str("\n");
|
||||
|
||||
// What Went Well
|
||||
md.push_str("## What Went Well\n\n");
|
||||
md.push_str("- [Item 1]\n\n");
|
||||
|
||||
// What Went Wrong
|
||||
md.push_str("## What Went Wrong\n\n");
|
||||
md.push_str("- [Item 1]\n\n");
|
||||
|
||||
// Action Items
|
||||
md.push_str("## Action Items\n\n");
|
||||
md.push_str("| Item | Owner | Due | Status | RFC |\n");
|
||||
md.push_str("|------|-------|-----|--------|-----|\n");
|
||||
md.push_str("| [Action 1] | [Name] | [Date] | Open | |\n");
|
||||
md.push_str("\n");
|
||||
|
||||
// Lessons Learned
|
||||
md.push_str("## Lessons Learned\n\n");
|
||||
md.push_str("[Key takeaways from this incident]\n");
|
||||
|
||||
md
|
||||
}
|
||||
|
||||
/// Convert a title to kebab-case for filenames
|
||||
fn to_kebab_case(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
/// Convert slug to title case
|
||||
fn to_title_case(s: &str) -> String {
|
||||
s.split('-')
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_severity_from_str() {
|
||||
assert_eq!(Severity::from_str("P1"), Some(Severity::P1));
|
||||
assert_eq!(Severity::from_str("critical"), Some(Severity::P1));
|
||||
assert_eq!(Severity::from_str("P4"), Some(Severity::P4));
|
||||
assert_eq!(Severity::from_str("invalid"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_kebab_case() {
|
||||
assert_eq!(to_kebab_case("Database Outage"), "database-outage");
|
||||
assert_eq!(to_kebab_case("API failure"), "api-failure");
|
||||
}
|
||||
}
|
||||
343
crates/blue-mcp/src/handlers/runbook.rs
Normal file
343
crates/blue-mcp/src/handlers/runbook.rs
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
//! Runbook tool handlers
|
||||
//!
|
||||
//! Handles runbook creation and updates with RFC linking.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use blue_core::{DocType, Document, ProjectState};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::error::ServerError;
|
||||
|
||||
/// Handle blue_runbook_create
|
||||
pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result<Value, ServerError> {
|
||||
let title = args
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let source_rfc = args.get("source_rfc").and_then(|v| v.as_str());
|
||||
let service_name = args.get("service_name").and_then(|v| v.as_str());
|
||||
let owner = args.get("owner").and_then(|v| v.as_str());
|
||||
|
||||
let operations: Vec<String> = args
|
||||
.get("operations")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Validate source RFC exists if provided
|
||||
let source_rfc_doc = if let Some(rfc_title) = source_rfc {
|
||||
Some(
|
||||
state
|
||||
.store
|
||||
.find_document(DocType::Rfc, rfc_title)
|
||||
.map_err(|_| {
|
||||
ServerError::NotFound(format!("Source RFC '{}' not found", rfc_title))
|
||||
})?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Get next runbook number
|
||||
let runbook_number = state
|
||||
.store
|
||||
.next_number(DocType::Runbook)
|
||||
.map_err(|e| ServerError::CommandFailed(e.to_string()))?;
|
||||
|
||||
// Generate file path
|
||||
let file_name = format!("{}.md", to_kebab_case(title));
|
||||
let file_path = PathBuf::from("runbooks").join(&file_name);
|
||||
let docs_path = state.home.docs_path(&state.project);
|
||||
let runbook_path = docs_path.join(&file_path);
|
||||
|
||||
// Generate markdown content
|
||||
let markdown = generate_runbook_markdown(title, &source_rfc_doc, service_name, owner, &operations);
|
||||
|
||||
// Create document in SQLite store
|
||||
let doc = Document {
|
||||
id: None,
|
||||
doc_type: DocType::Runbook,
|
||||
number: Some(runbook_number),
|
||||
title: title.to_string(),
|
||||
status: "active".to_string(),
|
||||
file_path: Some(file_path.to_string_lossy().to_string()),
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
};
|
||||
state
|
||||
.store
|
||||
.add_document(&doc)
|
||||
.map_err(|e| ServerError::CommandFailed(e.to_string()))?;
|
||||
|
||||
// Write the markdown file
|
||||
if let Some(parent) = runbook_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| ServerError::CommandFailed(e.to_string()))?;
|
||||
}
|
||||
fs::write(&runbook_path, &markdown).map_err(|e| ServerError::CommandFailed(e.to_string()))?;
|
||||
|
||||
// Update source RFC with runbook link if provided
|
||||
if let Some(ref rfc_doc) = source_rfc_doc {
|
||||
if let Some(ref rfc_file_path) = rfc_doc.file_path {
|
||||
let rfc_path = docs_path.join(rfc_file_path);
|
||||
if rfc_path.exists() {
|
||||
if let Ok(rfc_content) = fs::read_to_string(&rfc_path) {
|
||||
let runbook_link = format!(
|
||||
"| **Runbook** | [{}](../runbooks/{}) |",
|
||||
title, file_name
|
||||
);
|
||||
|
||||
// Insert after Status line if not already present
|
||||
if !rfc_content.contains("| **Runbook** |") {
|
||||
let updated_rfc = if rfc_content.contains("| **Status** | Implemented |") {
|
||||
rfc_content.replace(
|
||||
"| **Status** | Implemented |",
|
||||
&format!("| **Status** | Implemented |\n{}", runbook_link),
|
||||
)
|
||||
} else {
|
||||
rfc_content
|
||||
};
|
||||
let _ = fs::write(&rfc_path, updated_rfc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let hint = "Runbook created. Fill in the operation procedures and troubleshooting sections.";
|
||||
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"message": blue_core::voice::info(
|
||||
&format!("Runbook created: {}", title),
|
||||
Some(hint)
|
||||
),
|
||||
"title": title,
|
||||
"file": runbook_path.display().to_string(),
|
||||
"source_rfc": source_rfc,
|
||||
"content": markdown,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Handle blue_runbook_update
|
||||
pub fn handle_update(state: &mut ProjectState, args: &Value) -> Result<Value, ServerError> {
|
||||
let title = args
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or(ServerError::InvalidParams)?;
|
||||
|
||||
let add_operation = args.get("add_operation").and_then(|v| v.as_str());
|
||||
let add_troubleshooting = args.get("add_troubleshooting").and_then(|v| v.as_str());
|
||||
|
||||
// Find the runbook
|
||||
let doc = state
|
||||
.store
|
||||
.find_document(DocType::Runbook, title)
|
||||
.map_err(|_| ServerError::NotFound(format!("Runbook '{}' not found", title)))?;
|
||||
|
||||
let runbook_file_path = doc.file_path.as_ref().ok_or_else(|| {
|
||||
ServerError::CommandFailed("Runbook has no file path".to_string())
|
||||
})?;
|
||||
|
||||
let docs_path = state.home.docs_path(&state.project);
|
||||
let runbook_path = docs_path.join(runbook_file_path);
|
||||
let mut content = fs::read_to_string(&runbook_path)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to read runbook: {}", e)))?;
|
||||
|
||||
let mut changes = Vec::new();
|
||||
|
||||
// Add new operation if provided
|
||||
if let Some(operation) = add_operation {
|
||||
let operation_section = format!(
|
||||
"\n### Operation: {}\n\n**When to use**: [Describe trigger condition]\n\n**Steps**:\n1. [Step 1]\n\n**Verification**:\n```bash\n# Verify success\n```\n\n**Rollback**:\n```bash\n# Rollback if needed\n```\n",
|
||||
operation
|
||||
);
|
||||
|
||||
// Insert before Troubleshooting section or at end
|
||||
if content.contains("## Troubleshooting") {
|
||||
content = content.replace(
|
||||
"## Troubleshooting",
|
||||
&format!("{}\n## Troubleshooting", operation_section),
|
||||
);
|
||||
} else {
|
||||
content.push_str(&operation_section);
|
||||
}
|
||||
changes.push(format!("Added operation: {}", operation));
|
||||
}
|
||||
|
||||
// Add troubleshooting if provided
|
||||
if let Some(troubleshooting) = add_troubleshooting {
|
||||
let troubleshooting_section = format!(
|
||||
"\n### Symptom: {}\n\n**Possible causes**:\n1. [Cause 1]\n\n**Resolution**:\n1. [Step 1]\n",
|
||||
troubleshooting
|
||||
);
|
||||
|
||||
// Insert into Troubleshooting section or create one
|
||||
if content.contains("## Troubleshooting") {
|
||||
if content.contains("## Escalation") {
|
||||
content = content.replace(
|
||||
"## Escalation",
|
||||
&format!("{}\n## Escalation", troubleshooting_section),
|
||||
);
|
||||
} else {
|
||||
content.push_str(&troubleshooting_section);
|
||||
}
|
||||
} else {
|
||||
content.push_str(&format!(
|
||||
"\n## Troubleshooting\n{}",
|
||||
troubleshooting_section
|
||||
));
|
||||
}
|
||||
changes.push(format!("Added troubleshooting: {}", troubleshooting));
|
||||
}
|
||||
|
||||
// Write updated content
|
||||
fs::write(&runbook_path, &content)
|
||||
.map_err(|e| ServerError::CommandFailed(format!("Failed to write runbook: {}", e)))?;
|
||||
|
||||
let hint = "Runbook updated. Review the changes and fill in details.";
|
||||
|
||||
Ok(json!({
|
||||
"status": "success",
|
||||
"message": blue_core::voice::info(
|
||||
&format!("Runbook updated: {}", title),
|
||||
Some(hint)
|
||||
),
|
||||
"title": title,
|
||||
"file": runbook_path.display().to_string(),
|
||||
"changes": changes,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Generate runbook markdown content
|
||||
fn generate_runbook_markdown(
|
||||
title: &str,
|
||||
source_rfc: &Option<Document>,
|
||||
service_name: Option<&str>,
|
||||
owner: Option<&str>,
|
||||
operations: &[String],
|
||||
) -> String {
|
||||
let mut md = String::new();
|
||||
|
||||
// Title
|
||||
md.push_str(&format!(
|
||||
"# Runbook: {}\n\n",
|
||||
to_title_case(title)
|
||||
));
|
||||
|
||||
// Metadata table
|
||||
md.push_str("| | |\n|---|---|\n");
|
||||
md.push_str("| **Status** | Active |\n");
|
||||
|
||||
if let Some(o) = owner {
|
||||
md.push_str(&format!("| **Owner** | {} |\n", o));
|
||||
}
|
||||
|
||||
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
|
||||
md.push_str(&format!("| **Created** | {} |\n", date));
|
||||
|
||||
if let Some(ref rfc_doc) = source_rfc {
|
||||
if let Some(ref rfc_file_path) = rfc_doc.file_path {
|
||||
md.push_str(&format!(
|
||||
"| **Source RFC** | [{}](../rfcs/{}) |\n",
|
||||
rfc_doc.title, rfc_file_path.replace("rfcs/", "")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
md.push_str("\n---\n\n");
|
||||
|
||||
// Overview
|
||||
md.push_str("## Overview\n\n");
|
||||
if let Some(svc) = service_name {
|
||||
md.push_str(&format!(
|
||||
"This runbook covers operational procedures for **{}**.\n\n",
|
||||
svc
|
||||
));
|
||||
} else {
|
||||
md.push_str("[Describe what this runbook covers]\n\n");
|
||||
}
|
||||
|
||||
// Prerequisites
|
||||
md.push_str("## Prerequisites\n\n");
|
||||
md.push_str("- [ ] Access to [system]\n");
|
||||
md.push_str("- [ ] Permissions for [action]\n\n");
|
||||
|
||||
// Common Operations
|
||||
md.push_str("## Common Operations\n\n");
|
||||
|
||||
if !operations.is_empty() {
|
||||
for op in operations {
|
||||
md.push_str(&format!(
|
||||
"### Operation: {}\n\n**When to use**: [Trigger condition]\n\n**Steps**:\n1. [Step 1]\n\n**Verification**:\n```bash\n# Command to verify success\n```\n\n**Rollback**:\n```bash\n# Command to rollback if needed\n```\n\n",
|
||||
op
|
||||
));
|
||||
}
|
||||
} else {
|
||||
md.push_str("### Operation 1: [Name]\n\n**When to use**: [Trigger condition]\n\n**Steps**:\n1. [Step 1]\n\n**Verification**:\n```bash\n# Command to verify success\n```\n\n**Rollback**:\n```bash\n# Command to rollback if needed\n```\n\n");
|
||||
}
|
||||
|
||||
// Troubleshooting
|
||||
md.push_str("## Troubleshooting\n\n");
|
||||
md.push_str("### Symptom: [Description]\n\n**Possible causes**:\n1. [Cause 1]\n\n**Resolution**:\n1. [Step 1]\n\n");
|
||||
|
||||
// Escalation
|
||||
md.push_str("## Escalation\n\n");
|
||||
md.push_str("| Level | Contact | When |\n");
|
||||
md.push_str("|-------|---------|------|\n");
|
||||
md.push_str("| L1 | [Team] | [Condition] |\n");
|
||||
md.push_str("| L2 | [Team] | [Condition] |\n\n");
|
||||
|
||||
// Related Documents
|
||||
md.push_str("## Related Documents\n\n");
|
||||
if source_rfc.is_some() {
|
||||
md.push_str("- Source RFC (linked above)\n");
|
||||
}
|
||||
md.push_str("- [Link to architecture]\n");
|
||||
md.push_str("- [Link to monitoring dashboard]\n");
|
||||
|
||||
md
|
||||
}
|
||||
|
||||
/// Convert a title to kebab-case for filenames
|
||||
fn to_kebab_case(s: &str) -> String {
|
||||
s.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
/// Convert slug to title case
|
||||
fn to_title_case(s: &str) -> String {
|
||||
s.split('-')
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_to_kebab_case() {
|
||||
assert_eq!(to_kebab_case("Deploy Service"), "deploy-service");
|
||||
assert_eq!(to_kebab_case("API Gateway Runbook"), "api-gateway-runbook");
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,23 @@ impl BlueServer {
|
|||
self.state.as_ref().ok_or(ServerError::BlueNotDetected)
|
||||
}
|
||||
|
||||
fn ensure_state_mut(&mut self) -> Result<&mut ProjectState, ServerError> {
|
||||
if self.state.is_none() {
|
||||
let cwd = self.cwd.as_ref().ok_or(ServerError::BlueNotDetected)?;
|
||||
let home = detect_blue(cwd).map_err(|_| ServerError::BlueNotDetected)?;
|
||||
|
||||
// Try to get project name from the current path
|
||||
let project = home.project_name.clone().unwrap_or_else(|| "default".to_string());
|
||||
|
||||
let state = ProjectState::load(home, &project)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
self.state = Some(state);
|
||||
}
|
||||
|
||||
self.state.as_mut().ok_or(ServerError::BlueNotDetected)
|
||||
}
|
||||
|
||||
/// Handle a JSON-RPC request
|
||||
pub fn handle_request(&mut self, request: &str) -> String {
|
||||
let result = self.handle_request_inner(request);
|
||||
|
|
@ -1202,6 +1219,118 @@ impl BlueServer {
|
|||
},
|
||||
"required": ["task", "base_url"]
|
||||
}
|
||||
},
|
||||
// Phase 9: Post-mortem tools
|
||||
{
|
||||
"name": "blue_postmortem_create",
|
||||
"description": "Create a post-mortem document for incident tracking.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Post-mortem title"
|
||||
},
|
||||
"severity": {
|
||||
"type": "string",
|
||||
"description": "Severity level (P1, P2, P3, P4)"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "Brief incident summary"
|
||||
},
|
||||
"root_cause": {
|
||||
"type": "string",
|
||||
"description": "Root cause of the incident"
|
||||
},
|
||||
"duration": {
|
||||
"type": "string",
|
||||
"description": "Incident duration"
|
||||
},
|
||||
"impact": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Impact items"
|
||||
}
|
||||
},
|
||||
"required": ["title", "severity"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "blue_postmortem_action_to_rfc",
|
||||
"description": "Convert a post-mortem action item into an RFC with bidirectional linking.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"postmortem_title": {
|
||||
"type": "string",
|
||||
"description": "Title of the post-mortem"
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "Action item index (1-based) or substring to match"
|
||||
},
|
||||
"rfc_title": {
|
||||
"type": "string",
|
||||
"description": "Optional RFC title (defaults to action item text)"
|
||||
}
|
||||
},
|
||||
"required": ["postmortem_title", "action"]
|
||||
}
|
||||
},
|
||||
// Phase 9: Runbook tools
|
||||
{
|
||||
"name": "blue_runbook_create",
|
||||
"description": "Create a runbook document for operational procedures.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Runbook title"
|
||||
},
|
||||
"source_rfc": {
|
||||
"type": "string",
|
||||
"description": "Source RFC title to link"
|
||||
},
|
||||
"service_name": {
|
||||
"type": "string",
|
||||
"description": "Service or feature name"
|
||||
},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "Owner team or person"
|
||||
},
|
||||
"operations": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Initial operations to document"
|
||||
}
|
||||
},
|
||||
"required": ["title"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "blue_runbook_update",
|
||||
"description": "Update an existing runbook with new operations or troubleshooting.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Runbook title"
|
||||
},
|
||||
"add_operation": {
|
||||
"type": "string",
|
||||
"description": "New operation to add"
|
||||
},
|
||||
"add_troubleshooting": {
|
||||
"type": "string",
|
||||
"description": "Troubleshooting section to add"
|
||||
}
|
||||
},
|
||||
"required": ["title"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
|
@ -1284,6 +1413,12 @@ impl BlueServer {
|
|||
"blue_extract_dialogue" => self.handle_extract_dialogue(&call.arguments),
|
||||
// Phase 8: Playwright handler
|
||||
"blue_playwright_verify" => self.handle_playwright_verify(&call.arguments),
|
||||
// Phase 9: Post-mortem handlers
|
||||
"blue_postmortem_create" => self.handle_postmortem_create(&call.arguments),
|
||||
"blue_postmortem_action_to_rfc" => self.handle_postmortem_action_to_rfc(&call.arguments),
|
||||
// Phase 9: Runbook handlers
|
||||
"blue_runbook_create" => self.handle_runbook_create(&call.arguments),
|
||||
"blue_runbook_update" => self.handle_runbook_update(&call.arguments),
|
||||
_ => Err(ServerError::ToolNotFound(call.name)),
|
||||
}?;
|
||||
|
||||
|
|
@ -1945,6 +2080,34 @@ impl BlueServer {
|
|||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
crate::handlers::playwright::handle_verify(args)
|
||||
}
|
||||
|
||||
// Phase 9: Post-mortem handlers
|
||||
|
||||
fn handle_postmortem_create(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
let state = self.ensure_state_mut()?;
|
||||
crate::handlers::postmortem::handle_create(state, args)
|
||||
}
|
||||
|
||||
fn handle_postmortem_action_to_rfc(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
let state = self.ensure_state_mut()?;
|
||||
crate::handlers::postmortem::handle_action_to_rfc(state, args)
|
||||
}
|
||||
|
||||
// Phase 9: Runbook handlers
|
||||
|
||||
fn handle_runbook_create(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
let state = self.ensure_state_mut()?;
|
||||
crate::handlers::runbook::handle_create(state, args)
|
||||
}
|
||||
|
||||
fn handle_runbook_update(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
|
||||
let state = self.ensure_state_mut()?;
|
||||
crate::handlers::runbook::handle_update(state, args)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BlueServer {
|
||||
|
|
|
|||
Loading…
Reference in a new issue