syncable_cli/agent/tools/platform/
get_deployment_status.rs

1//! Get deployment status tool for the agent
2//!
3//! Allows the agent to check the status of a deployment task.
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};
12
13/// Arguments for the get deployment status tool
14#[derive(Debug, Deserialize)]
15pub struct GetDeploymentStatusArgs {
16    /// The task ID to check status for
17    pub task_id: String,
18    /// Optional project ID to check actual deployment status (for public_url)
19    pub project_id: Option<String>,
20    /// Optional service name to find the specific deployment
21    pub service_name: Option<String>,
22}
23
24/// Error type for get deployment status operations
25#[derive(Debug, thiserror::Error)]
26#[error("Get deployment status error: {0}")]
27pub struct GetDeploymentStatusError(String);
28
29/// Tool to get deployment task status
30///
31/// Returns the current status of a deployment including progress percentage,
32/// current step, and overall status.
33#[derive(Debug, Clone, Serialize, Deserialize, Default)]
34pub struct GetDeploymentStatusTool;
35
36impl GetDeploymentStatusTool {
37    /// Create a new GetDeploymentStatusTool
38    pub fn new() -> Self {
39        Self
40    }
41}
42
43impl Tool for GetDeploymentStatusTool {
44    const NAME: &'static str = "get_deployment_status";
45
46    type Error = GetDeploymentStatusError;
47    type Args = GetDeploymentStatusArgs;
48    type Output = String;
49
50    async fn definition(&self, _prompt: String) -> ToolDefinition {
51        ToolDefinition {
52            name: Self::NAME.to_string(),
53            description: r#"Get the status of a deployment task and optionally check the actual service status.
54
55Returns the current status of a deployment, including progress percentage,
56current step, overall status, and optionally the public URL if the service is ready.
57
58**CRITICAL - DO NOT POLL IN A LOOP:**
59After checking status, you MUST inform the user and WAIT for them to ask again.
60DO NOT call this tool repeatedly in succession. Deployments take 1-3 minutes.
61The response includes an "action" field - follow it:
62- "STOP_POLLING": Deployment is done (success or failure). Tell the user.
63- "INFORM_USER_AND_WAIT": Tell user the current status and wait for them to ask for updates.
64
65**IMPORTANT for Cloud Runner:**
66The task may show "completed" when infrastructure is provisioned, but the actual
67service build and deployment takes longer. Pass project_id and service_name to
68also check if the service has a public URL (meaning it's actually ready).
69
70**Status Values:**
71- Task status: "processing", "completed", "failed"
72- Overall status: "generating", "building", "deploying", "healthy", "failed"
73- Service ready: Only when public_url is available
74
75**Prerequisites:**
76- User must be authenticated via `sync-ctl auth login`
77- A deployment must have been triggered (use trigger_deployment first)
78
79**Use Cases:**
80- Check deployment status ONCE after triggering, then inform user
81- Let user ask for updates when they want them
82- Get error details if deployment failed"#
83                .to_string(),
84            parameters: json!({
85                "type": "object",
86                "properties": {
87                    "task_id": {
88                        "type": "string",
89                        "description": "The deployment task ID (from trigger_deployment response)"
90                    },
91                    "project_id": {
92                        "type": "string",
93                        "description": "Optional: Project ID to check actual service status and public URL"
94                    },
95                    "service_name": {
96                        "type": "string",
97                        "description": "Optional: Service name to find the specific deployment"
98                    }
99                },
100                "required": ["task_id"]
101            }),
102        }
103    }
104
105    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
106        // Validate task_id
107        if args.task_id.trim().is_empty() {
108            return Ok(format_error_for_llm(
109                "get_deployment_status",
110                ErrorCategory::ValidationFailed,
111                "task_id cannot be empty",
112                Some(vec![
113                    "Use trigger_deployment to start a deployment and get a task_id",
114                    "Use list_deployments to find previous deployment task IDs",
115                ]),
116            ));
117        }
118
119        // Create the API client
120        let client = match PlatformApiClient::new() {
121            Ok(c) => c,
122            Err(e) => {
123                return Ok(format_api_error("get_deployment_status", e));
124            }
125        };
126
127        // Get the deployment status (Backstage task)
128        match client.get_deployment_status(&args.task_id).await {
129            Ok(status) => {
130                let task_complete = status.status == "completed";
131                let is_failed = status.status == "failed" || status.overall_status == "failed";
132                let is_healthy = status.overall_status == "healthy";
133
134                // Also check actual deployment if project_id and service_name provided
135                // This is crucial for Cloud Runner where task completes but service takes longer
136                let (service_status, public_url, service_ready) = if let (Some(project_id), Some(service_name)) = (&args.project_id, &args.service_name) {
137                    match client.list_deployments(project_id, Some(10)).await {
138                        Ok(paginated) => {
139                            // Find the deployment for this service
140                            let deployment = paginated.data.iter()
141                                .find(|d| d.service_name.eq_ignore_ascii_case(service_name));
142
143                            match deployment {
144                                Some(d) => (
145                                    Some(d.status.clone()),
146                                    d.public_url.clone(),
147                                    d.public_url.is_some() && d.status == "running"
148                                ),
149                                None => (None, None, false)
150                            }
151                        }
152                        Err(_) => (None, None, false)
153                    }
154                } else {
155                    (None, None, false)
156                };
157
158                // True completion = task done AND (service has URL or no service check requested)
159                let truly_ready = if args.project_id.is_some() {
160                    service_ready
161                } else {
162                    is_healthy
163                };
164
165                let mut result = json!({
166                    "success": true,
167                    "task_id": args.task_id,
168                    "task_status": status.status,
169                    "task_progress": status.progress,
170                    "current_step": status.current_step,
171                    "overall_status": status.overall_status,
172                    "overall_message": status.overall_message,
173                    "task_complete": task_complete,
174                    "is_failed": is_failed,
175                    "service_ready": truly_ready
176                });
177
178                // Add service-specific info if we checked
179                if let Some(svc_status) = service_status {
180                    result["service_status"] = json!(svc_status);
181                }
182                if let Some(url) = &public_url {
183                    result["public_url"] = json!(url);
184                }
185
186                // Add error details if failed
187                if let Some(error) = &status.error {
188                    result["error"] = json!(error);
189                }
190
191                // Add next steps based on actual status
192                // IMPORTANT: Guide agent to STOP polling and inform user
193                if is_failed {
194                    result["next_steps"] = json!([
195                        "STOP - Deployment failed. Inform the user of the error.",
196                        "Review the error message for details",
197                        "Check the deployment configuration",
198                        "Verify the code builds successfully locally"
199                    ]);
200                    result["action"] = json!("STOP_POLLING");
201                } else if truly_ready && public_url.is_some() {
202                    result["next_steps"] = json!([
203                        format!("STOP - Service is live at: {}", public_url.as_ref().unwrap()),
204                        "Deployment completed successfully!",
205                        "Inform the user their service is ready"
206                    ]);
207                    result["action"] = json!("STOP_POLLING");
208                } else if task_complete && !truly_ready {
209                    result["next_steps"] = json!([
210                        "STOP POLLING - Inform the user that deployment is in progress",
211                        "Infrastructure is ready, Cloud Runner is building the container",
212                        "Tell the user to wait 1-2 minutes, then they can ask you to check status again",
213                        "DO NOT call get_deployment_status again automatically - wait for user to ask"
214                    ]);
215                    result["action"] = json!("INFORM_USER_AND_WAIT");
216                    result["estimated_wait"] = json!("1-2 minutes");
217                    result["note"] = json!("Task shows 100% but container is still being built/deployed. This is normal. DO NOT poll repeatedly - inform the user and wait for them to ask for status.");
218                } else if !task_complete {
219                    result["next_steps"] = json!([
220                        format!("STOP POLLING - Deployment is {} ({}% complete)", status.overall_status, status.progress),
221                        "Inform the user of current progress",
222                        "Tell them to wait and ask again in 30 seconds if they want an update",
223                        "DO NOT call get_deployment_status again automatically"
224                    ]);
225                    result["action"] = json!("INFORM_USER_AND_WAIT");
226                }
227
228                serde_json::to_string_pretty(&result)
229                    .map_err(|e| GetDeploymentStatusError(format!("Failed to serialize: {}", e)))
230            }
231            Err(e) => Ok(format_api_error("get_deployment_status", e)),
232        }
233    }
234}
235
236/// Format a PlatformApiError for LLM consumption
237fn format_api_error(tool_name: &str, error: PlatformApiError) -> String {
238    match error {
239        PlatformApiError::Unauthorized => format_error_for_llm(
240            tool_name,
241            ErrorCategory::PermissionDenied,
242            "Not authenticated - please run `sync-ctl auth login` first",
243            Some(vec![
244                "The user needs to authenticate with the Syncable platform",
245                "Run: sync-ctl auth login",
246            ]),
247        ),
248        PlatformApiError::NotFound(msg) => format_error_for_llm(
249            tool_name,
250            ErrorCategory::ResourceUnavailable,
251            &format!("Deployment task not found: {}", msg),
252            Some(vec![
253                "The task_id may be incorrect or expired",
254                "Use trigger_deployment to start a new deployment",
255            ]),
256        ),
257        PlatformApiError::PermissionDenied(msg) => format_error_for_llm(
258            tool_name,
259            ErrorCategory::PermissionDenied,
260            &format!("Permission denied: {}", msg),
261            Some(vec![
262                "The user does not have access to this deployment",
263                "Contact the project admin for access",
264            ]),
265        ),
266        PlatformApiError::RateLimited => format_error_for_llm(
267            tool_name,
268            ErrorCategory::ResourceUnavailable,
269            "Rate limit exceeded - please try again later",
270            Some(vec!["Wait a moment before retrying"]),
271        ),
272        PlatformApiError::HttpError(e) => format_error_for_llm(
273            tool_name,
274            ErrorCategory::NetworkError,
275            &format!("Network error: {}", e),
276            Some(vec![
277                "Check network connectivity",
278                "The Syncable API may be temporarily unavailable",
279            ]),
280        ),
281        PlatformApiError::ParseError(msg) => format_error_for_llm(
282            tool_name,
283            ErrorCategory::InternalError,
284            &format!("Failed to parse API response: {}", msg),
285            Some(vec!["This may be a temporary API issue"]),
286        ),
287        PlatformApiError::ApiError { status, message } => format_error_for_llm(
288            tool_name,
289            ErrorCategory::ExternalCommandFailed,
290            &format!("API error ({}): {}", status, message),
291            Some(vec!["Check the error message for details"]),
292        ),
293        PlatformApiError::ServerError { status, message } => format_error_for_llm(
294            tool_name,
295            ErrorCategory::ExternalCommandFailed,
296            &format!("Server error ({}): {}", status, message),
297            Some(vec![
298                "The Syncable API is experiencing issues",
299                "Try again later",
300            ]),
301        ),
302        PlatformApiError::ConnectionFailed => format_error_for_llm(
303            tool_name,
304            ErrorCategory::NetworkError,
305            "Could not connect to Syncable API",
306            Some(vec![
307                "Check your internet connection",
308                "The Syncable API may be temporarily unavailable",
309            ]),
310        ),
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_tool_name() {
320        assert_eq!(GetDeploymentStatusTool::NAME, "get_deployment_status");
321    }
322
323    #[test]
324    fn test_tool_creation() {
325        let tool = GetDeploymentStatusTool::new();
326        assert!(format!("{:?}", tool).contains("GetDeploymentStatusTool"));
327    }
328}