feat: Phase 4 - session and reminder tools
Add multi-agent session coordination and reminder management: - store.rs: Added sessions and reminders tables (schema v2) - handlers/session.rs: Session ping (start/heartbeat/end) + list - handlers/reminder.rs: Reminder CRUD with gates, snoozing, clearing - voice.rs: Added info() function for informational messages - state.rs: Added for_test() helper with test-helpers feature New MCP tools (6): - blue_session_ping, blue_session_list - blue_reminder_create, blue_reminder_list - blue_reminder_snooze, blue_reminder_clear Total: 28 MCP tools, 21 tests passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
09e7c89c1b
commit
1c2ceb71d1
11 changed files with 1237 additions and 5 deletions
|
|
@ -5,6 +5,9 @@ edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
description = "Core data structures and logic for Blue"
|
description = "Core data structures and logic for Blue"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
test-helpers = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,6 @@ pub mod workflow;
|
||||||
pub use documents::*;
|
pub use documents::*;
|
||||||
pub use repo::{detect_blue, BlueHome, RepoError, WorktreeInfo};
|
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, 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 voice::*;
|
||||||
pub use workflow::{PrdStatus, RfcStatus, SpikeOutcome as WorkflowSpikeOutcome, SpikeStatus, WorkflowError};
|
pub use workflow::{PrdStatus, RfcStatus, SpikeOutcome as WorkflowSpikeOutcome, SpikeStatus, WorkflowError};
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,26 @@ pub struct ProjectState {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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
|
/// Load project state
|
||||||
pub fn load(home: BlueHome, project: &str) -> Result<Self, StateError> {
|
pub fn load(home: BlueHome, project: &str) -> Result<Self, StateError> {
|
||||||
let db_path = home.db_path(project);
|
let db_path = home.db_path(project);
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBe
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
/// Current schema version
|
/// Current schema version
|
||||||
const SCHEMA_VERSION: i32 = 1;
|
const SCHEMA_VERSION: i32 = 2;
|
||||||
|
|
||||||
/// Core database schema
|
/// Core database schema
|
||||||
const SCHEMA: &str = r#"
|
const SCHEMA: &str = r#"
|
||||||
|
|
@ -73,6 +73,36 @@ const SCHEMA: &str = r#"
|
||||||
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE,
|
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
UNIQUE(document_id, key)
|
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
|
/// FTS5 schema for full-text search
|
||||||
|
|
@ -237,6 +267,105 @@ pub struct SearchResult {
|
||||||
pub snippet: Option<String>,
|
pub snippet: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<Self> {
|
||||||
|
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<i64>,
|
||||||
|
pub rfc_title: String,
|
||||||
|
pub session_type: SessionType,
|
||||||
|
pub started_at: String,
|
||||||
|
pub last_heartbeat: String,
|
||||||
|
pub ended_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Self> {
|
||||||
|
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<i64>,
|
||||||
|
pub title: String,
|
||||||
|
pub context: Option<String>,
|
||||||
|
pub gate: Option<String>,
|
||||||
|
pub due_date: Option<String>,
|
||||||
|
pub snooze_until: Option<String>,
|
||||||
|
pub status: ReminderStatus,
|
||||||
|
pub linked_doc_id: Option<i64>,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
pub cleared_at: Option<String>,
|
||||||
|
pub resolution: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
/// Store errors - in Blue's voice
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum StoreError {
|
pub enum StoreError {
|
||||||
|
|
@ -936,6 +1065,309 @@ impl DocumentStore {
|
||||||
rows.collect::<Result<Vec<_>, _>>()
|
rows.collect::<Result<Vec<_>, _>>()
|
||||||
.map_err(StoreError::Database)
|
.map_err(StoreError::Database)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Session Operations ====================
|
||||||
|
|
||||||
|
/// Start or update a session
|
||||||
|
pub fn upsert_session(&self, session: &Session) -> Result<i64, StoreError> {
|
||||||
|
self.with_retry(|| {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
// Try to get existing session
|
||||||
|
let existing: Option<i64> = 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<Option<Session>, 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<Vec<Session>, 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::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(StoreError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clean up stale sessions (no heartbeat in 5+ minutes)
|
||||||
|
pub fn cleanup_stale_sessions(&self, timeout_minutes: i64) -> Result<usize, StoreError> {
|
||||||
|
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<i64, StoreError> {
|
||||||
|
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<Reminder, StoreError> {
|
||||||
|
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<Reminder, StoreError> {
|
||||||
|
// 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<ReminderStatus>, include_future: bool) -> Result<Vec<Reminder>, 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::<Result<Vec<_>, _>>()
|
||||||
|
.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)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,14 @@ pub fn ask(context: &str, question: &str) -> String {
|
||||||
format!("{}. {}?", context, question)
|
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
|
/// The welcome message
|
||||||
pub fn welcome() -> &'static str {
|
pub fn welcome() -> &'static str {
|
||||||
r#"Welcome home.
|
r#"Welcome home.
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,6 @@ tokio.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
git2.workspace = true
|
git2.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
blue-core = { workspace = true, features = ["test-helpers"] }
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,7 @@ pub mod adr;
|
||||||
pub mod decision;
|
pub mod decision;
|
||||||
pub mod pr;
|
pub mod pr;
|
||||||
pub mod release;
|
pub mod release;
|
||||||
|
pub mod reminder;
|
||||||
|
pub mod session;
|
||||||
pub mod spike;
|
pub mod spike;
|
||||||
pub mod worktree;
|
pub mod worktree;
|
||||||
|
|
|
||||||
311
crates/blue-mcp/src/handlers/reminder.rs
Normal file
311
crates/blue-mcp/src/handlers/reminder.rs
Normal file
|
|
@ -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<Value, ServerError> {
|
||||||
|
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<Value, ServerError> {
|
||||||
|
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<Value> = 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<Value, ServerError> {
|
||||||
|
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<Value, ServerError> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
243
crates/blue-mcp/src/handlers/session.rs
Normal file
243
crates/blue-mcp/src/handlers/session.rs
Normal file
|
|
@ -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<Value, ServerError> {
|
||||||
|
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<Value, ServerError> {
|
||||||
|
// 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<Value, ServerError> {
|
||||||
|
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<Value, ServerError> {
|
||||||
|
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<Value, ServerError> {
|
||||||
|
// 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<Value> = 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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_check_approvals" => self.handle_pr_check_approvals(&call.arguments),
|
||||||
"blue_pr_merge" => self.handle_pr_merge(&call.arguments),
|
"blue_pr_merge" => self.handle_pr_merge(&call.arguments),
|
||||||
"blue_release_create" => self.handle_release_create(&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)),
|
_ => Err(ServerError::ToolNotFound(call.name)),
|
||||||
}?;
|
}?;
|
||||||
|
|
||||||
|
|
@ -1108,6 +1267,46 @@ impl BlueServer {
|
||||||
let state = self.ensure_state()?;
|
let state = self.ensure_state()?;
|
||||||
crate::handlers::release::handle_create(state, args)
|
crate::handlers::release::handle_create(state, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 4: Session and Reminder handlers
|
||||||
|
|
||||||
|
fn handle_session_ping(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||||
|
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<Value>) -> Result<Value, ServerError> {
|
||||||
|
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<Value>) -> Result<Value, ServerError> {
|
||||||
|
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<Value>) -> Result<Value, ServerError> {
|
||||||
|
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<Value>) -> Result<Value, ServerError> {
|
||||||
|
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<Value>) -> Result<Value, ServerError> {
|
||||||
|
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 {
|
impl Default for BlueServer {
|
||||||
|
|
|
||||||
|
|
@ -218,13 +218,24 @@ blue/
|
||||||
- [x] Blue's voice in all error messages
|
- [x] Blue's voice in all error messages
|
||||||
- [x] 16 tests passing
|
- [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:
|
Remaining tools to port:
|
||||||
- Staging environment tools
|
- Staging environment tools
|
||||||
- Session management
|
|
||||||
- Code search/indexing
|
- Code search/indexing
|
||||||
- Reminders
|
|
||||||
|
|
||||||
## Test Plan
|
## Test Plan
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue