Skip to main content

syncable_cli/agent/tools/platform/
trigger_deployment.rs

1//! Trigger deployment tool for the agent
2//!
3//! Allows the agent to trigger a deployment using a deployment config.
4
5use rig::completion::ToolDefinition;
6use rig::tool::Tool;
7use serde::{Deserialize, Serialize};
8use serde_json::json;
9
10use crate::agent::tools::error::{ErrorCategory, format_error_for_llm};
11use crate::platform::api::{PlatformApiClient, PlatformApiError, TriggerDeploymentRequest};
12use crate::platform::session::PlatformSession;
13
14/// Arguments for the trigger deployment tool
15#[derive(Debug, Deserialize)]
16pub struct TriggerDeploymentArgs {
17    /// The deployment config ID to use
18    pub config_id: String,
19    /// Optional specific commit SHA to deploy
20    pub commit_sha: Option<String>,
21}
22
23/// Error type for trigger deployment operations
24#[derive(Debug, thiserror::Error)]
25#[error("Trigger deployment error: {0}")]
26pub struct TriggerDeploymentError(String);
27
28/// Tool to trigger a deployment using a deployment config
29///
30/// Starts a new deployment for the specified configuration. Returns a task ID
31/// that can be used to monitor deployment progress.
32#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33pub struct TriggerDeploymentTool;
34
35impl TriggerDeploymentTool {
36    /// Create a new TriggerDeploymentTool
37    pub fn new() -> Self {
38        Self
39    }
40}
41
42impl Tool for TriggerDeploymentTool {
43    const NAME: &'static str = "trigger_deployment";
44
45    type Error = TriggerDeploymentError;
46    type Args = TriggerDeploymentArgs;
47    type Output = String;
48
49    async fn definition(&self, _prompt: String) -> ToolDefinition {
50        ToolDefinition {
51            name: Self::NAME.to_string(),
52            description: r#"Trigger a deployment using a deployment configuration.
53
54Starts a new deployment for the specified config. Returns a task ID that can be
55used to monitor deployment progress with `get_deployment_status`.
56
57**Parameters:**
58- config_id: The deployment config ID (get from list_deployment_configs or create_deployment_config)
59- commit_sha: Optional specific commit to deploy (defaults to latest on branch)
60
61**Prerequisites:**
62- User must be authenticated via `sync-ctl auth login`
63- A deployment config must exist (use create_deployment_config first if needed)
64
65**Use Cases:**
66- Deploy the latest code from a branch
67- Deploy a specific commit version
68- Trigger a manual deployment for a service
69
70**Returns:**
71- task_id: Use this to check deployment progress with get_deployment_status
72- status: Initial deployment status
73- message: Human-readable status message"#
74                .to_string(),
75            parameters: json!({
76                "type": "object",
77                "properties": {
78                    "config_id": {
79                        "type": "string",
80                        "description": "The deployment config ID (from list_deployment_configs or create_deployment_config)"
81                    },
82                    "commit_sha": {
83                        "type": "string",
84                        "description": "Optional: specific commit SHA to deploy (defaults to latest)"
85                    }
86                },
87                "required": ["config_id"]
88            }),
89        }
90    }
91
92    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
93        // Validate config_id
94        if args.config_id.trim().is_empty() {
95            return Ok(format_error_for_llm(
96                "trigger_deployment",
97                ErrorCategory::ValidationFailed,
98                "config_id cannot be empty",
99                Some(vec![
100                    "Use list_deployment_configs to find available deployment configs",
101                ]),
102            ));
103        }
104
105        // Load project_id from session (authoritative source)
106        let session = match PlatformSession::load() {
107            Ok(s) => s,
108            Err(_) => {
109                return Ok(format_error_for_llm(
110                    "trigger_deployment",
111                    ErrorCategory::InternalError,
112                    "Failed to load platform session",
113                    Some(vec!["Try authenticating with `sync-ctl auth login`"]),
114                ));
115            }
116        };
117
118        if !session.is_project_selected() {
119            return Ok(format_error_for_llm(
120                "trigger_deployment",
121                ErrorCategory::ValidationFailed,
122                "No project selected",
123                Some(vec!["Use select_project to choose a project first"]),
124            ));
125        }
126
127        let project_id = session.project_id.clone().unwrap_or_default();
128
129        // Create the API client
130        let client = match PlatformApiClient::new() {
131            Ok(c) => c,
132            Err(e) => {
133                return Ok(format_api_error("trigger_deployment", e));
134            }
135        };
136
137        // Build the request
138        let request = TriggerDeploymentRequest {
139            project_id,
140            config_id: args.config_id.clone(),
141            commit_sha: args.commit_sha.clone(),
142        };
143
144        // Trigger the deployment
145        match client.trigger_deployment(&request).await {
146            Ok(response) => {
147                let result = json!({
148                    "success": true,
149                    "task_id": response.backstage_task_id,
150                    "config_id": response.config_id,
151                    "status": response.status,
152                    "message": response.message,
153                    "next_steps": [
154                        format!("Use get_deployment_status with task_id '{}' to monitor progress", response.backstage_task_id),
155                        "Deployment typically takes 2-5 minutes to complete"
156                    ]
157                });
158
159                serde_json::to_string_pretty(&result)
160                    .map_err(|e| TriggerDeploymentError(format!("Failed to serialize: {}", e)))
161            }
162            Err(e) => Ok(format_api_error("trigger_deployment", e)),
163        }
164    }
165}
166
167/// Format a PlatformApiError for LLM consumption
168fn format_api_error(tool_name: &str, error: PlatformApiError) -> String {
169    match error {
170        PlatformApiError::Unauthorized => format_error_for_llm(
171            tool_name,
172            ErrorCategory::PermissionDenied,
173            "Not authenticated - please run `sync-ctl auth login` first",
174            Some(vec![
175                "The user needs to authenticate with the Syncable platform",
176                "Run: sync-ctl auth login",
177            ]),
178        ),
179        PlatformApiError::NotFound(msg) => format_error_for_llm(
180            tool_name,
181            ErrorCategory::ResourceUnavailable,
182            &format!("Resource not found: {}", msg),
183            Some(vec![
184                "The project ID or config ID may be incorrect",
185                "Use list_deployment_configs to find valid config IDs",
186            ]),
187        ),
188        PlatformApiError::PermissionDenied(msg) => format_error_for_llm(
189            tool_name,
190            ErrorCategory::PermissionDenied,
191            &format!("Permission denied: {}", msg),
192            Some(vec![
193                "The user does not have permission to trigger deployments",
194                "Contact the project admin for access",
195            ]),
196        ),
197        PlatformApiError::RateLimited => format_error_for_llm(
198            tool_name,
199            ErrorCategory::ResourceUnavailable,
200            "Rate limit exceeded - please try again later",
201            Some(vec!["Wait a moment before retrying"]),
202        ),
203        PlatformApiError::HttpError(e) => format_error_for_llm(
204            tool_name,
205            ErrorCategory::NetworkError,
206            &format!("Network error: {}", e),
207            Some(vec![
208                "Check network connectivity",
209                "The Syncable API may be temporarily unavailable",
210            ]),
211        ),
212        PlatformApiError::ParseError(msg) => format_error_for_llm(
213            tool_name,
214            ErrorCategory::InternalError,
215            &format!("Failed to parse API response: {}", msg),
216            Some(vec!["This may be a temporary API issue"]),
217        ),
218        PlatformApiError::ApiError { status, message } => format_error_for_llm(
219            tool_name,
220            ErrorCategory::ExternalCommandFailed,
221            &format!("API error ({}): {}", status, message),
222            Some(vec!["Check the error message for details"]),
223        ),
224        PlatformApiError::ServerError { status, message } => format_error_for_llm(
225            tool_name,
226            ErrorCategory::ExternalCommandFailed,
227            &format!("Server error ({}): {}", status, message),
228            Some(vec![
229                "The Syncable API is experiencing issues",
230                "Try again later",
231            ]),
232        ),
233        PlatformApiError::ConnectionFailed => format_error_for_llm(
234            tool_name,
235            ErrorCategory::NetworkError,
236            "Could not connect to Syncable API",
237            Some(vec![
238                "Check your internet connection",
239                "The Syncable API may be temporarily unavailable",
240            ]),
241        ),
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_tool_name() {
251        assert_eq!(TriggerDeploymentTool::NAME, "trigger_deployment");
252    }
253
254    #[test]
255    fn test_tool_creation() {
256        let tool = TriggerDeploymentTool::new();
257        assert!(format!("{:?}", tool).contains("TriggerDeploymentTool"));
258    }
259}