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