feat(realm): Implement RFC 0002 Phase 2 session tools

Add MCP tools for session lifecycle management:
- session_start: Begin work session with realm context detection
- session_stop: End session with duration and activity summary

Session state stored in .blue/session file tracks:
- Session ID, realm, repo
- Active RFC being worked on
- Active domains (auto-detected from bindings)
- Contracts being modified (exports) and watched (imports)

Implementation details:
- SessionState struct with save/load/delete methods
- Automatic domain and contract detection from realm bindings
- Duration calculation on session stop
- 6 new tests for session functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Garcia 2026-01-24 07:27:15 -05:00
parent daaaea5c82
commit aba92d6f06
4 changed files with 381 additions and 6 deletions

View file

@ -9,6 +9,7 @@ description = "MCP server - Blue's voice"
blue-core.workspace = true blue-core.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
serde_yaml.workspace = true
thiserror.workspace = true thiserror.workspace = true
anyhow.workspace = true anyhow.workspace = true
tokio.workspace = true tokio.workspace = true

View file

@ -1,12 +1,20 @@
//! Realm MCP tool handlers //! Realm MCP tool handlers
//! //!
//! Implements RFC 0002: Realm MCP Integration (Phase 1) //! Implements RFC 0002: Realm MCP Integration
//!
//! Phase 1:
//! - realm_status: Get realm overview //! - realm_status: Get realm overview
//! - realm_check: Validate contracts/bindings //! - realm_check: Validate contracts/bindings
//! - contract_get: Get contract details //! - contract_get: Get contract details
//!
//! Phase 2:
//! - session_start: Begin work session
//! - session_stop: End session with summary
use blue_core::daemon::DaemonPaths; use blue_core::daemon::DaemonPaths;
use blue_core::realm::{LocalRepoConfig, RealmService}; use blue_core::realm::{LocalRepoConfig, RealmService};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::path::Path; use std::path::Path;
@ -290,6 +298,229 @@ pub fn handle_contract_get(
})) }))
} }
// ─── Phase 2: Session Tools ─────────────────────────────────────────────────
/// Session state stored in .blue/session
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionState {
pub id: String,
pub realm: String,
pub repo: String,
pub started_at: DateTime<Utc>,
pub last_activity: DateTime<Utc>,
#[serde(default)]
pub active_rfc: Option<String>,
#[serde(default)]
pub active_domains: Vec<String>,
#[serde(default)]
pub contracts_modified: Vec<String>,
#[serde(default)]
pub contracts_watched: Vec<String>,
}
impl SessionState {
/// Load session from .blue/session file
pub fn load(cwd: &Path) -> Option<Self> {
let session_path = cwd.join(".blue").join("session");
if !session_path.exists() {
return None;
}
let content = std::fs::read_to_string(&session_path).ok()?;
serde_yaml::from_str(&content).ok()
}
/// Save session to .blue/session file
pub fn save(&self, cwd: &Path) -> Result<(), ServerError> {
let blue_dir = cwd.join(".blue");
if !blue_dir.exists() {
return Err(ServerError::NotFound(
"Not in a realm repo. No .blue directory.".to_string(),
));
}
let session_path = blue_dir.join("session");
let content = serde_yaml::to_string(self)
.map_err(|e| ServerError::CommandFailed(format!("Failed to serialize session: {}", e)))?;
std::fs::write(&session_path, content)
.map_err(|e| ServerError::CommandFailed(format!("Failed to write session: {}", e)))?;
Ok(())
}
/// Delete session file
pub fn delete(cwd: &Path) -> Result<(), ServerError> {
let session_path = cwd.join(".blue").join("session");
if session_path.exists() {
std::fs::remove_file(&session_path).map_err(|e| {
ServerError::CommandFailed(format!("Failed to delete session: {}", e))
})?;
}
Ok(())
}
}
/// Generate a unique session ID
fn generate_session_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("sess-{:x}", timestamp)
}
/// Handle session_start - begin work session
pub fn handle_session_start(
cwd: Option<&Path>,
active_rfc: Option<&str>,
) -> Result<Value, ServerError> {
let cwd = cwd.ok_or(ServerError::InvalidParams)?;
let ctx = detect_context(Some(cwd))?;
// Check for existing session
if let Some(existing) = SessionState::load(cwd) {
return Ok(json!({
"status": "warning",
"message": "Session already active",
"session": {
"id": existing.id,
"realm": existing.realm,
"repo": existing.repo,
"started_at": existing.started_at.to_rfc3339(),
"active_rfc": existing.active_rfc,
"active_domains": existing.active_domains
},
"next_steps": ["Use session_stop to end the current session first"]
}));
}
// Determine active domains from repo's bindings
let details = ctx.service.load_realm_details(&ctx.realm_name).map_err(|e| {
ServerError::CommandFailed(format!("Failed to load realm: {}", e))
})?;
let active_domains: Vec<String> = details
.domains
.iter()
.filter(|d| d.bindings.iter().any(|b| b.repo == ctx.repo_name))
.map(|d| d.domain.name.clone())
.collect();
// Determine contracts we're watching (imports) and could modify (exports)
let mut contracts_watched = Vec::new();
let mut contracts_modified = Vec::new();
for domain in &details.domains {
for binding in &domain.bindings {
if binding.repo == ctx.repo_name {
for import in &binding.imports {
contracts_watched.push(format!("{}/{}", domain.domain.name, import.contract));
}
for export in &binding.exports {
contracts_modified.push(format!("{}/{}", domain.domain.name, export.contract));
}
}
}
}
let now = Utc::now();
let session = SessionState {
id: generate_session_id(),
realm: ctx.realm_name.clone(),
repo: ctx.repo_name.clone(),
started_at: now,
last_activity: now,
active_rfc: active_rfc.map(String::from),
active_domains: active_domains.clone(),
contracts_modified: contracts_modified.clone(),
contracts_watched: contracts_watched.clone(),
};
session.save(cwd)?;
// Build next steps
let mut next_steps = Vec::new();
if !contracts_watched.is_empty() {
next_steps.push(format!(
"Watching {} imported contract{}",
contracts_watched.len(),
if contracts_watched.len() == 1 { "" } else { "s" }
));
}
if active_rfc.is_none() {
next_steps.push("Consider setting active_rfc to track which RFC you're working on".to_string());
}
next_steps.push("Use session_stop when done to get a summary".to_string());
Ok(json!({
"status": "success",
"message": "Session started",
"session": {
"id": session.id,
"realm": session.realm,
"repo": session.repo,
"started_at": session.started_at.to_rfc3339(),
"active_rfc": session.active_rfc,
"active_domains": session.active_domains,
"contracts_modified": contracts_modified,
"contracts_watched": contracts_watched
},
"notifications": [],
"next_steps": next_steps
}))
}
/// Handle session_stop - end session with summary
pub fn handle_session_stop(cwd: Option<&Path>) -> Result<Value, ServerError> {
let cwd = cwd.ok_or(ServerError::InvalidParams)?;
// Load existing session
let session = SessionState::load(cwd).ok_or_else(|| {
ServerError::NotFound("No active session. Nothing to stop.".to_string())
})?;
// Calculate session duration
let duration = Utc::now().signed_duration_since(session.started_at);
let duration_str = if duration.num_hours() > 0 {
format!("{}h {}m", duration.num_hours(), duration.num_minutes() % 60)
} else if duration.num_minutes() > 0 {
format!("{}m", duration.num_minutes())
} else {
format!("{}s", duration.num_seconds())
};
// Delete the session file
SessionState::delete(cwd)?;
// Build summary
let summary = json!({
"id": session.id,
"realm": session.realm,
"repo": session.repo,
"started_at": session.started_at.to_rfc3339(),
"ended_at": Utc::now().to_rfc3339(),
"duration": duration_str,
"active_rfc": session.active_rfc,
"active_domains": session.active_domains,
"contracts_modified": session.contracts_modified,
"contracts_watched": session.contracts_watched
});
Ok(json!({
"status": "success",
"message": format!("Session ended after {}", duration_str),
"summary": summary,
"next_steps": ["Start a new session with session_start when you're ready to work again"]
}))
}
/// Get current session if one exists (for other tools to check)
pub fn get_current_session(cwd: Option<&Path>) -> Option<SessionState> {
cwd.and_then(SessionState::load)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -336,4 +567,96 @@ repo: test-repo
} }
} }
} }
// Phase 2: Session tests
#[test]
fn test_session_state_save_load() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
let blue_dir = path.join(".blue");
std::fs::create_dir_all(&blue_dir).unwrap();
let session = SessionState {
id: "test-session-123".to_string(),
realm: "test-realm".to_string(),
repo: "test-repo".to_string(),
started_at: Utc::now(),
last_activity: Utc::now(),
active_rfc: Some("my-rfc".to_string()),
active_domains: vec!["domain-1".to_string()],
contracts_modified: vec!["domain-1/contract-a".to_string()],
contracts_watched: vec!["domain-1/contract-b".to_string()],
};
// Save
session.save(&path).unwrap();
// Verify file exists
assert!(blue_dir.join("session").exists());
// Load
let loaded = SessionState::load(&path).unwrap();
assert_eq!(loaded.id, "test-session-123");
assert_eq!(loaded.realm, "test-realm");
assert_eq!(loaded.repo, "test-repo");
assert_eq!(loaded.active_rfc, Some("my-rfc".to_string()));
assert_eq!(loaded.active_domains, vec!["domain-1".to_string()]);
}
#[test]
fn test_session_state_delete() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
let blue_dir = path.join(".blue");
std::fs::create_dir_all(&blue_dir).unwrap();
// Create session file
let session = SessionState {
id: "to-delete".to_string(),
realm: "test-realm".to_string(),
repo: "test-repo".to_string(),
started_at: Utc::now(),
last_activity: Utc::now(),
active_rfc: None,
active_domains: vec![],
contracts_modified: vec![],
contracts_watched: vec![],
};
session.save(&path).unwrap();
assert!(blue_dir.join("session").exists());
// Delete
SessionState::delete(&path).unwrap();
assert!(!blue_dir.join("session").exists());
}
#[test]
fn test_session_state_load_nonexistent() {
let tmp = TempDir::new().unwrap();
let result = SessionState::load(tmp.path());
assert!(result.is_none());
}
#[test]
fn test_generate_session_id() {
let id1 = generate_session_id();
let id2 = generate_session_id();
assert!(id1.starts_with("sess-"));
assert!(id2.starts_with("sess-"));
// IDs should be unique (different timestamps)
// Note: Could be same if generated within same millisecond
}
#[test]
fn test_session_stop_no_session() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().to_path_buf();
let blue_dir = path.join(".blue");
std::fs::create_dir_all(&blue_dir).unwrap();
let result = handle_session_stop(Some(&path));
assert!(result.is_err());
}
} }

View file

@ -1408,6 +1408,39 @@ impl BlueServer {
}, },
"required": ["cwd", "domain", "contract"] "required": ["cwd", "domain", "contract"]
} }
},
// Phase 2: Session tools (RFC 0002)
{
"name": "session_start",
"description": "Begin a work session. Tracks active realm, repo, domains, and contracts being modified or watched. Returns session ID and context.",
"inputSchema": {
"type": "object",
"properties": {
"cwd": {
"type": "string",
"description": "Current working directory (must be in a realm repo)"
},
"active_rfc": {
"type": "string",
"description": "Optional RFC title being worked on"
}
},
"required": ["cwd"]
}
},
{
"name": "session_stop",
"description": "End the current work session. Returns summary including duration, domains touched, and contracts modified.",
"inputSchema": {
"type": "object",
"properties": {
"cwd": {
"type": "string",
"description": "Current working directory (must be in a realm repo)"
}
},
"required": ["cwd"]
}
} }
] ]
})) }))
@ -1501,6 +1534,8 @@ impl BlueServer {
"realm_status" => self.handle_realm_status(&call.arguments), "realm_status" => self.handle_realm_status(&call.arguments),
"realm_check" => self.handle_realm_check(&call.arguments), "realm_check" => self.handle_realm_check(&call.arguments),
"contract_get" => self.handle_contract_get(&call.arguments), "contract_get" => self.handle_contract_get(&call.arguments),
"session_start" => self.handle_session_start(&call.arguments),
"session_stop" => self.handle_session_stop(&call.arguments),
_ => Err(ServerError::ToolNotFound(call.name)), _ => Err(ServerError::ToolNotFound(call.name)),
}?; }?;
@ -2224,6 +2259,20 @@ impl BlueServer {
.ok_or(ServerError::InvalidParams)?; .ok_or(ServerError::InvalidParams)?;
crate::handlers::realm::handle_contract_get(self.cwd.as_deref(), domain, contract) crate::handlers::realm::handle_contract_get(self.cwd.as_deref(), domain, contract)
} }
// Phase 2: Session handlers (RFC 0002)
fn handle_session_start(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let active_rfc = args
.as_ref()
.and_then(|a| a.get("active_rfc"))
.and_then(|v| v.as_str());
crate::handlers::realm::handle_session_start(self.cwd.as_deref(), active_rfc)
}
fn handle_session_stop(&mut self, _args: &Option<Value>) -> Result<Value, ServerError> {
crate::handlers::realm::handle_session_stop(self.cwd.as_deref())
}
} }
impl Default for BlueServer { impl Default for BlueServer {

View file

@ -178,15 +178,16 @@ All tools return `next_steps` suggestions based on state:
## Implementation Phases ## Implementation Phases
### Phase 1: Core Tools ### Phase 1: Core Tools
- `realm_status`, `realm_check`, `contract_get` - `realm_status`, `realm_check`, `contract_get`
- Context detection from cwd - Context detection from cwd
- Middleware for notification injection - Middleware for notification injection (deferred to Phase 4)
### Phase 2: Session Tools ### Phase 2: Session Tools
- `session_start`, `session_stop` - `session_start`, `session_stop`
- Session-scoped context - Session-scoped context via `.blue/session` file
- Notification lifecycle (pending → seen) - Tracks active RFC, domains, contracts modified/watched
- Daemon integration deferred to Phase 4
### Phase 3: Workflow Tools ### Phase 3: Workflow Tools
- `worktree_create` with domain peer selection - `worktree_create` with domain peer selection
@ -196,6 +197,7 @@ All tools return `next_steps` suggestions based on state:
- `notifications_list` with state filters - `notifications_list` with state filters
- Schema hash detection in `realm_check` - Schema hash detection in `realm_check`
- 7-day expiration cleanup - 7-day expiration cleanup
- Daemon integration for session registration
--- ---