Skip to main content

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) =
137                    if let (Some(project_id), Some(service_name)) =
138                        (&args.project_id, &args.service_name)
139                    {
140                        match client.list_deployments(project_id, Some(10)).await {
141                            Ok(paginated) => {
142                                // Find the deployment for this service
143                                let deployment = paginated
144                                    .data
145                                    .iter()
146                                    .find(|d| d.service_name.eq_ignore_ascii_case(service_name));
147
148                                match deployment {
149                                    Some(d) => (
150                                        Some(d.status.clone()),
151                                        d.public_url.clone(),
152                                        d.public_url.is_some() && d.status == "running",
153                                    ),
154                                    None => (None, None, false),
155                                }
156                            }
157                            Err(_) => (None, None, false),
158                        }
159                    } else {
160                        (None, None, false)
161                    };
162
163                // True completion = task done AND (service has URL or no service check requested)
164                let truly_ready = if args.project_id.is_some() {
165                    service_ready
166                } else {
167                    is_healthy
168                };
169
170                let mut result = json!({
171                    "success": true,
172                    "task_id": args.task_id,
173                    "task_status": status.status,
174                    "task_progress": status.progress,
175                    "current_step": status.current_step,
176                    "overall_status": status.overall_status,
177                    "overall_message": status.overall_message,
178                    "task_complete": task_complete,
179                    "is_failed": is_failed,
180                    "service_ready": truly_ready
181                });
182
183                // Add service-specific info if we checked
184                if let Some(svc_status) = service_status {
185                    result["service_status"] = json!(svc_status);
186                }
187                if let Some(url) = &public_url {
188                    result["public_url"] = json!(url);
189                }
190
191                // Add error details if failed
192                if let Some(error) = &status.error {
193                    result["error"] = json!(error);
194                }
195
196                // Add next steps based on actual status
197                // IMPORTANT: Guide agent to STOP polling and inform user
198                if is_failed {
199                    result["next_steps"] = json!([
200                        "STOP - Deployment failed. Inform the user of the error.",
201                        "Review the error message for details",
202                        "Check the deployment configuration",
203                        "Verify the code builds successfully locally"
204                    ]);
205                    result["action"] = json!("STOP_POLLING");
206                } else if truly_ready && public_url.is_some() {
207                    result["next_steps"] = json!([
208                        format!(
209                            "STOP - Service is live at: {}",
210                            public_url.as_ref().unwrap()
211                        ),
212                        "Deployment completed successfully!",
213                        "Inform the user their service is ready"
214                    ]);
215                    result["action"] = json!("STOP_POLLING");
216                } else if task_complete && !truly_ready {
217                    result["next_steps"] = json!([
218                        "STOP POLLING - Inform the user that deployment is in progress",
219                        "Infrastructure is ready, Cloud Runner is building the container",
220                        "Tell the user to wait 1-2 minutes, then they can ask you to check status again",
221                        "DO NOT call get_deployment_status again automatically - wait for user to ask"
222                    ]);
223                    result["action"] = json!("INFORM_USER_AND_WAIT");
224                    result["estimated_wait"] = json!("1-2 minutes");
225                    result["note"] = json!(
226                        "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."
227                    );
228                } else if !task_complete {
229                    result["next_steps"] = json!([
230                        format!(
231                            "STOP POLLING - Deployment is {} ({}% complete)",
232                            status.overall_status, status.progress
233                        ),
234                        "Inform the user of current progress",
235                        "Tell them to wait and ask again in 30 seconds if they want an update",
236                        "DO NOT call get_deployment_status again automatically"
237                    ]);
238                    result["action"] = json!("INFORM_USER_AND_WAIT");
239                }
240
241                serde_json::to_string_pretty(&result)
242                    .map_err(|e| GetDeploymentStatusError(format!("Failed to serialize: {}", e)))
243            }
244            Err(e) => Ok(format_api_error("get_deployment_status", e)),
245        }
246    }
247}
248
249/// Format a PlatformApiError for LLM consumption
250fn format_api_error(tool_name: &str, error: PlatformApiError) -> String {
251    match error {
252        PlatformApiError::Unauthorized => format_error_for_llm(
253            tool_name,
254            ErrorCategory::PermissionDenied,
255            "Not authenticated - please run `sync-ctl auth login` first",
256            Some(vec![
257                "The user needs to authenticate with the Syncable platform",
258                "Run: sync-ctl auth login",
259            ]),
260        ),
261        PlatformApiError::NotFound(msg) => format_error_for_llm(
262            tool_name,
263            ErrorCategory::ResourceUnavailable,
264            &format!("Deployment task not found: {}", msg),
265            Some(vec![
266                "The task_id may be incorrect or expired",
267                "Use trigger_deployment to start a new deployment",
268            ]),
269        ),
270        PlatformApiError::PermissionDenied(msg) => format_error_for_llm(
271            tool_name,
272            ErrorCategory::PermissionDenied,
273            &format!("Permission denied: {}", msg),
274            Some(vec![
275                "The user does not have access to this deployment",
276                "Contact the project admin for access",
277            ]),
278        ),
279        PlatformApiError::RateLimited => format_error_for_llm(
280            tool_name,
281            ErrorCategory::ResourceUnavailable,
282            "Rate limit exceeded - please try again later",
283            Some(vec!["Wait a moment before retrying"]),
284        ),
285        PlatformApiError::HttpError(e) => format_error_for_llm(
286            tool_name,
287            ErrorCategory::NetworkError,
288            &format!("Network error: {}", e),
289            Some(vec![
290                "Check network connectivity",
291                "The Syncable API may be temporarily unavailable",
292            ]),
293        ),
294        PlatformApiError::ParseError(msg) => format_error_for_llm(
295            tool_name,
296            ErrorCategory::InternalError,
297            &format!("Failed to parse API response: {}", msg),
298            Some(vec!["This may be a temporary API issue"]),
299        ),
300        PlatformApiError::ApiError { status, message } => format_error_for_llm(
301            tool_name,
302            ErrorCategory::ExternalCommandFailed,
303            &format!("API error ({}): {}", status, message),
304            Some(vec!["Check the error message for details"]),
305        ),
306        PlatformApiError::ServerError { status, message } => format_error_for_llm(
307            tool_name,
308            ErrorCategory::ExternalCommandFailed,
309            &format!("Server error ({}): {}", status, message),
310            Some(vec![
311                "The Syncable API is experiencing issues",
312                "Try again later",
313            ]),
314        ),
315        PlatformApiError::ConnectionFailed => format_error_for_llm(
316            tool_name,
317            ErrorCategory::NetworkError,
318            "Could not connect to Syncable API",
319            Some(vec![
320                "Check your internet connection",
321                "The Syncable API may be temporarily unavailable",
322            ]),
323        ),
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn test_tool_name() {
333        assert_eq!(GetDeploymentStatusTool::NAME, "get_deployment_status");
334    }
335
336    #[test]
337    fn test_tool_creation() {
338        let tool = GetDeploymentStatusTool::new();
339        assert!(format!("{:?}", tool).contains("GetDeploymentStatusTool"));
340    }
341}