diff --git a/crates/blue-core/src/store.rs b/crates/blue-core/src/store.rs index 30f1805..23f3246 100644 --- a/crates/blue-core/src/store.rs +++ b/crates/blue-core/src/store.rs @@ -124,6 +124,24 @@ const SCHEMA: &str = r#" CREATE INDEX IF NOT EXISTS idx_staging_locks_resource ON staging_locks(resource); CREATE INDEX IF NOT EXISTS idx_staging_queue_resource ON staging_lock_queue(resource); + + CREATE TABLE IF NOT EXISTS staging_deployments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + iac_type TEXT NOT NULL, + deploy_command TEXT NOT NULL, + stacks TEXT, + deployed_by TEXT NOT NULL, + agent_id TEXT, + deployed_at TEXT NOT NULL, + ttl_expires_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'deployed', + destroyed_at TEXT, + metadata TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_staging_deployments_status ON staging_deployments(status); + CREATE INDEX IF NOT EXISTS idx_staging_deployments_expires ON staging_deployments(ttl_expires_at); "#; /// FTS5 schema for full-text search @@ -429,6 +447,45 @@ pub enum StagingLockResult { }, } +/// A tracked staging deployment with TTL +#[derive(Debug, Clone)] +pub struct StagingDeployment { + pub id: Option, + pub name: String, + pub iac_type: String, + pub deploy_command: String, + pub stacks: Option, + pub deployed_by: String, + pub agent_id: Option, + pub deployed_at: String, + pub ttl_expires_at: String, + pub status: String, + pub destroyed_at: Option, + pub metadata: Option, +} + +/// Result of staging resource cleanup operation +#[derive(Debug, Clone)] +pub struct StagingCleanupResult { + /// Number of expired locks deleted + pub locks_cleaned: usize, + /// Number of orphaned queue entries deleted + pub queue_entries_cleaned: usize, + /// Number of deployments marked as expired + pub deployments_marked_expired: usize, + /// Deployments that are expired but not yet destroyed + pub expired_deployments_pending_destroy: Vec, +} + +/// Info about an expired deployment that needs to be destroyed +#[derive(Debug, Clone)] +pub struct ExpiredDeploymentInfo { + pub name: String, + pub iac_type: String, + pub deploy_command: String, + pub stacks: Option, +} + /// Store errors - in Blue's voice #[derive(Debug, thiserror::Error)] pub enum StoreError { @@ -1640,6 +1697,227 @@ impl DocumentStore { Ok((locks_cleaned, queue_cleaned)) }) } + + // ===== Staging Deployments ===== + + /// Record a new staging deployment + pub fn record_staging_deployment( + &self, + name: &str, + iac_type: &str, + deploy_command: &str, + stacks: Option<&str>, + deployed_by: &str, + agent_id: Option<&str>, + ttl_hours: u32, + metadata: Option<&str>, + ) -> Result { + self.with_retry(|| { + let now = chrono::Utc::now(); + let ttl_expires = now + chrono::Duration::hours(ttl_hours as i64); + + self.conn.execute( + "INSERT OR REPLACE INTO staging_deployments + (name, iac_type, deploy_command, stacks, deployed_by, agent_id, deployed_at, ttl_expires_at, status, metadata) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'deployed', ?9)", + params![ + name, + iac_type, + deploy_command, + stacks, + deployed_by, + agent_id, + now.to_rfc3339(), + ttl_expires.to_rfc3339(), + metadata + ], + )?; + + Ok(StagingDeployment { + id: Some(self.conn.last_insert_rowid()), + name: name.to_string(), + iac_type: iac_type.to_string(), + deploy_command: deploy_command.to_string(), + stacks: stacks.map(|s| s.to_string()), + deployed_by: deployed_by.to_string(), + agent_id: agent_id.map(|s| s.to_string()), + deployed_at: now.to_rfc3339(), + ttl_expires_at: ttl_expires.to_rfc3339(), + status: "deployed".to_string(), + destroyed_at: None, + metadata: metadata.map(|s| s.to_string()), + }) + }) + } + + /// List all staging deployments, optionally filtered by status + pub fn list_staging_deployments( + &self, + status: Option<&str>, + ) -> Result, StoreError> { + let query = if status.is_some() { + "SELECT id, name, iac_type, deploy_command, stacks, deployed_by, agent_id, + deployed_at, ttl_expires_at, status, destroyed_at, metadata + FROM staging_deployments WHERE status = ?1 ORDER BY deployed_at DESC" + } else { + "SELECT id, name, iac_type, deploy_command, stacks, deployed_by, agent_id, + deployed_at, ttl_expires_at, status, destroyed_at, metadata + FROM staging_deployments ORDER BY deployed_at DESC" + }; + + let mut stmt = self.conn.prepare(query)?; + + let rows = if let Some(s) = status { + stmt.query_map(params![s], Self::map_staging_deployment)? + } else { + stmt.query_map([], Self::map_staging_deployment)? + }; + + rows.collect::, _>>() + .map_err(StoreError::Database) + } + + /// Get expired deployments that need cleanup + pub fn get_expired_deployments(&self) -> Result, StoreError> { + let now = chrono::Utc::now().to_rfc3339(); + + let mut stmt = self.conn.prepare( + "SELECT id, name, iac_type, deploy_command, stacks, deployed_by, agent_id, + deployed_at, ttl_expires_at, status, destroyed_at, metadata + FROM staging_deployments + WHERE status = 'deployed' AND ttl_expires_at < ?1 + ORDER BY ttl_expires_at", + )?; + + let rows = stmt.query_map(params![now], Self::map_staging_deployment)?; + + rows.collect::, _>>() + .map_err(StoreError::Database) + } + + /// Mark a deployment as destroyed + pub fn mark_deployment_destroyed(&self, name: &str) -> Result<(), StoreError> { + self.with_retry(|| { + let now = chrono::Utc::now().to_rfc3339(); + + let updated = self.conn.execute( + "UPDATE staging_deployments SET status = 'destroyed', destroyed_at = ?1 + WHERE name = ?2 AND status = 'deployed'", + params![now, name], + )?; + + if updated == 0 { + return Err(StoreError::NotFound(format!( + "Deployment '{}' not found or already destroyed", + name + ))); + } + + Ok(()) + }) + } + + /// Mark expired deployments as expired (for auto-cleanup tracking) + pub fn mark_expired_deployments(&self) -> Result, StoreError> { + let expired = self.get_expired_deployments()?; + + self.with_retry(|| { + let now = chrono::Utc::now().to_rfc3339(); + self.conn.execute( + "UPDATE staging_deployments SET status = 'expired' + WHERE status = 'deployed' AND ttl_expires_at < ?1", + params![now], + )?; + Ok(()) + })?; + + Ok(expired) + } + + /// Get a specific deployment by name + pub fn get_staging_deployment( + &self, + name: &str, + ) -> Result, StoreError> { + self.conn + .query_row( + "SELECT id, name, iac_type, deploy_command, stacks, deployed_by, agent_id, + deployed_at, ttl_expires_at, status, destroyed_at, metadata + FROM staging_deployments WHERE name = ?1", + params![name], + Self::map_staging_deployment, + ) + .optional() + .map_err(StoreError::Database) + } + + /// Helper to map a row to StagingDeployment + fn map_staging_deployment(row: &rusqlite::Row) -> rusqlite::Result { + Ok(StagingDeployment { + id: Some(row.get(0)?), + name: row.get(1)?, + iac_type: row.get(2)?, + deploy_command: row.get(3)?, + stacks: row.get(4)?, + deployed_by: row.get(5)?, + agent_id: row.get(6)?, + deployed_at: row.get(7)?, + ttl_expires_at: row.get(8)?, + status: row.get(9)?, + destroyed_at: row.get(10)?, + metadata: row.get(11)?, + }) + } + + /// Clean up all expired staging resources (locks, deployments, queue entries) + pub fn cleanup_expired_staging_resources(&self) -> Result { + let now = chrono::Utc::now().to_rfc3339(); + + // Clean up expired locks + let locks_cleaned = self.conn.execute( + "DELETE FROM staging_locks WHERE expires_at < ?1", + params![now], + )?; + + // Clean up orphaned queue entries + let queue_cleaned = self.conn.execute( + "DELETE FROM staging_lock_queue + WHERE resource NOT IN (SELECT resource FROM staging_locks)", + [], + )?; + + // Mark expired deployments + let deployments_marked = self.conn.execute( + "UPDATE staging_deployments SET status = 'expired' + WHERE status = 'deployed' AND ttl_expires_at < ?1", + params![now], + )?; + + // Get list of expired deployments that need cleanup commands + let mut stmt = self.conn.prepare( + "SELECT name, iac_type, deploy_command, stacks + FROM staging_deployments + WHERE status = 'expired' AND destroyed_at IS NULL", + )?; + + let expired_deployments: Vec = stmt + .query_map([], |row| { + Ok(ExpiredDeploymentInfo { + name: row.get(0)?, + iac_type: row.get(1)?, + deploy_command: row.get(2)?, + stacks: row.get(3)?, + }) + })? + .collect::, _>>()?; + + Ok(StagingCleanupResult { + locks_cleaned, + queue_entries_cleaned: queue_cleaned, + deployments_marked_expired: deployments_marked, + expired_deployments_pending_destroy: expired_deployments, + }) + } } #[cfg(test)] diff --git a/crates/blue-mcp/src/handlers/staging.rs b/crates/blue-mcp/src/handlers/staging.rs index da198da..e9098c6 100644 --- a/crates/blue-mcp/src/handlers/staging.rs +++ b/crates/blue-mcp/src/handlers/staging.rs @@ -359,6 +359,79 @@ pub fn handle_cost(args: &Value, repo_path: &std::path::Path) -> Result Result { + let status = args.get("status").and_then(|v| v.as_str()); + let check_expired = args + .get("check_expired") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // Check for expired deployments if requested + let expired = if check_expired { + state + .store + .mark_expired_deployments() + .map_err(|e| ServerError::CommandFailed(e.to_string()))? + } else { + vec![] + }; + + // List deployments with optional status filter + let deployments = state + .store + .list_staging_deployments(status) + .map_err(|e| ServerError::CommandFailed(e.to_string()))?; + + let deployed_count = deployments + .iter() + .filter(|d| d.status == "deployed") + .count(); + let expired_count = expired.len(); + + let hint = if check_expired && expired_count > 0 { + format!( + "{} staging deployment(s). {} newly expired and marked for cleanup.", + deployments.len(), + expired_count + ) + } else { + format!( + "{} staging deployment(s) ({} active).", + deployments.len(), + deployed_count + ) + }; + + Ok(json!({ + "status": "success", + "message": blue_core::voice::info( + &format!("{} staging deployment{}", deployments.len(), if deployments.len() == 1 { "" } else { "s" }), + Some(&hint) + ), + "deployments": deployments.iter().map(|d| json!({ + "name": d.name, + "iac_type": d.iac_type, + "deploy_command": d.deploy_command, + "stacks": d.stacks, + "deployed_by": d.deployed_by, + "deployed_at": d.deployed_at, + "ttl_expires_at": d.ttl_expires_at, + "status": d.status, + "destroyed_at": d.destroyed_at, + })).collect::>(), + "expired_count": expired_count, + "newly_expired": expired.iter().map(|d| json!({ + "name": d.name, + "iac_type": d.iac_type, + "ttl_expires_at": d.ttl_expires_at, + })).collect::>(), + })) +} + fn detect_cdk_stacks(path: &std::path::Path) -> Vec { let mut stacks = Vec::new(); diff --git a/crates/blue-mcp/src/server.rs b/crates/blue-mcp/src/server.rs index e0b6b08..8e03b84 100644 --- a/crates/blue-mcp/src/server.rs +++ b/crates/blue-mcp/src/server.rs @@ -849,6 +849,28 @@ impl BlueServer { } } }, + { + "name": "blue_staging_deployments", + "description": "List staging environment deployments. Shows deployed, destroyed, or expired environments. Use check_expired=true to mark expired deployments.", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + }, + "status": { + "type": "string", + "enum": ["deployed", "destroyed", "expired"], + "description": "Filter by deployment status" + }, + "check_expired": { + "type": "boolean", + "description": "Check for and mark expired deployments (default: false)" + } + } + } + }, { "name": "blue_audit", "description": "Check project health and find issues. Returns stalled work, missing ADRs, and recommendations.", @@ -1387,6 +1409,7 @@ impl BlueServer { "blue_staging_unlock" => self.handle_staging_unlock(&call.arguments), "blue_staging_status" => self.handle_staging_status(&call.arguments), "blue_staging_cleanup" => self.handle_staging_cleanup(&call.arguments), + "blue_staging_deployments" => self.handle_staging_deployments(&call.arguments), // Phase 6: Audit and completion handlers "blue_audit" => self.handle_audit(&call.arguments), "blue_rfc_complete" => self.handle_rfc_complete(&call.arguments), @@ -1955,6 +1978,13 @@ impl BlueServer { crate::handlers::staging::handle_cleanup(state, args) } + fn handle_staging_deployments(&mut self, args: &Option) -> Result { + let empty = json!({}); + let args = args.as_ref().unwrap_or(&empty); + let state = self.ensure_state()?; + crate::handlers::staging::handle_deployments(state, args) + } + // Phase 6: Audit and completion handlers fn handle_audit(&mut self, _args: &Option) -> Result {