feat: Phase 10 - staging deployments tool (55 tools total)
Add blue_staging_deployments tool to list and track staging environment deployments with TTL-based expiration. blue-core: - Add staging_deployments table schema - Add StagingDeployment, StagingCleanupResult, ExpiredDeploymentInfo structs - Add record/list/mark_expired staging deployment store methods blue-mcp: - Add handle_deployments to staging handler - Add tool definition and dispatch for blue_staging_deployments Remaining to port: code_index and code_search (require tree-sitter infra) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6969a9caff
commit
4ecaeea6ad
3 changed files with 381 additions and 0 deletions
|
|
@ -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_locks_resource ON staging_locks(resource);
|
||||||
CREATE INDEX IF NOT EXISTS idx_staging_queue_resource ON staging_lock_queue(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
|
/// 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<i64>,
|
||||||
|
pub name: String,
|
||||||
|
pub iac_type: String,
|
||||||
|
pub deploy_command: String,
|
||||||
|
pub stacks: Option<String>,
|
||||||
|
pub deployed_by: String,
|
||||||
|
pub agent_id: Option<String>,
|
||||||
|
pub deployed_at: String,
|
||||||
|
pub ttl_expires_at: String,
|
||||||
|
pub status: String,
|
||||||
|
pub destroyed_at: Option<String>,
|
||||||
|
pub metadata: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<ExpiredDeploymentInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 {
|
||||||
|
|
@ -1640,6 +1697,227 @@ impl DocumentStore {
|
||||||
Ok((locks_cleaned, queue_cleaned))
|
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<StagingDeployment, StoreError> {
|
||||||
|
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<Vec<StagingDeployment>, 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::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(StoreError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get expired deployments that need cleanup
|
||||||
|
pub fn get_expired_deployments(&self) -> Result<Vec<StagingDeployment>, 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::<Result<Vec<_>, _>>()
|
||||||
|
.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<Vec<StagingDeployment>, 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<Option<StagingDeployment>, 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<StagingDeployment> {
|
||||||
|
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<StagingCleanupResult, StoreError> {
|
||||||
|
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<ExpiredDeploymentInfo> = 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::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
Ok(StagingCleanupResult {
|
||||||
|
locks_cleaned,
|
||||||
|
queue_entries_cleaned: queue_cleaned,
|
||||||
|
deployments_marked_expired: deployments_marked,
|
||||||
|
expired_deployments_pending_destroy: expired_deployments,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,79 @@ pub fn handle_cost(args: &Value, repo_path: &std::path::Path) -> Result<Value, S
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle blue_staging_deployments
|
||||||
|
///
|
||||||
|
/// Lists staging deployments with optional filtering.
|
||||||
|
/// Can also check for expired deployments that need cleanup.
|
||||||
|
pub fn handle_deployments(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
|
||||||
|
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::<Vec<_>>(),
|
||||||
|
"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::<Vec<_>>(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
fn detect_cdk_stacks(path: &std::path::Path) -> Vec<String> {
|
fn detect_cdk_stacks(path: &std::path::Path) -> Vec<String> {
|
||||||
let mut stacks = Vec::new();
|
let mut stacks = Vec::new();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"name": "blue_audit",
|
||||||
"description": "Check project health and find issues. Returns stalled work, missing ADRs, and recommendations.",
|
"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_unlock" => self.handle_staging_unlock(&call.arguments),
|
||||||
"blue_staging_status" => self.handle_staging_status(&call.arguments),
|
"blue_staging_status" => self.handle_staging_status(&call.arguments),
|
||||||
"blue_staging_cleanup" => self.handle_staging_cleanup(&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
|
// Phase 6: Audit and completion handlers
|
||||||
"blue_audit" => self.handle_audit(&call.arguments),
|
"blue_audit" => self.handle_audit(&call.arguments),
|
||||||
"blue_rfc_complete" => self.handle_rfc_complete(&call.arguments),
|
"blue_rfc_complete" => self.handle_rfc_complete(&call.arguments),
|
||||||
|
|
@ -1955,6 +1978,13 @@ impl BlueServer {
|
||||||
crate::handlers::staging::handle_cleanup(state, args)
|
crate::handlers::staging::handle_cleanup(state, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_staging_deployments(&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::staging::handle_deployments(state, args)
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 6: Audit and completion handlers
|
// Phase 6: Audit and completion handlers
|
||||||
|
|
||||||
fn handle_audit(&mut self, _args: &Option<Value>) -> Result<Value, ServerError> {
|
fn handle_audit(&mut self, _args: &Option<Value>) -> Result<Value, ServerError> {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue