diff --git a/crates/blue-core/Cargo.toml b/crates/blue-core/Cargo.toml index 8f8857d..a73a628 100644 --- a/crates/blue-core/Cargo.toml +++ b/crates/blue-core/Cargo.toml @@ -5,6 +5,9 @@ edition.workspace = true license.workspace = true description = "Core data structures and logic for Blue" +[features] +test-helpers = [] + [dependencies] serde.workspace = true serde_json.workspace = true diff --git a/crates/blue-core/src/lib.rs b/crates/blue-core/src/lib.rs index 3d0c136..6f1b88c 100644 --- a/crates/blue-core/src/lib.rs +++ b/crates/blue-core/src/lib.rs @@ -22,6 +22,6 @@ pub mod workflow; pub use documents::*; pub use repo::{detect_blue, BlueHome, RepoError, WorktreeInfo}; pub use state::{ItemType, ProjectState, StateError, StatusSummary, WorkItem}; -pub use store::{DocType, Document, DocumentStore, LinkType, SearchResult, StoreError, Task as StoreTask, TaskProgress, Worktree}; +pub use store::{DocType, Document, DocumentStore, LinkType, Reminder, ReminderStatus, SearchResult, Session, SessionType, StoreError, Task as StoreTask, TaskProgress, Worktree}; pub use voice::*; pub use workflow::{PrdStatus, RfcStatus, SpikeOutcome as WorkflowSpikeOutcome, SpikeStatus, WorkflowError}; diff --git a/crates/blue-core/src/state.rs b/crates/blue-core/src/state.rs index 2d98ee1..58c563e 100644 --- a/crates/blue-core/src/state.rs +++ b/crates/blue-core/src/state.rs @@ -26,6 +26,26 @@ pub struct ProjectState { } impl ProjectState { + /// Create a test state with in-memory store (for testing only) + #[cfg(any(test, feature = "test-helpers"))] + pub fn for_test() -> Self { + use std::path::PathBuf; + let store = DocumentStore::open_in_memory().unwrap(); + Self { + home: BlueHome { + root: PathBuf::from("/test"), + data_path: PathBuf::from("/test/.blue/data"), + repos_path: PathBuf::from("/test/.blue/repos"), + worktrees_path: PathBuf::from("/test/.blue/worktrees"), + project_name: Some("test".to_string()), + }, + store, + worktrees: Vec::new(), + worktree_rfcs: HashSet::new(), + project: "test".to_string(), + } + } + /// Load project state pub fn load(home: BlueHome, project: &str) -> Result { let db_path = home.db_path(project); diff --git a/crates/blue-core/src/store.rs b/crates/blue-core/src/store.rs index e1e44bb..7a9e7eb 100644 --- a/crates/blue-core/src/store.rs +++ b/crates/blue-core/src/store.rs @@ -10,7 +10,7 @@ use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBe use tracing::{debug, info, warn}; /// Current schema version -const SCHEMA_VERSION: i32 = 1; +const SCHEMA_VERSION: i32 = 2; /// Core database schema const SCHEMA: &str = r#" @@ -73,6 +73,36 @@ const SCHEMA: &str = r#" FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE, UNIQUE(document_id, key) ); + + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfc_title TEXT NOT NULL, + session_type TEXT NOT NULL DEFAULT 'implementation', + started_at TEXT NOT NULL, + last_heartbeat TEXT NOT NULL, + ended_at TEXT, + UNIQUE(rfc_title) + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_active ON sessions(ended_at) WHERE ended_at IS NULL; + + CREATE TABLE IF NOT EXISTS reminders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + context TEXT, + gate TEXT, + due_date TEXT, + snooze_until TEXT, + status TEXT NOT NULL DEFAULT 'pending', + linked_doc_id INTEGER, + created_at TEXT NOT NULL, + cleared_at TEXT, + resolution TEXT, + FOREIGN KEY (linked_doc_id) REFERENCES documents(id) ON DELETE SET NULL + ); + + CREATE INDEX IF NOT EXISTS idx_reminders_status ON reminders(status); + CREATE INDEX IF NOT EXISTS idx_reminders_due ON reminders(due_date) WHERE status = 'pending'; "#; /// FTS5 schema for full-text search @@ -237,6 +267,105 @@ pub struct SearchResult { pub snippet: Option, } +/// Session types for multi-agent coordination +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionType { + Implementation, + Review, + Testing, +} + +impl SessionType { + pub fn as_str(&self) -> &'static str { + match self { + SessionType::Implementation => "implementation", + SessionType::Review => "review", + SessionType::Testing => "testing", + } + } + + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "implementation" => Some(SessionType::Implementation), + "review" => Some(SessionType::Review), + "testing" => Some(SessionType::Testing), + _ => None, + } + } +} + +/// An active session on an RFC +#[derive(Debug, Clone)] +pub struct Session { + pub id: Option, + pub rfc_title: String, + pub session_type: SessionType, + pub started_at: String, + pub last_heartbeat: String, + pub ended_at: Option, +} + +/// Reminder status +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReminderStatus { + Pending, + Snoozed, + Cleared, +} + +impl ReminderStatus { + pub fn as_str(&self) -> &'static str { + match self { + ReminderStatus::Pending => "pending", + ReminderStatus::Snoozed => "snoozed", + ReminderStatus::Cleared => "cleared", + } + } + + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "pending" => Some(ReminderStatus::Pending), + "snoozed" => Some(ReminderStatus::Snoozed), + "cleared" => Some(ReminderStatus::Cleared), + _ => None, + } + } +} + +/// A reminder with optional gate condition +#[derive(Debug, Clone)] +pub struct Reminder { + pub id: Option, + pub title: String, + pub context: Option, + pub gate: Option, + pub due_date: Option, + pub snooze_until: Option, + pub status: ReminderStatus, + pub linked_doc_id: Option, + pub created_at: Option, + pub cleared_at: Option, + pub resolution: Option, +} + +impl Reminder { + pub fn new(title: &str) -> Self { + Self { + id: None, + title: title.to_string(), + context: None, + gate: None, + due_date: None, + snooze_until: None, + status: ReminderStatus::Pending, + linked_doc_id: None, + created_at: None, + cleared_at: None, + resolution: None, + } + } +} + /// Store errors - in Blue's voice #[derive(Debug, thiserror::Error)] pub enum StoreError { @@ -936,6 +1065,309 @@ impl DocumentStore { rows.collect::, _>>() .map_err(StoreError::Database) } + + // ==================== Session Operations ==================== + + /// Start or update a session + pub fn upsert_session(&self, session: &Session) -> Result { + self.with_retry(|| { + let now = chrono::Utc::now().to_rfc3339(); + + // Try to get existing session + let existing: Option = self.conn + .query_row( + "SELECT id FROM sessions WHERE rfc_title = ?1 AND ended_at IS NULL", + params![session.rfc_title], + |row| row.get(0), + ) + .optional()?; + + match existing { + Some(id) => { + // Update heartbeat + self.conn.execute( + "UPDATE sessions SET last_heartbeat = ?1, session_type = ?2 WHERE id = ?3", + params![now, session.session_type.as_str(), id], + )?; + Ok(id) + } + None => { + // Create new session + self.conn.execute( + "INSERT INTO sessions (rfc_title, session_type, started_at, last_heartbeat) + VALUES (?1, ?2, ?3, ?4)", + params![ + session.rfc_title, + session.session_type.as_str(), + now, + now + ], + )?; + Ok(self.conn.last_insert_rowid()) + } + } + }) + } + + /// End a session + pub fn end_session(&self, rfc_title: &str) -> Result<(), StoreError> { + self.with_retry(|| { + let now = chrono::Utc::now().to_rfc3339(); + let updated = self.conn.execute( + "UPDATE sessions SET ended_at = ?1 WHERE rfc_title = ?2 AND ended_at IS NULL", + params![now, rfc_title], + )?; + if updated == 0 { + return Err(StoreError::NotFound(format!("active session for '{}'", rfc_title))); + } + Ok(()) + }) + } + + /// Get active session for an RFC + pub fn get_active_session(&self, rfc_title: &str) -> Result, StoreError> { + self.conn + .query_row( + "SELECT id, rfc_title, session_type, started_at, last_heartbeat, ended_at + FROM sessions WHERE rfc_title = ?1 AND ended_at IS NULL", + params![rfc_title], + |row| { + Ok(Session { + id: Some(row.get(0)?), + rfc_title: row.get(1)?, + session_type: SessionType::from_str(&row.get::<_, String>(2)?).unwrap_or(SessionType::Implementation), + started_at: row.get(3)?, + last_heartbeat: row.get(4)?, + ended_at: row.get(5)?, + }) + }, + ) + .optional() + .map_err(StoreError::Database) + } + + /// List all active sessions + pub fn list_active_sessions(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT id, rfc_title, session_type, started_at, last_heartbeat, ended_at + FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC", + )?; + + let rows = stmt.query_map([], |row| { + Ok(Session { + id: Some(row.get(0)?), + rfc_title: row.get(1)?, + session_type: SessionType::from_str(&row.get::<_, String>(2)?).unwrap_or(SessionType::Implementation), + started_at: row.get(3)?, + last_heartbeat: row.get(4)?, + ended_at: row.get(5)?, + }) + })?; + + rows.collect::, _>>() + .map_err(StoreError::Database) + } + + /// Clean up stale sessions (no heartbeat in 5+ minutes) + pub fn cleanup_stale_sessions(&self, timeout_minutes: i64) -> Result { + self.with_retry(|| { + let now = chrono::Utc::now(); + let cutoff = now - chrono::Duration::minutes(timeout_minutes); + let cutoff_str = cutoff.to_rfc3339(); + + let count = self.conn.execute( + "UPDATE sessions SET ended_at = ?1 + WHERE ended_at IS NULL AND last_heartbeat < ?2", + params![now.to_rfc3339(), cutoff_str], + )?; + Ok(count) + }) + } + + // ==================== Reminder Operations ==================== + + /// Add a reminder + pub fn add_reminder(&self, reminder: &Reminder) -> Result { + self.with_retry(|| { + let now = chrono::Utc::now().to_rfc3339(); + self.conn.execute( + "INSERT INTO reminders (title, context, gate, due_date, snooze_until, status, linked_doc_id, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + reminder.title, + reminder.context, + reminder.gate, + reminder.due_date, + reminder.snooze_until, + reminder.status.as_str(), + reminder.linked_doc_id, + now, + ], + )?; + Ok(self.conn.last_insert_rowid()) + }) + } + + /// Get a reminder by ID + pub fn get_reminder(&self, id: i64) -> Result { + self.conn + .query_row( + "SELECT id, title, context, gate, due_date, snooze_until, status, linked_doc_id, created_at, cleared_at, resolution + FROM reminders WHERE id = ?1", + params![id], + |row| { + Ok(Reminder { + id: Some(row.get(0)?), + title: row.get(1)?, + context: row.get(2)?, + gate: row.get(3)?, + due_date: row.get(4)?, + snooze_until: row.get(5)?, + status: ReminderStatus::from_str(&row.get::<_, String>(6)?).unwrap_or(ReminderStatus::Pending), + linked_doc_id: row.get(7)?, + created_at: row.get(8)?, + cleared_at: row.get(9)?, + resolution: row.get(10)?, + }) + }, + ) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => StoreError::NotFound(format!("reminder #{}", id)), + e => StoreError::Database(e), + }) + } + + /// Find reminder by title (partial match) + pub fn find_reminder(&self, title: &str) -> Result { + // Try exact match first + if let Ok(reminder) = self.conn.query_row( + "SELECT id, title, context, gate, due_date, snooze_until, status, linked_doc_id, created_at, cleared_at, resolution + FROM reminders WHERE title = ?1 AND status != 'cleared'", + params![title], + |row| { + Ok(Reminder { + id: Some(row.get(0)?), + title: row.get(1)?, + context: row.get(2)?, + gate: row.get(3)?, + due_date: row.get(4)?, + snooze_until: row.get(5)?, + status: ReminderStatus::from_str(&row.get::<_, String>(6)?).unwrap_or(ReminderStatus::Pending), + linked_doc_id: row.get(7)?, + created_at: row.get(8)?, + cleared_at: row.get(9)?, + resolution: row.get(10)?, + }) + }, + ) { + return Ok(reminder); + } + + // Try partial match + let pattern = format!("%{}%", title.to_lowercase()); + self.conn + .query_row( + "SELECT id, title, context, gate, due_date, snooze_until, status, linked_doc_id, created_at, cleared_at, resolution + FROM reminders WHERE LOWER(title) LIKE ?1 AND status != 'cleared' + ORDER BY LENGTH(title) ASC LIMIT 1", + params![pattern], + |row| { + Ok(Reminder { + id: Some(row.get(0)?), + title: row.get(1)?, + context: row.get(2)?, + gate: row.get(3)?, + due_date: row.get(4)?, + snooze_until: row.get(5)?, + status: ReminderStatus::from_str(&row.get::<_, String>(6)?).unwrap_or(ReminderStatus::Pending), + linked_doc_id: row.get(7)?, + created_at: row.get(8)?, + cleared_at: row.get(9)?, + resolution: row.get(10)?, + }) + }, + ) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => StoreError::NotFound(format!("reminder matching '{}'", title)), + e => StoreError::Database(e), + }) + } + + /// List reminders by status + pub fn list_reminders(&self, status: Option, include_future: bool) -> Result, StoreError> { + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); + + let query = match (status, include_future) { + (Some(s), true) => format!( + "SELECT id, title, context, gate, due_date, snooze_until, status, linked_doc_id, created_at, cleared_at, resolution + FROM reminders WHERE status = '{}' ORDER BY due_date ASC, created_at ASC", + s.as_str() + ), + (Some(s), false) => format!( + "SELECT id, title, context, gate, due_date, snooze_until, status, linked_doc_id, created_at, cleared_at, resolution + FROM reminders WHERE status = '{}' AND (snooze_until IS NULL OR snooze_until <= '{}') + ORDER BY due_date ASC, created_at ASC", + s.as_str(), today + ), + (None, true) => "SELECT id, title, context, gate, due_date, snooze_until, status, linked_doc_id, created_at, cleared_at, resolution + FROM reminders ORDER BY due_date ASC, created_at ASC".to_string(), + (None, false) => format!( + "SELECT id, title, context, gate, due_date, snooze_until, status, linked_doc_id, created_at, cleared_at, resolution + FROM reminders WHERE snooze_until IS NULL OR snooze_until <= '{}' + ORDER BY due_date ASC, created_at ASC", + today + ), + }; + + let mut stmt = self.conn.prepare(&query)?; + let rows = stmt.query_map([], |row| { + Ok(Reminder { + id: Some(row.get(0)?), + title: row.get(1)?, + context: row.get(2)?, + gate: row.get(3)?, + due_date: row.get(4)?, + snooze_until: row.get(5)?, + status: ReminderStatus::from_str(&row.get::<_, String>(6)?).unwrap_or(ReminderStatus::Pending), + linked_doc_id: row.get(7)?, + created_at: row.get(8)?, + cleared_at: row.get(9)?, + resolution: row.get(10)?, + }) + })?; + + rows.collect::, _>>() + .map_err(StoreError::Database) + } + + /// Snooze a reminder + pub fn snooze_reminder(&self, id: i64, until: &str) -> Result<(), StoreError> { + self.with_retry(|| { + let updated = self.conn.execute( + "UPDATE reminders SET snooze_until = ?1, status = 'snoozed' WHERE id = ?2", + params![until, id], + )?; + if updated == 0 { + return Err(StoreError::NotFound(format!("reminder #{}", id))); + } + Ok(()) + }) + } + + /// Clear a reminder + pub fn clear_reminder(&self, id: i64, resolution: Option<&str>) -> Result<(), StoreError> { + self.with_retry(|| { + let now = chrono::Utc::now().to_rfc3339(); + let updated = self.conn.execute( + "UPDATE reminders SET status = 'cleared', cleared_at = ?1, resolution = ?2 WHERE id = ?3", + params![now, resolution, id], + )?; + if updated == 0 { + return Err(StoreError::NotFound(format!("reminder #{}", id))); + } + Ok(()) + }) + } } #[cfg(test)] diff --git a/crates/blue-core/src/voice.rs b/crates/blue-core/src/voice.rs index f57d299..f8b801f 100644 --- a/crates/blue-core/src/voice.rs +++ b/crates/blue-core/src/voice.rs @@ -34,6 +34,14 @@ pub fn ask(context: &str, question: &str) -> String { format!("{}. {}?", context, question) } +/// Format an informational message in Blue's voice +pub fn info(message: &str, detail: Option<&str>) -> String { + match detail { + Some(d) => format!("{}. {}", message, d), + None => message.to_string(), + } +} + /// The welcome message pub fn welcome() -> &'static str { r#"Welcome home. diff --git a/crates/blue-mcp/Cargo.toml b/crates/blue-mcp/Cargo.toml index 5cd0360..472dde3 100644 --- a/crates/blue-mcp/Cargo.toml +++ b/crates/blue-mcp/Cargo.toml @@ -15,3 +15,6 @@ tokio.workspace = true tracing.workspace = true chrono.workspace = true git2.workspace = true + +[dev-dependencies] +blue-core = { workspace = true, features = ["test-helpers"] } diff --git a/crates/blue-mcp/src/handlers/mod.rs b/crates/blue-mcp/src/handlers/mod.rs index f793cbe..29f839d 100644 --- a/crates/blue-mcp/src/handlers/mod.rs +++ b/crates/blue-mcp/src/handlers/mod.rs @@ -6,5 +6,7 @@ pub mod adr; pub mod decision; pub mod pr; pub mod release; +pub mod reminder; +pub mod session; pub mod spike; pub mod worktree; diff --git a/crates/blue-mcp/src/handlers/reminder.rs b/crates/blue-mcp/src/handlers/reminder.rs new file mode 100644 index 0000000..3445171 --- /dev/null +++ b/crates/blue-mcp/src/handlers/reminder.rs @@ -0,0 +1,311 @@ +//! Reminder tool handlers +//! +//! Handles reminder CRUD with gates, snoozing, and clearing. + +use blue_core::{DocType, ProjectState, Reminder, ReminderStatus}; +use serde_json::{json, Value}; + +use crate::error::ServerError; + +/// Handle blue_reminder_create +pub fn handle_create(state: &ProjectState, args: &Value) -> Result { + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let context = args.get("context").and_then(|v| v.as_str()); + let gate = args.get("gate").and_then(|v| v.as_str()); + let due_date = args.get("due_date").and_then(|v| v.as_str()); + let snooze_until = args.get("snooze_until").and_then(|v| v.as_str()); + let link_to = args.get("link_to").and_then(|v| v.as_str()); + + // Find linked document if specified + let linked_doc_id = if let Some(link_title) = link_to { + // Try RFC first, then spike, then others + let doc = state.store.find_document(DocType::Rfc, link_title) + .or_else(|_| state.store.find_document(DocType::Spike, link_title)) + .or_else(|_| state.store.find_document(DocType::Decision, link_title)); + + match doc { + Ok(d) => d.id, + Err(_) => { + return Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + &format!("Can't find document '{}'", link_title), + "Check the title's spelled right?" + ) + })); + } + } + } else { + None + }; + + let mut reminder = Reminder::new(title); + reminder.context = context.map(|s| s.to_string()); + reminder.gate = gate.map(|s| s.to_string()); + reminder.due_date = due_date.map(|s| s.to_string()); + reminder.snooze_until = snooze_until.map(|s| s.to_string()); + reminder.linked_doc_id = linked_doc_id; + + if snooze_until.is_some() { + reminder.status = ReminderStatus::Snoozed; + } + + match state.store.add_reminder(&reminder) { + Ok(id) => { + let hint = match (&reminder.gate, &reminder.due_date) { + (Some(g), Some(d)) => format!("Gate: '{}', Due: {}", g, d), + (Some(g), None) => format!("Gate: '{}'", g), + (None, Some(d)) => format!("Due: {}", d), + (None, None) => "No gate or due date set".to_string(), + }; + + Ok(json!({ + "status": "success", + "message": blue_core::voice::success( + &format!("Created reminder: '{}'", title), + Some(&hint) + ), + "reminder": { + "id": id, + "title": title, + "gate": reminder.gate, + "due_date": reminder.due_date, + "snooze_until": reminder.snooze_until, + "linked_to": link_to + } + })) + } + Err(e) => Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + "Couldn't create reminder", + &e.to_string() + ) + })), + } +} + +/// Handle blue_reminder_list +pub fn handle_list(state: &ProjectState, args: &Value) -> Result { + let status_filter = args.get("status").and_then(|v| v.as_str()); + let include_future = args.get("include_future").and_then(|v| v.as_bool()).unwrap_or(false); + + let status = status_filter.and_then(|s| match s { + "pending" => Some(ReminderStatus::Pending), + "snoozed" => Some(ReminderStatus::Snoozed), + "cleared" => Some(ReminderStatus::Cleared), + "all" => None, + _ => Some(ReminderStatus::Pending), + }); + + let reminders = state.store.list_reminders(status, include_future).unwrap_or_default(); + + if reminders.is_empty() { + let msg = match status_filter { + Some("cleared") => "No cleared reminders", + Some("snoozed") => "No snoozed reminders", + _ => "No pending reminders", + }; + return Ok(json!({ + "status": "success", + "message": blue_core::voice::info(msg, Some("Clear skies ahead!")), + "reminders": [] + })); + } + + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); + + let reminder_list: Vec = reminders + .iter() + .map(|r| { + let is_due = r.due_date.as_ref().map(|d| d <= &today).unwrap_or(false); + let is_overdue = r.due_date.as_ref().map(|d| d < &today).unwrap_or(false); + + json!({ + "id": r.id, + "title": r.title, + "context": r.context, + "gate": r.gate, + "due_date": r.due_date, + "snooze_until": r.snooze_until, + "status": r.status.as_str(), + "is_due": is_due, + "is_overdue": is_overdue + }) + }) + .collect(); + + let due_count = reminder_list.iter().filter(|r| r["is_due"].as_bool().unwrap_or(false)).count(); + let overdue_count = reminder_list.iter().filter(|r| r["is_overdue"].as_bool().unwrap_or(false)).count(); + + let hint = if overdue_count > 0 { + Some(format!("{} overdue!", overdue_count)) + } else if due_count > 0 { + Some(format!("{} due today", due_count)) + } else { + None + }; + + Ok(json!({ + "status": "success", + "message": blue_core::voice::info( + &format!("{} reminder{}", reminders.len(), if reminders.len() == 1 { "" } else { "s" }), + hint.as_deref() + ), + "reminders": reminder_list, + "due_count": due_count, + "overdue_count": overdue_count + })) +} + +/// Handle blue_reminder_snooze +pub fn handle_snooze(state: &ProjectState, args: &Value) -> Result { + let until = args + .get("until") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + // Find reminder by ID or title + let reminder = if let Some(id) = args.get("id").and_then(|v| v.as_i64()) { + state.store.get_reminder(id) + } else if let Some(title) = args.get("title").and_then(|v| v.as_str()) { + state.store.find_reminder(title) + } else { + return Err(ServerError::InvalidParams); + }; + + let reminder = match reminder { + Ok(r) => r, + Err(e) => { + return Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + "Can't find that reminder", + &e.to_string() + ) + })); + } + }; + + let id = reminder.id.unwrap(); + + match state.store.snooze_reminder(id, until) { + Ok(_) => Ok(json!({ + "status": "success", + "message": blue_core::voice::success( + &format!("Snoozed '{}' until {}", reminder.title, until), + Some("I'll remind you then!") + ), + "reminder": { + "id": id, + "title": reminder.title, + "snooze_until": until + } + })), + Err(e) => Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + "Couldn't snooze reminder", + &e.to_string() + ) + })), + } +} + +/// Handle blue_reminder_clear +pub fn handle_clear(state: &ProjectState, args: &Value) -> Result { + let resolution = args.get("resolution").and_then(|v| v.as_str()); + + // Find reminder by ID or title + let reminder = if let Some(id) = args.get("id").and_then(|v| v.as_i64()) { + state.store.get_reminder(id) + } else if let Some(title) = args.get("title").and_then(|v| v.as_str()) { + state.store.find_reminder(title) + } else { + return Err(ServerError::InvalidParams); + }; + + let reminder = match reminder { + Ok(r) => r, + Err(e) => { + return Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + "Can't find that reminder", + &e.to_string() + ) + })); + } + }; + + let id = reminder.id.unwrap(); + + match state.store.clear_reminder(id, resolution) { + Ok(_) => { + let hint = resolution.map(|r| format!("Resolution: {}", r)); + Ok(json!({ + "status": "success", + "message": blue_core::voice::success( + &format!("Cleared '{}'", reminder.title), + hint.as_deref() + ), + "reminder": { + "id": id, + "title": reminder.title, + "resolution": resolution + } + })) + } + Err(e) => Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + "Couldn't clear reminder", + &e.to_string() + ) + })), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_reminder() { + let state = ProjectState::for_test(); + let args = json!({ + "title": "Test reminder", + "context": "Some context", + "gate": "Wait for approval" + }); + + let result = handle_create(&state, &args).unwrap(); + assert_eq!(result["status"], "success"); + assert!(result["reminder"]["id"].is_number()); + } + + #[test] + fn test_list_empty() { + let state = ProjectState::for_test(); + let args = json!({}); + + let result = handle_list(&state, &args).unwrap(); + assert_eq!(result["status"], "success"); + assert_eq!(result["reminders"].as_array().unwrap().len(), 0); + } + + #[test] + fn test_snooze_requires_until() { + let state = ProjectState::for_test(); + let args = json!({ + "id": 1 + }); + + let result = handle_snooze(&state, &args); + assert!(result.is_err()); + } +} diff --git a/crates/blue-mcp/src/handlers/session.rs b/crates/blue-mcp/src/handlers/session.rs new file mode 100644 index 0000000..eb84be1 --- /dev/null +++ b/crates/blue-mcp/src/handlers/session.rs @@ -0,0 +1,243 @@ +//! Session tool handlers +//! +//! Handles session management for multi-agent coordination. + +use blue_core::{DocType, ProjectState, Session, SessionType}; +use serde_json::{json, Value}; + +use crate::error::ServerError; + +/// Handle blue_session_ping +pub fn handle_ping(state: &ProjectState, args: &Value) -> Result { + let title = args + .get("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 session_type_str = args + .get("session_type") + .and_then(|v| v.as_str()) + .unwrap_or("implementation"); + + let session_type = SessionType::from_str(session_type_str).unwrap_or(SessionType::Implementation); + + // Verify the RFC exists + let rfc = match state.store.find_document(DocType::Rfc, title) { + Ok(doc) => doc, + Err(_) => { + return Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + &format!("Can't find RFC '{}'", title), + "Check the title's spelled right?" + ) + })); + } + }; + + match action { + "start" => handle_start(state, &rfc.title, session_type), + "heartbeat" => handle_heartbeat(state, &rfc.title, session_type), + "end" => handle_end(state, &rfc.title), + _ => Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + &format!("Unknown action '{}'", action), + "Use 'start', 'heartbeat', or 'end'" + ) + })), + } +} + +fn handle_start(state: &ProjectState, title: &str, session_type: SessionType) -> Result { + // Check for existing session + match state.store.get_active_session(title) { + Ok(Some(existing)) => { + return Ok(json!({ + "status": "warning", + "message": blue_core::voice::info( + &format!("Session already active for '{}'", title), + Some(&format!("Started at {}, type: {}", existing.started_at, existing.session_type.as_str())) + ), + "session": { + "rfc_title": existing.rfc_title, + "session_type": existing.session_type.as_str(), + "started_at": existing.started_at, + "last_heartbeat": existing.last_heartbeat + } + })); + } + Ok(None) => {} + Err(e) => { + return Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + "Couldn't check for existing sessions", + &e.to_string() + ) + })); + } + } + + let session = Session { + id: None, + rfc_title: title.to_string(), + session_type, + started_at: String::new(), + last_heartbeat: String::new(), + ended_at: None, + }; + + match state.store.upsert_session(&session) { + Ok(_) => Ok(json!({ + "status": "success", + "message": blue_core::voice::success( + &format!("Started {} session for '{}'", session_type.as_str(), title), + Some("I'll keep an eye on things. Remember to send heartbeats!") + ), + "session": { + "rfc_title": title, + "session_type": session_type.as_str() + } + })), + Err(e) => Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + "Couldn't start session", + &e.to_string() + ) + })), + } +} + +fn handle_heartbeat(state: &ProjectState, title: &str, session_type: SessionType) -> Result { + let session = Session { + id: None, + rfc_title: title.to_string(), + session_type, + started_at: String::new(), + last_heartbeat: String::new(), + ended_at: None, + }; + + match state.store.upsert_session(&session) { + Ok(_) => Ok(json!({ + "status": "success", + "message": blue_core::voice::success( + &format!("Heartbeat recorded for '{}'", title), + None::<&str> + ), + "session": { + "rfc_title": title, + "session_type": session_type.as_str() + } + })), + Err(e) => Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + "Couldn't record heartbeat", + &e.to_string() + ) + })), + } +} + +fn handle_end(state: &ProjectState, title: &str) -> Result { + match state.store.end_session(title) { + Ok(_) => Ok(json!({ + "status": "success", + "message": blue_core::voice::success( + &format!("Session ended for '{}'", title), + Some("Good work! The RFC is free for others now.") + ) + })), + Err(e) => Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + &format!("No active session for '{}'", title), + &e.to_string() + ) + })), + } +} + +/// Handle blue_session_list (list active sessions) +pub fn handle_list(state: &ProjectState, _args: &Value) -> Result { + // First, clean up stale sessions (older than 5 minutes) + let cleaned = state.store.cleanup_stale_sessions(5).unwrap_or(0); + + let sessions = state.store.list_active_sessions().unwrap_or_default(); + + if sessions.is_empty() { + return Ok(json!({ + "status": "success", + "message": blue_core::voice::info( + "No active sessions", + Some("The workspace is quiet. Good time to start something!") + ), + "sessions": [], + "stale_cleaned": cleaned + })); + } + + let session_list: Vec = sessions + .iter() + .map(|s| { + json!({ + "rfc_title": s.rfc_title, + "session_type": s.session_type.as_str(), + "started_at": s.started_at, + "last_heartbeat": s.last_heartbeat + }) + }) + .collect(); + + Ok(json!({ + "status": "success", + "message": blue_core::voice::info( + &format!("{} active session{}", sessions.len(), if sessions.len() == 1 { "" } else { "s" }), + None::<&str> + ), + "sessions": session_list, + "stale_cleaned": cleaned + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_session_requires_rfc() { + let state = ProjectState::for_test(); + let args = json!({ + "title": "nonexistent-rfc", + "action": "start" + }); + + let result = handle_ping(&state, &args).unwrap(); + assert_eq!(result["status"], "error"); + } + + #[test] + fn test_session_invalid_action() { + let state = ProjectState::for_test(); + + // Create an RFC first + let doc = blue_core::Document::new(DocType::Rfc, "test-rfc", "draft"); + state.store.add_document(&doc).unwrap(); + + let args = json!({ + "title": "test-rfc", + "action": "invalid" + }); + + let result = handle_ping(&state, &args).unwrap(); + assert_eq!(result["status"], "error"); + } +} diff --git a/crates/blue-mcp/src/server.rs b/crates/blue-mcp/src/server.rs index 975495c..74d3bc3 100644 --- a/crates/blue-mcp/src/server.rs +++ b/crates/blue-mcp/src/server.rs @@ -597,6 +597,158 @@ impl BlueServer { } } } + }, + { + "name": "blue_session_ping", + "description": "Register or update session activity for an RFC.", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + }, + "title": { + "type": "string", + "description": "RFC title being worked on" + }, + "action": { + "type": "string", + "description": "Session action to perform", + "enum": ["start", "heartbeat", "end"] + }, + "session_type": { + "type": "string", + "description": "Type of work being done (default: implementation)", + "enum": ["implementation", "review", "testing"] + } + }, + "required": ["title", "action"] + } + }, + { + "name": "blue_session_list", + "description": "List active sessions on RFCs.", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + } + } + } + }, + { + "name": "blue_reminder_create", + "description": "Create a gated reminder with optional time and condition triggers.", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + }, + "title": { + "type": "string", + "description": "Short reminder title" + }, + "context": { + "type": "string", + "description": "Additional notes/context" + }, + "gate": { + "type": "string", + "description": "Condition that must be met (optional)" + }, + "due_date": { + "type": "string", + "description": "Target date YYYY-MM-DD (optional)" + }, + "snooze_until": { + "type": "string", + "description": "Don't show until this date YYYY-MM-DD (optional)" + }, + "link_to": { + "type": "string", + "description": "RFC/spike/decision title to link" + } + }, + "required": ["title"] + } + }, + { + "name": "blue_reminder_list", + "description": "List reminders with optional filters.", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + }, + "status": { + "type": "string", + "description": "Filter by status (default: pending)", + "enum": ["pending", "snoozed", "cleared", "all"] + }, + "include_future": { + "type": "boolean", + "description": "Include snoozed items not yet due (default: false)" + } + } + } + }, + { + "name": "blue_reminder_snooze", + "description": "Snooze a reminder until a specific date.", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + }, + "id": { + "type": "number", + "description": "Reminder ID" + }, + "title": { + "type": "string", + "description": "Or match by title (partial match)" + }, + "until": { + "type": "string", + "description": "New snooze date (YYYY-MM-DD)" + } + }, + "required": ["until"] + } + }, + { + "name": "blue_reminder_clear", + "description": "Clear a reminder (mark gate as resolved).", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + }, + "id": { + "type": "number", + "description": "Reminder ID" + }, + "title": { + "type": "string", + "description": "Or match by title (partial match)" + }, + "resolution": { + "type": "string", + "description": "How the gate was resolved" + } + } + } } ] })) @@ -641,6 +793,13 @@ impl BlueServer { "blue_pr_check_approvals" => self.handle_pr_check_approvals(&call.arguments), "blue_pr_merge" => self.handle_pr_merge(&call.arguments), "blue_release_create" => self.handle_release_create(&call.arguments), + // Phase 4: Session and Reminder handlers + "blue_session_ping" => self.handle_session_ping(&call.arguments), + "blue_session_list" => self.handle_session_list(&call.arguments), + "blue_reminder_create" => self.handle_reminder_create(&call.arguments), + "blue_reminder_list" => self.handle_reminder_list(&call.arguments), + "blue_reminder_snooze" => self.handle_reminder_snooze(&call.arguments), + "blue_reminder_clear" => self.handle_reminder_clear(&call.arguments), _ => Err(ServerError::ToolNotFound(call.name)), }?; @@ -1108,6 +1267,46 @@ impl BlueServer { let state = self.ensure_state()?; crate::handlers::release::handle_create(state, args) } + + // Phase 4: Session and Reminder handlers + + fn handle_session_ping(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state()?; + crate::handlers::session::handle_ping(state, args) + } + + fn handle_session_list(&mut self, args: &Option) -> Result { + let empty = json!({}); + let args = args.as_ref().unwrap_or(&empty); + let state = self.ensure_state()?; + crate::handlers::session::handle_list(state, args) + } + + fn handle_reminder_create(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state()?; + crate::handlers::reminder::handle_create(state, args) + } + + fn handle_reminder_list(&mut self, args: &Option) -> Result { + let empty = json!({}); + let args = args.as_ref().unwrap_or(&empty); + let state = self.ensure_state()?; + crate::handlers::reminder::handle_list(state, args) + } + + fn handle_reminder_snooze(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state()?; + crate::handlers::reminder::handle_snooze(state, args) + } + + fn handle_reminder_clear(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state()?; + crate::handlers::reminder::handle_clear(state, args) + } } impl Default for BlueServer { diff --git a/docs/rfcs/0002-port-coherence-functionality.md b/docs/rfcs/0002-port-coherence-functionality.md index 594bfbd..8cc8114 100644 --- a/docs/rfcs/0002-port-coherence-functionality.md +++ b/docs/rfcs/0002-port-coherence-functionality.md @@ -218,13 +218,24 @@ blue/ - [x] Blue's voice in all error messages - [x] 16 tests passing -### Phase 4: Pending +### Phase 4: Session and Reminders - COMPLETE + +- [x] store.rs - Added session and reminder tables, schema v2 +- [x] handlers/session.rs - Session ping (start/heartbeat/end) + list +- [x] handlers/reminder.rs - Reminder CRUD with gates, snoozing, clearing +- [x] voice.rs - Added info() function for informational messages +- [x] 6 new MCP tools: blue_session_ping, blue_session_list, + blue_reminder_create, blue_reminder_list, blue_reminder_snooze, + blue_reminder_clear +- [x] Total: 28 MCP tools +- [x] Blue's voice in all error messages +- [x] 21 tests passing + +### Phase 5: Pending Remaining tools to port: - Staging environment tools -- Session management - Code search/indexing -- Reminders ## Test Plan